Skip to content

Commit 60edf90

Browse files
test: add exit code tests for threaded child watcher fallback
Add tests to verify that non-zero exit codes and signal termination are correctly reported when using the threaded waitpid() fallback path (when pidfd is unavailable). Includes a concurrent test that starts multiple processes, terminates them in a controlled order, and verifies that the correct exit status is attributed to each process, and at the expected time. Assisted-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 5c4b1a6 commit 60edf90

File tree

1 file changed

+79
-0
lines changed

1 file changed

+79
-0
lines changed

test/pytest/test_transport.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,85 @@ async def test_pidfd_ENOSYS(self, monkeypatch: pytest.MonkeyPatch) -> None:
294294
protocol, _transport = self.subprocess(['true'])
295295
await protocol.eof_and_exited_with_code(0)
296296

297+
@pytest.mark.asyncio
298+
async def test_pidfd_ENOSYS_nonzero_exit(self, monkeypatch: pytest.MonkeyPatch) -> None:
299+
# test that non-zero exit codes are correctly reported via the threaded fallback path
300+
monkeypatch.setattr(os, 'pidfd_open', unittest.mock.Mock(side_effect=OSError), raising=False)
301+
protocol, _transport = self.subprocess(['false'])
302+
await protocol.eof_and_exited_with_code(1)
303+
304+
@pytest.mark.asyncio
305+
async def test_pidfd_ENOSYS_exit_code(self, monkeypatch: pytest.MonkeyPatch) -> None:
306+
# test that specific exit codes are correctly reported via the threaded fallback path
307+
monkeypatch.setattr(os, 'pidfd_open', unittest.mock.Mock(side_effect=OSError), raising=False)
308+
protocol, _transport = self.subprocess(['sh', '-c', 'exit 42'])
309+
await protocol.eof_and_exited_with_code(42)
310+
311+
@pytest.mark.asyncio
312+
async def test_pidfd_ENOSYS_signal(self, monkeypatch: pytest.MonkeyPatch) -> None:
313+
# test that signal termination is correctly reported via the threaded fallback path
314+
monkeypatch.setattr(os, 'pidfd_open', unittest.mock.Mock(side_effect=OSError), raising=False)
315+
protocol, transport = self.subprocess(['cat'])
316+
transport.send_signal(signal.SIGTERM)
317+
await protocol.eof_and_exited_with_code(-signal.SIGTERM)
318+
319+
@pytest.mark.asyncio
320+
async def test_pidfd_ENOSYS_kill(self, monkeypatch: pytest.MonkeyPatch) -> None:
321+
# test that SIGKILL is correctly reported via the threaded fallback path
322+
monkeypatch.setattr(os, 'pidfd_open', unittest.mock.Mock(side_effect=OSError), raising=False)
323+
protocol, transport = self.subprocess(['cat'])
324+
transport.kill()
325+
await protocol.eof_and_exited_with_code(-signal.SIGKILL)
326+
327+
@pytest.mark.asyncio
328+
async def test_pidfd_ENOSYS_concurrent(self, monkeypatch: pytest.MonkeyPatch) -> None:
329+
# test multiple concurrent subprocesses with different exit scenarios
330+
# using the threaded fallback path, to ensure exit statuses don't get mixed up
331+
monkeypatch.setattr(os, 'pidfd_open', unittest.mock.Mock(side_effect=OSError), raising=False)
332+
333+
# start processes that block on stdin - we control when they exit
334+
proto_0, transport_0 = self.subprocess(['sh', '-c', 'read a; exit 0'])
335+
proto_1, transport_1 = self.subprocess(['sh', '-c', 'read a; exit 1'])
336+
proto_42, transport_42 = self.subprocess(['sh', '-c', 'read a; exit 42'])
337+
proto_term, transport_term = self.subprocess(['sh', '-c', 'read a; exit 99'])
338+
proto_kill, transport_kill = self.subprocess(['sh', '-c', 'read a; exit 99'])
339+
340+
# create tasks for each process
341+
task_0 = asyncio.create_task(proto_0.eof_and_exited_with_code(0))
342+
task_1 = asyncio.create_task(proto_1.eof_and_exited_with_code(1))
343+
task_42 = asyncio.create_task(proto_42.eof_and_exited_with_code(42))
344+
task_term = asyncio.create_task(proto_term.eof_and_exited_with_code(-signal.SIGTERM))
345+
task_kill = asyncio.create_task(proto_kill.eof_and_exited_with_code(-signal.SIGKILL))
346+
pending = {task_0, task_1, task_42, task_term, task_kill}
347+
348+
# exit them one by one in a specific order and verify each time
349+
transport_kill.kill()
350+
done, pending = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED)
351+
assert done == {task_kill}
352+
task_kill.result()
353+
354+
transport_term.send_signal(signal.SIGTERM)
355+
done, pending = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED)
356+
assert done == {task_term}
357+
task_term.result()
358+
359+
transport_42.write_eof()
360+
done, pending = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED)
361+
assert done == {task_42}
362+
task_42.result()
363+
364+
transport_1.write_eof()
365+
done, pending = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED)
366+
assert done == {task_1}
367+
task_1.result()
368+
369+
transport_0.write_eof()
370+
done, pending = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED)
371+
assert done == {task_0}
372+
task_0.result()
373+
374+
assert not pending
375+
297376
@pytest.mark.asyncio
298377
async def test_true_pty(self) -> None:
299378
loop = asyncio.get_running_loop()

0 commit comments

Comments
 (0)