Linux automation engineers diagnosing a Python subprocess after a child exits while a descendant keeps stdout open.

The Child Exited. Why Is Python Still Waiting for Its Output?

Tue, Oct 13, 2026

After p.wait() has returned zero, Popen.returncode is zero. Yet communicate() still has not returned, even though the only intended output, PARENT followed by a newline, was written immediately. The missing event is not another process exit. It is end-of-file on the captured stdout pipe.

That distinction matters because a descendant can inherit the pipe’s write end. When the direct child terminates, its own descriptors close, but the kernel cannot report EOF while another process still owns a descriptor referring to that write end. The descendant does not need to write another byte. Merely keeping the descriptor open is enough to keep the reader waiting.

This article reproduces that state with a bounded Linux fixture and then separates four questions that automation commonly collapses into one: Did the direct child exit successfully? Did the captured stream reach EOF? Did the reader receive exactly the declared bytes? Was the synthetic process family cleaned up and reaped?

The decisive controls do not solve the problem by killing every process. One command makes the worker close only stdout while remaining alive. Another redirects the worker’s stdout at spawn so it never owns the captured stream. Those cases establish a repairable output-ownership contract: process completion, stream completion, content acceptance and cleanup evidence are related, but they are not interchangeable.

Define process completion and output completion separately

A process result is scoped to a process. In Python, Popen.wait() waits for the Popen child and sets its return code; Popen.returncode == 0 therefore establishes only that the direct child completed successfully under the application’s exit-status convention. It does not state that every descendant has exited, that every inherited descriptor has closed, or that captured output is complete. The subprocess documentation treats return codes and pipe communication as distinct parts of the interface.

For this experiment, acceptance requires three independent predicates:

  • Direct-child result: the launcher exits and its return code is exactly zero.

  • Output completion: the stdout reader reaches EOF rather than merely receiving some bytes.

  • Content acceptance: the complete capture is exactly b"PARENT\n".

Cleanup is a fourth predicate. It does not redefine the output contract, but it decides whether the laboratory result is trustworthy. Both deliberately orphaned workers must later exit, be reaped, and leave the supervisor’s previous subreaper setting restored.

A live worker is permitted in P3 and P4. That is intentional. The test is not “all descendants are dead”; it is “the declared producer has finished, no unintended process still owns the captured stream, and the exact expected bytes are complete.” Readers building the broader operational background can review Linux administration foundations, but the acceptance boundary here remains deliberately narrow: one direct child, one worker, one captured stdout pipe, one control socket and a fixed seven-byte payload.

Identify every owner of the pipe write end

A pipe is a kernel byte stream with separate read and write ends. Linux reports EOF to a reader only after all file descriptors referring to the pipe’s write end have been closed. Until then, an empty pipe may simply mean that no writer has produced another byte yet. The pipe(7) manual states both rules directly: reads from an empty pipe block while a potential writer remains, and EOF appears only when every write-end reference is gone.

In the held-writer family, ownership develops in this order:

  1. The supervisor creates the direct child with stdout=subprocess.PIPE. The supervisor owns the readable endpoint through p.stdout; the launcher receives the writable endpoint as file descriptor 1.

  2. The launcher starts a worker with stdout=None. For that nested Popen, None means no new redirection, so the worker inherits the launcher’s standard output.

  3. The launcher prints PARENT\n and exits. Its copy of the write end closes.

  4. The worker remains alive with its inherited file descriptor 1. No more bytes are required for that descriptor to prevent EOF.

This is the same ownership discipline that appears in other descriptor-lifetime problems, but the acceptance oracle is different. The nearby experiment on inherited descriptors and flock ownership asks when an advisory lock becomes available. Here there is no lock, inode or contender. The direct child must exit first, and the observable under test is pipe EOF plus exact captured bytes.

Separate standard-stream redirection from close_fds

close_fds=True is important, but it does not mean “close stdout.” On POSIX, the official Popen constructor documentation states that close_fds=True closes descriptors other than 0, 1 and 2 before the child executes. pass_fds names additional descriptors to keep open and forces close_fds=True. In this fixture, the private control socket is passed deliberately; the standard streams are handled separately.

That is why the held worker can have all unrelated descriptors closed and still retain the captured pipe: its connection is intentional standard-output inheritance. The repair is not to repeat close_fds=True more forcefully. The launcher must assign the worker’s stdout according to policy: inherit it only when the worker is a declared producer, redirect it to a different destination, or close it explicitly when continued output is forbidden.

Prepare a standalone Linux supervisor

Run the fixture only as a standalone, single-threaded process. It requires Linux, os.pidfd_open, and permission to read and change the calling process’s child-subreaper attribute through prctl. Unsupported platforms, missing APIs or failed prctl calls are HOLD, not degraded passes.

The subreaper setting exists solely so the supervisor can adopt and reap the deliberately orphaned worker after the launcher exits. Linux documents that an orphan is reparented to the nearest living ancestor marked as a child subreaper; that process can then receive termination status and wait for it. The script first reads the existing setting, enables subreaping, and restores the original value in finally. Because this setting changes which orphans the calling process may adopt, the fixture must not be embedded in a shared application server that supervises unrelated work. PR_SET_CHILD_SUBREAPER(2const) defines that adoption behavior.

Each worker reports its PID and process-group ID over a private socket. The supervisor immediately opens a pidfd for that worker. Python’s os.pidfd_open returns a descriptor referring to the process. The Linux pidfd_open(2) documentation states that select(), poll() and epoll() report a pidfd as readable when the referenced task terminates and becomes a zombie. That lets the fixture test “still alive” and “has exited” without repeatedly treating a bare numeric PID as its whole identity.

A pidfd is evidence about the worker process, not about its standard-output descriptor. A non-readable pidfd establishes that the referenced task has not yet terminated; it does not show whether file descriptor 1 is open, whether that descriptor still names the capture pipe, or whether the reader has reached EOF. The fixture therefore combines the pidfd check with the control-socket acknowledgement and the communicate() result. Removing any one of those observations would weaken the conclusion: liveness alone cannot diagnose a retained writer, and EOF alone cannot prove which process released it.

The commissioning record attributes one successful run to CPython 3.13.5 on Linux-6.18.44-x86_64-with-glibc2.41. The documentation used here is the Python 3.13 branch; the observed result remains tied to the recorded interpreter and platform, not to every Python or Linux combination.

Run the complete four-case pipe experiment

Save the following as pipe_fixture.py. The driver contains the worker and launcher programs as strings, so no unpublished helper file is required. It launches argument lists through sys.executable, not a shell command string. Each process family gets a private local socket pair for ordering and control, while stdout remains a separate channel under test.

"""Linux-only pipe-EOF lab. Run as a standalone process, never in a shared server."""
import ctypes
import hashlib
import json
import os
import platform
import select
import signal
import socket
import subprocess
import sys
import tempfile
from datetime import datetime, timezone
from pathlib import Path

WORKER = r'''
import json, os, socket, sys
s = socket.socket(fileno=int(sys.argv[1]))
s.settimeout(8)  # Failure escape, not an ordering mechanism.
s.sendall((json.dumps({'pid': os.getpid(), 'pgid': os.getpgrp()})+'\n').encode())
try:
    while True:
        command = s.recv(1)
        if command == b'C':
            os.close(1)
            s.sendall(b'C\n')
        elif command == b'X' or not command:
            break
finally:
    s.close()
'''
LAUNCHER = r'''
import os, subprocess, sys
fd, mode, worker = int(sys.argv[1]), sys.argv[2], sys.argv[3]
subprocess.Popen([sys.executable, '-c', worker, str(fd)], pass_fds=(fd,),
                 stdin=subprocess.DEVNULL,
                 stdout=subprocess.DEVNULL if mode == 'redirect' else None,
                 stderr=subprocess.DEVNULL, close_fds=True)
os.close(fd)
print('PARENT', flush=True)
'''

def require(ok, message):
    if not ok:
        raise AssertionError(message)

def receive_line(sock):
    result = bytearray()
    while not result.endswith(b'\n'):
        chunk = sock.recv(1)
        if not chunk:
            raise RuntimeError('Control connection closed early')
        result.extend(chunk)
        if len(result) > 1024:
            raise RuntimeError('Unexpected control frame')
    return bytes(result)

def family(mode, report):
    control, passed = socket.socketpair()
    control.settimeout(5)
    p = None
    pid = None
    pidfd = None
    try:
        p = subprocess.Popen([sys.executable, '-c', LAUNCHER, str(passed.fileno()), mode, WORKER],
                             pass_fds=(passed.fileno(),), start_new_session=True,
                             stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
                             stderr=subprocess.DEVNULL, close_fds=True)
        passed.close()
        ready = json.loads(receive_line(control))
        pid = ready['pid']
        pidfd = os.pidfd_open(pid)
        require(ready['pgid'] == p.pid, 'Worker escaped the owned group')
        require(p.wait(timeout=5) == 0, 'Direct child did not exit successfully')
        require(not select.select([pidfd], [], [], 0)[0], 'Worker exited before control')
        if mode == 'hold':
            row = {'id':'P2','status':'FAIL','direct_returncode':p.returncode,
                   'worker_pid':pid,'worker_pgid':ready['pgid']}
            report['cases'].append(row)
            try:
                p.communicate(timeout=0.2)
            except subprocess.TimeoutExpired as error:
                row['observed']='TIMEOUT_AFTER_DIRECT_CHILD_EXIT'
                row['partial_stdout_hex']=(error.output or b'').hex()
            else:
                raise AssertionError('Missing inherited-writer negative control')
            require(not select.select([pidfd], [], [], 0)[0], 'Timeout killed worker')
            row['status']='PASS'
            row = {'id':'P3','status':'FAIL'}
            report['cases'].append(row)
            control.sendall(b'C')
            require(receive_line(control) == b'C\n', 'Writer did not acknowledge close')
            out, = p.communicate(timeout=5)
            require(out == b'PARENT\n', 'Unexpected output or duplicate partial data')
            require(not select.select([pidfd], [], [], 0)[0], 'Worker must still be alive')
            row.update(status='PASS',observed='EOFWITH_WORKER_ALIVE',stdout_hex=out.hex())
        else:
            row = {'id':'P4','status':'FAIL'}
            report['cases'].append(row)
            out, = p.communicate(timeout=5)
            require(out == b'PARENT\n', 'Redirected descendant changed parent output')
            require(not select.select([pidfd], [], [], 0)[0], 'Redirected worker exited early')
            row.update(status='PASS',observed='EOFWITH_REDIRECTED_WORKER_ALIVE',stdout_hex=out.hex())
        control.sendall(b'X')
        require(select.select([pidfd], [], [], 5)[0], 'Worker did not exit on release')
        reaped, status = os.waitpid(pid, 0)
        require(reaped == pid and os.waitstatus_to_exitcode(status) == 0, 'Worker cleanup failed')
        report['cleanup'].append({'mode':mode,'worker_reaped':True})
        pid = None
    finally:
        passed.close()
        control.close()
        if p is not None and (p.poll() is None or pid is not None):
            try:
                os.killpg(p.pid, signal.SIGKILL)  # Only this newly created, recorded group.
            except ProcessLookupError:
                pass
        if p is not None:
            p.communicate(timeout=5)
        if pid is not None:
            if pidfd is not None:
                require(select.select([pidfd], [], [], 5)[0], 'Cleanup deadline expired')
            try:
                os.waitpid(pid, 0)
            except ChildProcessError:
                pass
        if pidfd is not None:
            os.close(pidfd)

if name == '__main__':
    if sys.platform != 'linux' or not hasattr(os, 'pidfd_open'):
        raise SystemExit('HOLD: Linux with pidfd_open is required')
    dest = Path(tempfile.mkdtemp(prefix='pipe-run-', dir=Path(__file__).parent))
    report = {'fixture':'pipe-eof-v1','status':'FAIL','cases':[],'cleanup':[],
              'python':platform.python_version(),'platform':platform.platform(),
              'started_utc':datetime.now(timezone.utc).isoformat(),
              'source_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest()}
    libc = ctypes.CDLL(None, use_errno=True)
    old = ctypes.c_int()
    require(libc.prctl(37, ctypes.byref(old), 0, 0, 0) == 0, 'Cannot read subreaper setting')
    require(libc.prctl(36, 1, 0, 0, 0) == 0, 'Cannot enable isolated cleanup supervisor')
    try:
        leaf = subprocess.run([sys.executable,'-c',"print('PARENT')"],
                              capture_output=True, check=True, timeout=5)
        require(leaf.stdout == b'PARENT\n', 'Leaf baseline failed')
        report['cases'].append({'id':'P1','status':'PASS','observed':'LEAF_EXIT_AND_EOF'})
        family('hold', report)
        family('redirect', report)
        require([r['id'] for r in report['cases']] == ['P1','P2','P3','P4'], 'Incomplete case inventory')
        require(len(report['cleanup']) == 2, 'Incomplete cleanup inventory')
        report['status']='PASS_4_CASES'
    except BaseException as error:
        report['error']={'type':type(error).__name__,'message':str(error)}
        raise
    finally:
        restored = libc.prctl(36, old.value, 0, 0, 0) == 0
        report['subreaper_restored']=restored
        if not restored:
            report['status']='FAIL'
        report['finished_utc']=datetime.now(timezone.utc).isoformat()
        path=dest/'results.json'
        path.write_text(json.dumps(report,indent=2)+'\n',encoding='utf-8')
        print(path)
        if not restored:
            raise RuntimeError('Subreaper setting was not restored')

Run it from a directory in which the current user may create a temporary subdirectory:

python pipe_fixture.py

Before comparing a rerun with the recorded manifest, check python --version and confirm that os.pidfd_open is present. A result from another interpreter patch, kernel, libc or source digest may still be useful, but it is a separate execution and needs its own timestamps and environment fields. Do not copy the commissioning values into a new result file or treat a matching expected table as execution evidence.

The fixture records platform and interpreter strings, UTC start and finish timestamps, the SHA-256 of the exact script bytes, case rows, cleanup rows and restoration state. It does not infer success from the final status string alone: assertions build that status only after the required case order and cleanup inventory are complete.

The supplied commissioning execution is attributed as pipe-eof-v1, status PASS_4_CASES, started 2026-10-13T07:39:16.036566+00:00 and finished 2026-10-13T07:39:24.442276+00:00. It ran on CPython 3.13.5 with Linux-6.18.44-x86_64-with-glibc2.41; the recorded source SHA-256 is 408b4066a77385060ad1ad112654584ae7ebae98cf3f69b64210b24001a15e0a. Those values identify one observed run. They are not performance claims, and a future execution needs its own environment, source hash and result file.

The source digest is byte-sensitive. The recorded SHA-256 corresponds to the displayed source saved as UTF-8 with LF line endings and one final newline after the last line. Different line endings, altered indentation or a missing terminal newline produce a different digest even when the program appears visually identical. The hash identifies the source bytes; it does not establish that the program ran, that the platform fields are accurate, or that cleanup completed. Those conclusions require the actual result record and the runtime observations enforced by the assertions.

Bound the test without making real-time promises

The protocol uses several different bounds because they answer different failure questions. The 0.2-second communicate() timeout is a deliberately short negative-control probe issued only after worker readiness, pidfd creation, direct-child exit zero and a live-worker check. Its job is to make the inherited-writer state visible; it is not a production timeout recommendation.

The five-second controller bounds prevent the laboratory from waiting indefinitely for direct-child exit, a control acknowledgement, EOF or worker termination. The worker’s eight-second socket timeout is a final failure escape if the controller disappears. None of these numbers promises strict real-time scheduling or proves that a real workload should complete within the same interval. An unexpected protocol timeout is HOLD. The only timeout classified as diagnostic PASS is P2’s specifically ordered TIMEOUT_AFTER_DIRECT_CHILD_EXIT, and even that pass does not approve a timeout-based production design.

Establish the ordinary exit-and-EOF baseline

P1 removes the descendant from the problem. It runs one leaf interpreter with subprocess.run, captures both standard streams, requires a zero exit status through check=True, and compares stdout with exactly b"PARENT\n". Because the leaf has no worker retaining stdout, its process exit also closes the only child-side reference to the captured write end. The capture can therefore finish normally.

This baseline is deliberately small. Its purpose is to prove that the chosen interpreter can start, write and collect the declared payload in the test environment. It also fixes the content oracle before the more complicated families run. If P1 returns b"PARENT\r\n", emits a warning, writes nothing, raises CalledProcessError, or exceeds its timeout, the experiment stops. The later cases cannot repair a broken baseline by producing a more convenient report.

P1 does not prove that every process family will produce EOF. It has no inherited writer and therefore does not exercise the ownership bug. Nor does it show that status zero alone implies valid content: the fixture checks both independently. The subprocess.run() documentation states that capture_output=True configures stdout and stderr pipes and returns captured bytes when text mode is not requested. With check=True, a nonzero result raises CalledProcessError. If a run() timeout expires, the process represented by its internal Popen object is killed and waited for before TimeoutExpired is re-raised.

The baseline decision is therefore diagnostic PASS, not blanket production acceptance. It establishes a healthy uncomplicated path and the exact byte contract. The process-family controls still have to show whether a descendant inherited the stream, whether EOF can occur while that worker remains alive, and whether cleanup completes afterward.

Wait for direct-child exit before testing the held pipe

P2 creates the state named by the primary search intent: a Python subprocess has exited, but stdout remains open. Ordering is essential. A timeout without ordering could mean that the launcher was still computing, blocked before spawning the worker, failed to flush its output, or never reached the intended state.

The control socket supplies the first barrier. The worker sends a newline-terminated JSON object containing its PID and process-group ID. Only after the supervisor receives and validates that frame does it open the pidfd. The launcher was created with start_new_session=True; the Popen documentation states that this performs setsid() in the child before execution. The fixture then requires the worker process-group ID to equal the launcher PID. That check bounds the laboratory cleanup group; it does not claim that a process group is a permanent inventory of every descendant.

Next, p.wait(timeout=5) must return zero. This reaps the direct child and fixes p.returncode == 0 before the output probe. A zero-time select() call on the worker pidfd must still report no readable event, showing that the referenced worker has not exited. The Linux pidfd_open(2) documentation states that a pidfd becomes readable when the referenced task terminates and becomes a zombie.

Only then does the fixture call p.communicate(timeout=0.2). In the supplied execution, that ordered call raised subprocess.TimeoutExpired and the row recorded TIMEOUT_AFTER_DIRECT_CHILD_EXIT. The launcher was already gone; the worker was still alive; and its inherited file descriptor 1 still referred to the pipe’s write end. That is the negative control required to diagnose output ownership rather than slow direct-child execution.

Do not use wait-before-drain for arbitrary large output

This order is safe only because the synthetic producer emits the fixed seven-byte value b"PARENT\n". Python’s Popen.wait() documentation warns that waiting while stdout or stderr is piped can deadlock if the child writes enough data to fill the operating-system pipe buffer. Linux also documents finite pipe capacity and advises consumers to read data so writers do not remain blocked.

Do not copy P2’s wait-before-communicate sequence into an application that may produce large or unbounded output. Such a program has two independent risks: a descendant can retain the write end after the direct child exits, and the direct child can block before exiting because nobody drains a full pipe. This fixture isolates the first risk by making the payload tiny and deterministic. Large-output supervision needs a concurrent draining design or another bounded storage policy, which is outside this experiment.

Preserve the timeout phase and partial output

A TimeoutExpired row is evidence about a phase, not a substitute exit code. P2 already has direct_returncode: 0; the exception means the bounded communication operation did not complete after that result was known. It does not convert the launcher into a failed process, and it does not imply that the worker crashed. The immediate pidfd check proves the opposite for the recorded state: the worker remains live after the timeout.

The exception may carry output captured before expiry. The fixture stores (error.output or b"").hex() in partial_stdout_hex, preserving raw bytes without assuming they form complete text. In the observed fixture, the exception carried captured bytes. The TimeoutExpired API specifies that its captured output attribute, when present, is bytes even when text mode was requested, and it may remain None when no output was observed.

That field should be treated as diagnostic evidence only. P2’s acceptance question is not “did some recognizable prefix arrive?” It is “did communication reach EOF and return the complete declared capture?” The answer is no, so output acceptance remains unresolved. The proper technical decision is REPAIR_OUTPUT_OWNERSHIP because the worker is not a declared producer yet owns the captured write end.

It is also important to distinguish Popen.communicate() timeout behavior from subprocess.run(timeout=...). A communicate() timeout raises TimeoutExpired without killing the process represented by that Popen object, while run() documents that it kills and waits for its internal child before re-raising. Neither contract states that descendants are enumerated or terminated.

Therefore, a production policy cannot simply swap APIs and infer process-family cleanup. Keep the evidence fields separate: direct-child status, timeout phase, partial bytes, worker identity and worker liveness. A diagnostic timeout proves the fixture reached the held-writer state; it does not make a long timeout the repair.

Close the worker’s stdout while the worker stays alive

P3 changes exactly one ownership fact. The supervisor sends byte C over the independent control socket. The worker executes os.close(1) and acknowledges with b"C\n". The acknowledgement matters because it establishes that the intended worker processed the close command before the supervisor retries communication. The control path does not share stdout, so closing file descriptor 1 cannot erase the ordering evidence.

At that point the direct child is already reaped and the worker has closed its only reference to the captured write end. Under the Linux pipe EOF rule, no potential writer remains, so the reader can observe EOF. The second p.communicate(timeout=5) must return, and the complete stdout value must equal exactly b"PARENT\n". The fixture then probes the same pidfd again and requires it to remain non-readable. In other words, EOF has occurred while the worker is still alive.

That conjunction is the decisive evidence:

  • the launcher ended successfully;

  • the worker continued running;

  • only the worker’s stdout descriptor was closed;

  • the capture reached EOF;

  • the final bytes matched the declared producer contract.

Killing the worker is therefore unnecessary to complete this output stream. The qualified repair is narrower: remove an unintended writer from the stream. In a real launcher, that may mean assigning stdout=DEVNULL, opening a dedicated log file, connecting a different pipe with its own consumer, or arranging an explicit close before the descendant enters long-lived work. The destination must follow the application’s output policy; the laboratory does not prescribe silent disposal for valuable worker logs.

Do not concatenate partial and final output blindly

Python’s communicate() retry guarantee states that catching TimeoutExpired and retrying communication does not lose output. The fixture relies on that contract and treats the retry result as the complete capture. It does not concatenate error.output from P2 with out from P3.

Blind concatenation can duplicate bytes already retained internally by Popen. For this oracle, one complete message is required, so out == b"PARENT\n" is both simpler and stronger than trying to reconstruct a stream from overlapping snapshots. The timeout’s partial value remains in its own evidence field; the final value remains in stdout_hex. Raw bytes and hex avoid normalization, decoding replacement or newline conversion changing the comparison.

Avoid inheriting captured stdout when the worker does not own it

P4 tests prevention at spawn rather than repair after inheritance. The launcher starts the worker with stdout=subprocess.DEVNULL. Python defines subprocess.DEVNULL as the platform null device and accepts it as a standard-stream target in Popen. The worker still receives the control socket through pass_fds, remains in the owned process group and reports readiness, but its file descriptor 1 no longer refers to the supervisor’s capture pipe.

The launcher itself continues to own the captured stdout long enough to print PARENT\n. When it exits, its copy closes. Because the worker was redirected from the beginning, no descendant retains the write end, so communicate(timeout=5) reaches EOF while the worker remains alive. The exact result must again be b"PARENT\n", producing EOF_WITH_REDIRECTED_WORKER_ALIVE.

This control proves a declared producer policy: only the direct child contributes to the capture. It is different from shell pipeline status checks around tee, where the operational problem is preserving statuses across a pipeline. Here there is no shell pipeline and no PIPESTATUS; a known direct-child zero coexists with a stream that may or may not have reached EOF.

Use DEVNULL only when discarding worker stdout is genuinely the contract. If worker output is operationally important, give it an explicit destination and evidence policy: a separate pipe that is drained, a log file with defined ownership, or structured logging to an approved sink. The point is not “hide descendant output.” It is “do not let a process accidentally own a stream whose completion is used as another component’s acceptance signal.”

P4 therefore supports ACCEPT_OUTPUT_CONTRACT for this fixture’s policy. It does not prove that every application should redirect descendants, nor that a worker’s messages are unimportant.

Reap adopted workers and restore supervision state

Output acceptance occurs before worker shutdown in P3 and P4, but the lab must still close its own process lifecycle. After each case has established EOF and worker liveness, the supervisor sends X through the control socket. The worker breaks out of its loop, closes the socket and exits normally.

The supervisor waits for the pidfd to become readable, then calls os.waitpid(pid, 0). The Python process-waiting APIs document that waitpid returns the waited PID and an encoded wait status, while os.waitstatus_to_exitcode() converts a normal exit to a nonnegative exit code and a signal termination to the negative signal number. The fixture requires the adopted worker PID to match and the converted exit code to equal zero.

Each successful family appends a cleanup record:

{"mode": "hold", "worker_reaped": true}
{"mode": "redirect", "worker_reaped": true}

The outer finally restores the exact prior child-subreaper value, records subreaper_restored, writes results.json, and raises if restoration failed. A report cannot remain PASS_4_CASES after failed restoration; the code changes its status to FAIL first.

On an error path, cleanup is limited to the newly created and recorded process group. start_new_session=True made the launcher a session and process-group leader, and the ready message confirms that the worker remained in that group. os.killpg(p.pid, signal.SIGKILL) is therefore a bounded laboratory fallback. The kill(2) process-group rules define a negative process target as the process group whose ID is the absolute value of that target. The fixture does not use wildcard targets and does not present a process group as an immutable census of arbitrary descendants.

This is controlled cleanup, not a generic kill-process-tree guide. Descendants that deliberately escape the group, shared supervisors, service-manager policies and graceful production shutdown are excluded.

Treat cleanup evidence as part of the laboratory result

The supplied normal-path execution observed both worker reaping records and subreaper_restored=true. Those observations support the complete four-case result. They do not establish that every exception branch has been fault-injected or that cleanup can never fail.

A missing reaping row, a nonzero converted worker status, an expired pidfd cleanup bound, a control-protocol failure or unsuccessful subreaper restoration makes the complete run HOLD or FAIL, depending on where the report records it. Do not publish only the attractive P3/P4 stdout values while omitting a stranded worker or altered supervisor state. The evidence package is useful precisely because it preserves both the semantic result and the cost of obtaining it.

Reconcile the four cases and their separate predicates

The case count is not the oracle by itself. P2 passes because the expected negative control timed out after the direct child had exited; it would be a category error to turn that row into output acceptance. A disciplined report keeps exit, liveness, descriptor action, communication and bytes in separate columns, an approach consistent with deterministic QA evidence, where a pass must correspond to the intended assertion rather than merely a green aggregate.

Case

Direct-child result

Worker state at output check

Worker stdout policy/action

Communication result

Complete accepted bytes

Technical decision

P1

Exit 0

No worker

Leaf closes stdout on exit

Completes with EOF

b"PARENT\n"

Diagnostic baseline PASS

P2

Exit 0

Live

Inherits and retains captured stdout

TIMEOUT_AFTER_DIRECT_CHILD_EXIT

Not established

REPAIR_OUTPUT_OWNERSHIP

P3

Exit 0

Live

Closes only fd 1 after acknowledged C

EOF_WITH_WORKER_ALIVE

b"PARENT\n"

ACCEPT_OUTPUT_CONTRACT

P4

Exit 0

Live

Redirected to DEVNULL at spawn

EOF_WITH_REDIRECTED_WORKER_ALIVE

b"PARENT\n"

ACCEPT_OUTPUT_CONTRACT

The cleanup inventory is separate from that table. The JSON rows record that the hold and redirect workers were reaped. They do not serialize a separate worker exit-code field, but the driver appends each row only after os.waitstatus_to_exitcode(status) == 0 succeeds. In the supplied PASS_4_CASES run, the cleanup rows therefore imply that both zero-exit assertions passed, and the report also records restoration of the prior subreaper setting. The status still does not make P2’s timed-out capture acceptable in production.

The content value b"PARENT\n" is seven bytes: six ASCII letters plus newline. Its hex representation is 504152454e540a. P3 and P4 require that complete value. P2 preserves any partial snapshot separately rather than assuming what the report contained.

Choose repair or acceptance for the real output contract

Translate the laboratory into a production decision only after naming the intended producers. A descendant that is supposed to write to the same stream is not automatically a bug; it changes the completion contract. The consumer may need to wait for that producer’s close, and direct-child exit is then insufficient by design. An unintended descendant writer, however, should not be tolerated with a larger timeout.

Use the following decision rules:

Decision

Required evidence

REPAIR_OUTPUT_OWNERSHIP

The direct child is complete, but an unintended descendant still owns the captured write end or output completion depends on an undeclared writer.

ACCEPT_OUTPUT_CONTRACT

The named producer policy is explicit; the direct-child result satisfies policy; EOF is observed; complete bytes satisfy the independent content oracle; identity and cleanup evidence are complete.

HOLD

The platform is unsupported, stream owners are unknown, the protocol or identity checks fail, expected bytes are missing or changed, cleanup is incomplete, or supervision state is not restored.

Before changing code, write down the real producer set and answer four questions from evidence: Which process is meant to write to the capture? Which launch option gives each descendant its standard streams? What event proves that the last intended writer closed? What content check decides that the completed byte stream is acceptable? Then compare the failure record with those answers. A larger timeout resolves none of them. It may only delay the same unknown state, while an indiscriminate family kill can hide the ownership error by forcing every descriptor closed.

The repair belongs at the ownership boundary. Configure the descendant’s stdout at spawn, close it deliberately at a documented phase, or provide a different output channel with its own consumer and completion rule. Do not substitute timeout=60 for timeout=5 and call the issue resolved. A timeout can limit waiting; it cannot prove which process still owns the descriptor or whether the message is complete.

Likewise, do not accept a run merely because returncode == 0. That predicate says nothing about a retained writer. Do not accept EOF alone when the bytes are wrong. Do not accept exact visible text before EOF when another writer could still append data. And do not accept a clean output row while the fixture leaves an adopted worker unreaped.

EOF still does not prove the application message is complete

EOF proves a stream-lifetime fact: no descriptor referring to the pipe’s write end remains open. Pipes are byte streams and do not impose application message boundaries. A writer can close after emitting a truncated JSON object, an incomplete archive or no output at all, and the reader will still receive EOF. pipe(7) explicitly describes the channel as a byte stream without message boundaries.

This fixture adds a content oracle because the message is intentionally fixed. Other workloads need their own independent validation: length, framing, checksum, schema, record count, terminal marker or another declared condition. Output completion and output correctness must remain separate even after ownership is repaired.

Assign ownership and recover interrupted captures

Three owners should be named in the operational design. The launcher owner decides which standard streams each descendant inherits. The output consumer owner defines EOF handling and validates complete content. The supervisor owner controls process identity, cleanup boundaries, reaping and restoration of temporary supervision state. This mirrors the broader discipline of ownership in infrastructure automation: automation becomes dependable when responsibility for state and evidence is explicit rather than implicit.

When a capture is interrupted, retain the direct-child return code, TimeoutExpired class, partial raw bytes, PIDs, process-group ID, pidfd-based liveness result, source hash, timestamps and cleanup outcome. Those fields let an operator distinguish an open writer from missing output, a protocol failure from a child failure, and a cleanup failure from an application-content failure.

A rerun may be necessary when the complete output cannot be recovered. Do not invent missing bytes, decode malformed data into a plausible message, or claim that restarting reverses external effects. This synthetic fixture has no network service or real workload, so rerunning it is low-risk. A production command may have changed files, databases or remote systems before capture failed; recovery must follow that workload’s own idempotency and reconciliation rules.

Practise Linux diagnosis with structured administration work

A direct-child status and a completed output stream answer different questions. The reliable diagnosis is to identify every writer, establish direct-child exit independently, observe EOF independently, validate complete bytes independently, and preserve bounded cleanup evidence. P3 and P4 show the critical result: a worker can remain alive after EOF when it closes or never inherits the captured write end.

For structured practice in Windows and Linux administration, networking, command-line work, troubleshooting, virtualization, recovery, security and monitoring foundations, review Refonte Learning’s System Administration Program. The page lists a 10–12 hour weekly commitment and associates successful completion with a Training Certificate and a Certificate of Internship. This article does not imply that the program teaches this exact Python pidfd/subreaper laboratory.

The operational takeaway is narrower and durable: exit zero is not pipe EOF, EOF is not content acceptance, and none of those replaces cleanup evidence.