Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions scripts/ci-installed-journeys.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ def observer_failure(phase, error, roots=(), limit=480):
'finite journey outside process identity is unavailable',
'finite journey process census differs',
'finite journey process changed during pidfd binding',
'finite journey process wait is unavailable',
'finite journey process exit is unavailable',
'finite journey process identity changed',
'finite journey process identity is unavailable',
Expand Down Expand Up @@ -250,10 +251,12 @@ def same_process(left, right):
("pid", "uid", "start_ticks", "executable", "device", "inode"))


def pidfd_exited(fd):
def pidfd_exited(fd, timeout=0):
poll = select.poll()
poll.register(fd, select.POLLIN)
events = poll.poll(0)
if type(timeout) is not int or not 0 <= timeout <= 250:
raise RuntimeError("finite journey process wait is unavailable")
events = poll.poll(timeout)
# Reaped processes can also report HUP. Readability still proves the
# flags=0 pidfd's whole thread group exited; HUP alone proves no exit.
if events not in ([], [(fd, select.POLLIN)], [(fd, select.POLLIN | select.POLLHUP)]):
Expand Down Expand Up @@ -528,7 +531,10 @@ def observe_once(self, require_runtime=True):
except ProcessLookupError:
continue
try:
if pidfd_exited(fd):
# An exiting child loses /proc/<pid>/exe before its
# flags=0 pidfd reports whole-group exit. Wait out that
# short window once; a quiet pidfd still refuses.
if pidfd_exited(fd, timeout=250):
continue
finally:
os.close(fd)
Expand Down
47 changes: 47 additions & 0 deletions scripts/ci-release-policy-test.py
Original file line number Diff line number Diff line change
Expand Up @@ -2914,6 +2914,16 @@ def test_finite_z_leader_requires_retained_birth_and_whole_process_pidfd_exit(se
self.assertEqual(self.journey["pidfd_exited"](45), exited)
poll.register.assert_called_once_with(45, selector.POLLIN)
poll.poll.assert_called_once_with(0)
poll = mock.Mock()
poll.poll.return_value = [(45, selector.POLLIN)]
selector.poll.return_value = poll
with mock.patch.dict(self.globals, {"select": selector}):
self.assertTrue(self.journey["pidfd_exited"](45, timeout=250))
poll.poll.assert_called_once_with(250)
for timeout in (251, -1, True):
with mock.patch.dict(self.globals, {"select": selector}), \
self.assertRaisesRegex(RuntimeError, "process wait is unavailable"):
self.journey["pidfd_exited"](45, timeout=timeout)
for events in ([(45, selector.POLLHUP)], [(45, selector.POLLERR)], [(45, selector.POLLNVAL)],
[(45, 2)], [(45, selector.POLLIN | selector.POLLERR)],
[(45, selector.POLLIN | selector.POLLHUP | selector.POLLERR)],
Expand Down Expand Up @@ -3039,6 +3049,43 @@ def test_finite_census_accepts_same_birth_exec_and_never_rebinds_an_exited_pidfd
self.assertNotIn(103, owner.observe_once())
self.assertEqual(opened.call_count, len(rows))

def test_finite_census_waits_out_exit_race_and_still_refuses_a_quiet_pidfd(self):
for readable in (True, False):
with self.subTest(readable=readable), tempfile.TemporaryDirectory() as temp:
owner, _ = self.finite_owner(Path(temp))
row, reads = self.process_rows(owner)[100], []
outer = self.globals["bounded_text"]
child = owner.binding["ControlGroup"]

def census(path, *rest):
# The cgroup still lists the new pid after its exe vanished.
return ("100" if Path(path).name == "cgroup.procs" else
"0::" + (child if path.parent.name == "100" else "/outside"))

def exited(fd, timeout=0):
# Its pidfd stays quiet until the bounded whole-group wait.
reads.append((fd, timeout))
return readable and timeout == 250

def identity(pid, **_):
if pid == 100:
raise FileNotFoundError("fixture child exited")
return dict(row)

with mock.patch.dict(self.globals, {"bounded_text": census, "pidfd_exited": exited,
"linux_process_identity": identity}), \
mock.patch.object(os, "getuid", return_value=1001), \
mock.patch.object(os, "pidfd_open", return_value=1100, create=True), \
mock.patch.object(os, "close") as closed:
if readable:
self.assertEqual(owner.observe_once(require_runtime=False), {})
else:
with self.assertRaisesRegex(RuntimeError, "live child identity is unavailable"):
owner.observe_once(require_runtime=False)
self.assertEqual(reads, [(1100, 250)])
closed.assert_called_once_with(1100)
self.assertEqual((owner.known, owner.fds), ({}, {}))

def test_finite_pidfd_binding_closes_on_identity_race_or_unreadable_child(self):
for fault in ("reused", "disappeared", "denied"):
with self.subTest(fault=fault), tempfile.TemporaryDirectory() as temp:
Expand Down
Loading