Skip to content

Fix: exit a forked worker child once its parent is gone - #1495

Merged
ChaoWao merged 1 commit into
hw-native-sys:mainfrom
ChaoWao:detect-parent-death
Jul 26, 2026
Merged

Fix: exit a forked worker child once its parent is gone#1495
ChaoWao merged 1 commit into
hw-native-sys:mainfrom
ChaoWao:detect-parent-death

Conversation

@ChaoWao

@ChaoWao ChaoWao commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Fixes #1493. Stacked on #1494 (unify-child-mailbox-loops) — the check needs the single _run_mailbox_loop that PR introduces, so review/merge that one first. The diff here is 27 lines plus a test.

Summary

A forked sub/child/chip worker never checked whether its parent was still alive. When the parent died without Worker.close() — SIGKILL from a timeout, an OOM kill, a cancelled CI job, an editor killing a wedged pytest — every child was reparented and kept polling a mailbox nobody would ever write to. Because that poll is an unbounded busy-wait, each survivor held a full core indefinitely.

Not theoretical: on a shared dev box, six such processes belonging to two different users had been alive 5-7 days each at ~99.6% CPU — six cores permanently consumed. Four of the six carried tests/ut/py/test_worker/test_startup_readiness.py on their command line: runs whose parent was killed rather than allowed to finish.

Fix

_run_mailbox_loop samples getppid() on its idle path and leaves by the same SHUTDOWN route once it changes, so a child tears down exactly as it would on a clean shutdown.

Why not test for pid 1. A subreaper — container init, a systemd user session — adopts orphans instead of init, so the pid changes but never becomes 1. Comparing against the pid captured at loop entry covers both cases, and a live parent's pid cannot change, so the check cannot fire spuriously.

Cost. Sampling every _PARENT_LIVENESS_POLL_INTERVAL (1000) idle polls puts a getppid() roughly every 100 us against a ~0.1 us poll — fast enough that an orphan goes away before it is noticeable, cheap enough to be lost in the noise of the poll itself.

Residual gap, stated rather than papered over. A parent that dies between os.fork() and the child reaching the loop is already gone when the pid is captured, so that child is not detected. Closing it needs the pid handed in from before the fork, or PR_SET_PDEATHSIG — which is Linux-only and therefore an optimisation on top of this, not the mechanism.

Regression test

test_orphan_child_reaping.py SIGKILLs a subprocess parent and asserts every child is gone within 20 s.

Two details are load-bearing, both learned by getting them wrong first:

  • It writes to a file and never reads a pipe. Orphans inherit the parent's stdout, so on regression they hold a pipe's write end open — my first version used subprocess.run(capture_output=True) and hung for 120 s instead of failing.
  • Its finally kills whatever survived. That first version raised before it had parsed the pids, so its cleanup never ran and it leaked three spinning processes per run — a leak test that leaks.

Red-to-green:

# without the fix
AssertionError: 4 of 4 children outlived their SIGKILLed parent by 20s:
[2453757, 2453758, 2453759, 2453760] - a child is not noticing that its parent is gone
1 failed in 20.41s          (and no orphans left behind)

# with the fix
1 passed in 0.35s

Verification

  • pytest tests/ut/py -q -> 825 passed, 2 skipped (824 baseline + the new test).
  • pytest examples tests/st --platform a2a3sim --device 0-7 -q -> 108 passed, 2 skipped, 0 failed, 60/60 groups — identical to the Refactor: fold the three child mailbox loops into one state machine #1494 baseline. The chip loop shares this code path, so the device run is the one that matters.
  • pre-commit clean.

What this does not fix

The busy-wait itself. With the poll replaced by a blocking trigger an orphan would cost 0% CPU — but it would still be a leaked process holding fds and shm, so that work is complementary, not a substitute. This PR is what bounds the leak in time.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 462a91b1-3a80-4444-aa5a-ab128889ad6b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Forked worker mailbox handling is centralized with parent-death detection and shutdown cleanup. A Linux regression test verifies child exit after parent termination. New documentation describes host-build graph execution contracts, storage, lifecycle, fallback behavior, and roadmap constraints.

Changes

Worker orphan reaping

Layer / File(s) Summary
Shared mailbox lifecycle
python/simpler/worker.py
A shared mailbox loop handles task/control dispatch, error publication, shutdown, and parent PID changes.
Forked worker integrations
python/simpler/worker.py
Sub-worker, chip-process, and child-worker paths delegate task and control handling to the shared loop; child-worker errors now include operation names.
Orphan reaping regression test
tests/ut/py/test_worker/test_orphan_child_reaping.py
A Linux-only test kills a worker parent and verifies that reported child processes exit within the reap timeout.

Graph execution documentation

Layer / File(s) Summary
Graph model and submission contract
src/a2a3/runtime/host_build_graph/docs/GRAPH_EXECUTION.md
Documents graph recording, scalar categories, submission semantics, cache keys, and boundary validation.
Graph storage and lifecycle
src/a2a3/runtime/host_build_graph/docs/GRAPH_EXECUTION.md
Describes memory ownership, DAG representations, materialization, activation, and completion behavior.
Graph failure boundaries and roadmap
src/a2a3/runtime/host_build_graph/docs/GRAPH_EXECUTION.md
Lists fail-fast and fallback conditions, unsupported constructs, tracing conventions, limits, and roadmap items.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

Poem

I’m a rabbit guarding the mailbox tonight,
Parent gone? I hop away from sight.
Tasks and controls share one neat track,
No orphan child keeps spinning back.
Graphs now map their paths in prose—
Carrots for every node and rose!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR also adds a large Graph Execution documentation file unrelated to the orphan-reaping fix. Move the Graph Execution documentation to the stacked PR or a separate change so this PR stays focused on parent-death handling.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main fix: forked worker children exit when their parent disappears.
Description check ✅ Passed The description matches the change and explains the orphan-reaping fix and regression test.
Linked Issues check ✅ Passed The PR adds parent-death detection to the shared mailbox loop and a regression test that kills the parent and verifies children exit.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (2)
python/simpler/worker.py (2)

1862-1868: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Nested worker teardown only runs on the clean SHUTDOWN/orphan path.

Unlike _sub_worker_loop and _run_chip_main_loop, there is no finally here, so an exception escaping the loop leaves inner_worker un-closed in the forked child. Guarding with finally plus an idempotent close() makes teardown unconditional.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/simpler/worker.py` around lines 1862 - 1868, Wrap the
`_run_mailbox_loop` invocation in the nested worker path with a `finally` block
that always calls `inner_worker.close()`, including when the loop raises an
exception. Ensure `close()` remains safe if it is also invoked during normal
shutdown, matching the teardown behavior of `_sub_worker_loop` and
`_run_chip_main_loop`.

1650-1654: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider reusing _CTRL_OP_NAMES here.

The chip control error path hand-rolls the register/unregister naming that the new module-level map already provides, so the two can drift.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/simpler/worker.py` around lines 1650 - 1654, Update the chip control
error path to derive the operation name from the existing _CTRL_OP_NAMES map
instead of manually selecting “register” or “unregister” based on sub_cmd.
Preserve the current formatted hash, device, and exception details in the
_format_exc message.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@python/simpler/worker.py`:
- Line 1534: In the comment near the N×40B expression, replace the ambiguous
multiplication symbol “×” with the plain ASCII “x” while preserving the
comment’s meaning.
- Around line 1054-1055: Capture the original parent PID before the fork and
pass it into the child worker initialization instead of sampling it with
os.getppid() after fork. Update the worker setup around parent_pid and
liveness_countdown to use this inherited PID, while preserving the existing
liveness polling behavior.

In `@src/a2a3/runtime/host_build_graph/docs/GRAPH_EXECUTION.md`:
- Line 130: Update the alias-partition mismatch behavior in the GRAPH EXECUTION
documentation to reject cached-graph replay and fall back to the ordinary
execution path, replacing the current LOG_WARN-and-proceed behavior. Keep the
existing recomputation and comparison of rep[i] unchanged, and document the
mismatch as structural metadata drift rather than a recoverable warning.
- Around line 285-292: Update the cache-hit section describing rt_submit_graph
to state that cache misses execute fn and record its graph, while cache hits
replay the recorded definition without rerunning the host callable. Explicitly
document that fn must be deterministic and side-effect-free except for graph
construction; otherwise host-side effects and state reads occur only on the
first invocation.

In `@tests/ut/py/test_worker/test_orphan_child_reaping.py`:
- Around line 69-97: Update the cleanup in the test’s finally block to also
terminate and reap the parent subprocess when it remains running, including when
parent.wait raises TimeoutExpired. Preserve the existing reported-child cleanup,
and guard parent cleanup so it is safe if startup or waiting fails before parent
is assigned.

---

Nitpick comments:
In `@python/simpler/worker.py`:
- Around line 1862-1868: Wrap the `_run_mailbox_loop` invocation in the nested
worker path with a `finally` block that always calls `inner_worker.close()`,
including when the loop raises an exception. Ensure `close()` remains safe if it
is also invoked during normal shutdown, matching the teardown behavior of
`_sub_worker_loop` and `_run_chip_main_loop`.
- Around line 1650-1654: Update the chip control error path to derive the
operation name from the existing _CTRL_OP_NAMES map instead of manually
selecting “register” or “unregister” based on sub_cmd. Preserve the current
formatted hash, device, and exception details in the _format_exc message.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 5acb7c40-4c7a-47c9-8602-d1a3ddfa39a5

📥 Commits

Reviewing files that changed from the base of the PR and between 0ded81c and de0bffe.

📒 Files selected for processing (3)
  • python/simpler/worker.py
  • src/a2a3/runtime/host_build_graph/docs/GRAPH_EXECUTION.md
  • tests/ut/py/test_worker/test_orphan_child_reaping.py

Comment thread python/simpler/worker.py
Comment on lines +1054 to +1055
parent_pid = os.getppid()
liveness_countdown = _PARENT_LIVENESS_POLL_INTERVAL

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Parent PID sampled after fork leaves a small orphan window.

If the parent dies between fork() and this first os.getppid(), the sampled parent_pid is already the reparenting init/subreaper, so it never changes and the child spins forever — precisely the case #1493 targets. Capturing the parent's PID before the fork and passing it in closes the window.

🛡️ Sketch
 def _run_mailbox_loop(
     buf,
     state_addr: int,
     *,
     handle_task,
     handle_control,
     on_shutdown=None,
+    parent_pid: int | None = None,
 ) -> None:
@@
-    parent_pid = os.getppid()
+    # Prefer the pid captured by the forking parent; falling back to
+    # getppid() here can already observe the reparented ancestor.
+    parent_pid = parent_pid if parent_pid is not None else os.getppid()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/simpler/worker.py` around lines 1054 - 1055, Capture the original
parent PID before the fork and pass it into the child worker initialization
instead of sampling it with os.getppid() after fork. Update the worker setup
around parent_pid and liveness_countdown to use this inherited PID, while
preserving the existing liveness polling behavior.

Comment thread python/simpler/worker.py
_rewrite_blob_host_addrs(buf, _OFF_TASK_ARGS_BLOB, host_buf_ranges)
# Hand the mailbox bytes straight to C++ (zero-copy zero-decode):
# the blob layout is what `write_blob` already wrote, so re-parsing
# it in Python is N×40B of avoidable work and a permanent

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Ruff RUF003: ambiguous × in comment.

Flagged by static analysis on this changed line; replace with x to keep the lint gate clean.

✏️ Fix
-            # it in Python is N×40B of avoidable work and a permanent
+            # it in Python is Nx40B of avoidable work and a permanent
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# it in Python is N×40B of avoidable work and a permanent
# it in Python is Nx40B of avoidable work and a permanent
🧰 Tools
🪛 Ruff (0.15.21)

[warning] 1534-1534: Comment contains ambiguous × (MULTIPLICATION SIGN). Did you mean x (LATIN SMALL LETTER X)?

(RUF003)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/simpler/worker.py` at line 1534, In the comment near the N×40B
expression, replace the ambiguous multiplication symbol “×” with the plain ASCII
“x” while preserving the comment’s meaning.

Source: Linters/SAST tools

| Contract | Detection | Action on mismatch | Why |
| -------- | --------- | ------------------ | --- |
| **Structural metadata** — `shapes[]`, `strides[]`, `ndims`, `dtype`, size, `tag`, `manual_dep`, `is_contiguous` | field compare against the recorded signature | **`LOG_WARN` and fall back to the ordinary submit path** for this call (the cache is not used) | The recording baked `total_output_size`, `node_offsets[]`, and `required_heap` from these values. Replaying with a different shape would slice a block sized for the old one — a memory-safety fault, not a bounded wrong value. |
| **Alias partition** — which boundary slots share a buffer | recompute `rep[i] = min{ j<=i : args[j] shares args[i]'s buffer }` and compare | **`LOG_WARN` and proceed with the cached graph** | An internal tensor is matched to its boundary slot by address; when two slots alias, first-match resolves it by lowest index, valid only for the recorded alias pattern. The blast radius is bounded (a mis-bound pointer, not a wrong size) and the case is rare. |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Do not replay the cached graph after an alias-partition mismatch.

When boundary slots alias differently from the recording, the documented first-match lookup can bind an internal tensor to the wrong boundary slot and therefore the wrong caller-owned buffer. This is not merely a bounded warning; it can produce incorrect results or overwrite another tensor. Treat this mismatch like structural metadata drift and fall back to the ordinary path.

Proposed documentation fix
- | **Alias partition** — which boundary slots share a buffer | recompute `rep[i] = min{ j<=i : args[j] shares args[i]'s buffer }` and compare | **`LOG_WARN` and proceed with the cached graph** |
+ | **Alias partition** — which boundary slots share a buffer | recompute `rep[i] = min{ j<=i : args[j] shares args[i]'s buffer }` and compare | **`LOG_WARN` and fall back to the ordinary submit path** |
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
| **Alias partition** — which boundary slots share a buffer | recompute `rep[i] = min{ j<=i : args[j] shares args[i]'s buffer }` and compare | **`LOG_WARN` and proceed with the cached graph** | An internal tensor is matched to its boundary slot by address; when two slots alias, first-match resolves it by lowest index, valid only for the recorded alias pattern. The blast radius is bounded (a mis-bound pointer, not a wrong size) and the case is rare. |
| **Alias partition** — which boundary slots share a buffer | recompute `rep[i] = min{ j<=i : args[j] shares args[i]'s buffer }` and compare | **`LOG_WARN` and fall back to the ordinary submit path** | An internal tensor is matched to its boundary slot by address; when two slots alias, first-match resolves it by lowest index, valid only for the recorded alias pattern. The blast radius is bounded (a mis-bound pointer, not a wrong size) and the case is rare. |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/a2a3/runtime/host_build_graph/docs/GRAPH_EXECUTION.md` at line 130,
Update the alias-partition mismatch behavior in the GRAPH EXECUTION
documentation to reject cached-graph replay and fall back to the ordinary
execution path, replacing the current LOG_WARN-and-proceed behavior. Keep the
existing recomputation and comparison of rep[i] unchanged, and document the
mismatch as structural metadata drift rather than a recoverable warning.

Comment on lines +285 to +292
**Record (cache miss).** `rt_submit_graph` runs the function normally; every
submitted task is captured *and* executes as usual, so a miss costs nothing
beyond the recording. At scope end the recording is compacted into a definition.

**Submit (cache hit).** The Orchestrator allocates `required_heap`, computes only
the Graph's *external* dependencies, wires the boundary exactly like an ordinary
task, and places one `GRAPH` descriptor in the task window. The upload carries
the compact definition plus this call's boundary args — no expanded node array.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Define the orchestration callable’s cache-hit contract.

On a cache miss, rt_submit_graph executes fn; on a cache hit, it replays the recorded definition instead of rerunning the host body. Unless fn is explicitly required to be deterministic and side-effect-free apart from graph construction, host-side effects and state reads will occur only on the first invocation.

Proposed documentation addition
 Record (cache miss). `rt_submit_graph` runs the function normally; every
 submitted task is captured and executes as usual, so a miss costs nothing
 beyond the recording. At scope end the recording is compacted into a definition.
 
 Submit (cache hit). The Orchestrator allocates `required_heap`, computes only
 the Graph's **external** dependencies, wires the boundary exactly like an ordinary
 task, and places one `GRAPH` descriptor in the task window.
+ The orchestration callable must be deterministic and side-effect-free except
+ for constructing and submitting graph tasks. Per-invocation values must arrive
+ through `L0TaskArgs` or the configuration channel; the callable is not rerun
+ on cache hits.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
**Record (cache miss).** `rt_submit_graph` runs the function normally; every
submitted task is captured *and* executes as usual, so a miss costs nothing
beyond the recording. At scope end the recording is compacted into a definition.
**Submit (cache hit).** The Orchestrator allocates `required_heap`, computes only
the Graph's *external* dependencies, wires the boundary exactly like an ordinary
task, and places one `GRAPH` descriptor in the task window. The upload carries
the compact definition plus this call's boundary args — no expanded node array.
**Record (cache miss).** `rt_submit_graph` runs the function normally; every
submitted task is captured *and* executes as usual, so a miss costs nothing
beyond the recording. At scope end the recording is compacted into a definition.
**Submit (cache hit).** The Orchestrator allocates `required_heap`, computes only
the Graph's *external* dependencies, wires the boundary exactly like an ordinary
task, and places one `GRAPH` descriptor in the task window. The upload carries
the compact definition plus this call's boundary args — no expanded node array.
The orchestration callable must be deterministic and side-effect-free except
for constructing and submitting graph tasks. Per-invocation values must arrive
through `L0TaskArgs` or the configuration channel; the callable is not rerun
on cache hits.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/a2a3/runtime/host_build_graph/docs/GRAPH_EXECUTION.md` around lines 285 -
292, Update the cache-hit section describing rt_submit_graph to state that cache
misses execute fn and record its graph, while cache hits replay the recorded
definition without rerunning the host callable. Explicitly document that fn must
be deterministic and side-effect-free except for graph construction; otherwise
host-side effects and state reads occur only on the first invocation.

Comment on lines +69 to +97
try:
with out_path.open("w") as out:
parent = subprocess.Popen([sys.executable, "-c", _PARENT_SRC % NUM_SUB_WORKERS], stdout=out, stderr=out)
parent.wait(timeout=PARENT_EXIT_TIMEOUT_S)

reported = [int(tok) for tok in out_path.read_text().split() if tok.isdigit()]
assert len(reported) >= NUM_SUB_WORKERS, (
f"expected at least {NUM_SUB_WORKERS} forked children, parent reported {reported}\n"
f"parent output:\n{out_path.read_text()}"
)

deadline = time.monotonic() + REAP_TIMEOUT_S
survivors = reported
while survivors and time.monotonic() < deadline:
time.sleep(0.1)
survivors = [p for p in survivors if _alive(p)]

assert not survivors, (
f"{len(survivors)} of {len(reported)} children outlived their SIGKILLed parent by "
f"{REAP_TIMEOUT_S:.0f}s: {survivors} — a child is not noticing that its parent is gone"
)
finally:
# A regression leaves spinning processes behind; never hand them on to
# the next test, whether the assertion passed, failed, or never ran.
for pid in reported:
try:
os.kill(pid, 9)
except OSError:
pass

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Timed-out parent is never cleaned up.

If parent.wait() raises TimeoutExpired, reported is still empty, so the finally kills nothing and both the parent subprocess and its forked children leak into the rest of the session. Kill the parent in the finally as well.

🧹 Suggested cleanup
     out_path = tmp_path / "children.txt"
     reported: list[int] = []
+    parent = None
     try:
         with out_path.open("w") as out:
             parent = subprocess.Popen([sys.executable, "-c", _PARENT_SRC % NUM_SUB_WORKERS], stdout=out, stderr=out)
         parent.wait(timeout=PARENT_EXIT_TIMEOUT_S)
@@
     finally:
         # A regression leaves spinning processes behind; never hand them on to
         # the next test, whether the assertion passed, failed, or never ran.
+        if parent is not None and parent.poll() is None:
+            parent.kill()
+            parent.wait(timeout=5)
         for pid in reported:
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try:
with out_path.open("w") as out:
parent = subprocess.Popen([sys.executable, "-c", _PARENT_SRC % NUM_SUB_WORKERS], stdout=out, stderr=out)
parent.wait(timeout=PARENT_EXIT_TIMEOUT_S)
reported = [int(tok) for tok in out_path.read_text().split() if tok.isdigit()]
assert len(reported) >= NUM_SUB_WORKERS, (
f"expected at least {NUM_SUB_WORKERS} forked children, parent reported {reported}\n"
f"parent output:\n{out_path.read_text()}"
)
deadline = time.monotonic() + REAP_TIMEOUT_S
survivors = reported
while survivors and time.monotonic() < deadline:
time.sleep(0.1)
survivors = [p for p in survivors if _alive(p)]
assert not survivors, (
f"{len(survivors)} of {len(reported)} children outlived their SIGKILLed parent by "
f"{REAP_TIMEOUT_S:.0f}s: {survivors} — a child is not noticing that its parent is gone"
)
finally:
# A regression leaves spinning processes behind; never hand them on to
# the next test, whether the assertion passed, failed, or never ran.
for pid in reported:
try:
os.kill(pid, 9)
except OSError:
pass
out_path = tmp_path / "children.txt"
reported: list[int] = []
parent = None
try:
with out_path.open("w") as out:
parent = subprocess.Popen([sys.executable, "-c", _PARENT_SRC % NUM_SUB_WORKERS], stdout=out, stderr=out)
parent.wait(timeout=PARENT_EXIT_TIMEOUT_S)
reported = [int(tok) for tok in out_path.read_text().split() if tok.isdigit()]
assert len(reported) >= NUM_SUB_WORKERS, (
f"expected at least {NUM_SUB_WORKERS} forked children, parent reported {reported}\n"
f"parent output:\n{out_path.read_text()}"
)
deadline = time.monotonic() + REAP_TIMEOUT_S
survivors = reported
while survivors and time.monotonic() < deadline:
time.sleep(0.1)
survivors = [p for p in survivors if _alive(p)]
assert not survivors, (
f"{len(survivors)} of {len(reported)} children outlived their SIGKILLed parent by "
f"{REAP_TIMEOUT_S:.0f}s: {survivors} — a child is not noticing that its parent is gone"
)
finally:
# A regression leaves spinning processes behind; never hand them on to
# the next test, whether the assertion passed, failed, or never ran.
if parent is not None and parent.poll() is None:
parent.kill()
parent.wait(timeout=5)
for pid in reported:
try:
os.kill(pid, 9)
except OSError:
pass
🧰 Tools
🪛 ast-grep (0.44.1)

[error] 70-70: Command coming from incoming request
Context: subprocess.Popen([sys.executable, "-c", _PARENT_SRC % NUM_SUB_WORKERS], stdout=out, stderr=out)
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(subprocess-from-request)

🪛 Ruff (0.15.21)

[error] 71-71: subprocess call: check for execution of untrusted input

(S603)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/ut/py/test_worker/test_orphan_child_reaping.py` around lines 69 - 97,
Update the cleanup in the test’s finally block to also terminate and reap the
parent subprocess when it remains running, including when parent.wait raises
TimeoutExpired. Preserve the existing reported-child cleanup, and guard parent
cleanup so it is safe if startup or waiting fails before parent is assigned.

@ChaoWao

ChaoWao commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

CI caught a defect in the regression test itself — the fix under test is unaffected:

AssertionError: 1 of 6 children outlived their SIGKILLed parent by 20s: [3]

Two problems, both from enumerating children with pgrep -P inside the parent:

  • The transient shell that os.popen spawns to run pgrep is itself a child of the parent, so it landed in the list.
  • Inside the CI container's shallow pid namespace the list also picked up unrelated low pids — hence [3]. The test then waited 20 s on a container process and, in its finally, sent it SIGKILL.

Fixed by taking the pids from the Worker's own _sub_pids bookkeeping, and tightening >= NUM_SUB_WORKERS to == NUM_SUB_WORKERS so a wrong count fails loudly instead of being absorbed.

With pgrep gone the test is no longer Linux-specific, so the skipif is dropped — reparenting on parent death is POSIX behaviour, and the macOS ut job (which failed for the same reason) now covers it too.

Re-verified red-to-green against the parent branch's worker.py, since the fix is committed and git stash had nothing to remove — my first attempt at this "passed" only because it had silently run with the fix:

# without the fix
AssertionError: 3 of 3 children outlived their SIGKILLed parent by 20s: [2881160, 2881161, 2881162]
1 failed in 20.26s

# with the fix
1 passed in 0.24s   (5/5 consecutive runs)

pytest tests/ut/py -q → 825 passed, 2 skipped.

@ChaoWao
ChaoWao force-pushed the detect-parent-death branch 2 times, most recently from ae21520 to fe35353 Compare July 26, 2026 12:48
@ChaoWao

ChaoWao commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main now that #1494 has merged, and this PR now also removes src/a2a3/runtime/host_build_graph/docs/GRAPH_EXECUTION.md.

That file should never have been in #1494. It is documentation from the open #1444 (feat/graph-execution, @TaoZQY) that was sitting untracked in my working tree, and a git add -A swept it into the refactor commit — so #1494 merged 418 lines of somebody else's unmerged PR into main. Removing it here restores main to the state #1444 expects to merge into. It is unrelated to the fix and can be reviewed independently.

Also hardened the regression test after it failed once in ~22 local runs with an unhelpful expected exactly 3 forked sub-worker pids, parent reported []. I could not reproduce that (0 failures in 27 subsequent runs), so rather than guess at a cause I made the test name it: the parent is supposed to die from its own SIGKILL, so the test now asserts returncode == -SIGKILL and dumps the parent's own output otherwise. A parent that fails before forking now reports why, instead of surfacing as a pid-count mismatch that reads like a defect in the code under test.

Verification after the rebase:

# without the fix (main's worker.py)
AssertionError: 3 of 3 children outlived their SIGKILLed parent by 20s
1 failed in 20.35s

# with the fix
1 passed   (12/12 consecutive runs)

pre-commit clean.

A forked sub/child/chip worker never checked whether its parent was still
alive. When the parent died without `Worker.close()` — SIGKILL from a
`timeout`, an OOM kill, a cancelled CI job, an editor killing a wedged
pytest — every child was reparented and kept polling a mailbox nobody
would ever write to. Because that poll is an unbounded busy-wait, each
survivor held a full core indefinitely.

This is not theoretical. On a shared dev box six such processes belonging
to two different users had been alive 5-7 days each at ~99.6% CPU, six
cores permanently consumed. Four of the six carried
`tests/ut/py/test_worker/test_startup_readiness.py` on their command
line: runs whose parent was killed rather than allowed to finish.

`_run_mailbox_loop` now samples `getppid()` on its idle path and leaves
by the same SHUTDOWN route once it changes, so a child tears down exactly
as it would on a clean shutdown.

Compare against the pid captured at loop entry rather than testing for
pid 1: a subreaper (container init, a systemd user session) adopts
orphans instead of init, so the pid changes but never becomes 1. A live
parent's pid cannot change, so the check cannot fire spuriously.

Sampling every `_PARENT_LIVENESS_POLL_INTERVAL` idle polls puts a
`getppid()` roughly every 100 us against a ~0.1 us poll — fast enough
that an orphan goes away before it is noticeable, cheap enough to be lost
in the noise of the poll itself.

Residual gap: a parent that dies between `os.fork()` and the child
reaching the loop is already gone when the pid is captured, so that child
is not detected. Closing it needs the pid handed in from before the fork,
or `PR_SET_PDEATHSIG`, which is Linux-only and therefore an optimisation
on top of this rather than the mechanism.

`test_orphan_child_reaping.py` SIGKILLs a subprocess parent and asserts
every child is gone within 20 s. Three details are load-bearing:

- It reads the pids from the Worker's own `_sub_pids` rather than
  `pgrep -P`. Enumerating children externally also catches the transient
  shell that runs pgrep, and inside a container's shallow pid namespace it
  picks up unrelated low pids: on CI that produced "1 of 6 children
  outlived ... [3]", waiting on and then SIGKILLing a container process
  that was never ours.
- It writes to a file and never reads a pipe. Orphans inherit the
  parent's stdout, so on regression they hold a pipe's write end open and
  reading one would hang the test for as long as the bug survives instead
  of failing it.
- Its `finally` kills whatever survived, so a regression does not hand
  spinning processes to the next test.
- It asserts the parent died from its own SIGKILL. A parent that fails
  before forking otherwise surfaces as an unexplained "expected 3 pids,
  got []", which reads like a defect in the code under test rather than
  an environment problem in the harness.

It runs on macOS as well as Linux: reparenting on parent death is POSIX
behaviour, and nothing here is platform-specific once the pid list comes
from the Worker.

Also drop `src/a2a3/runtime/host_build_graph/docs/GRAPH_EXECUTION.md`,
which hw-native-sys#1494 added by accident: it is documentation from the open hw-native-sys#1444
(`feat/graph-execution`) that happened to be sitting untracked in the
working tree, and a `git add -A` swept it into that commit. Removing it
restores main to the state hw-native-sys#1444 expects to merge into. It is unrelated
to everything else here and can be reviewed independently of the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ChaoWao
ChaoWao merged commit 8e3bebf into hw-native-sys:main Jul 26, 2026
16 checks passed
@ChaoWao
ChaoWao deleted the detect-parent-death branch July 26, 2026 14:52
ChaoWao added a commit that referenced this pull request Jul 27, 2026
…log (#1504)

`test_orphan_child_reaping` scraped the forked children's pids out of the
parent subprocess's combined stdout+stderr, taking every digit-only token
it found. That stream also carries log lines and warnings, so any bare
number in them was read as a pid. On CI it collected a fourth entry:

    expected exactly 3 forked sub-worker pids, parent reported
    [6778, 6779, 6780, 3]

The three real pids were correct — `Worker._sub_pids` is not at fault —
and the run was otherwise healthy, so the test failed on main for a
parsing artifact rather than a defect. It also meant the `finally` block
would have sent SIGKILL to whatever process that stray number named.

Give the parent a dedicated output file, named on its command line, and
keep stdout+stderr as a separate log used only for diagnostics when an
assertion fails. Parsing a channel that carries nothing but the pids
removes the class of failure rather than filtering harder against it,
which is the same mistake in a different shape.

Also drop the `isdigit()` filter, now that a non-numeric token in the pid
file would be a real defect worth surfacing rather than noise to skip.

15/15 local runs pass; red-to-green still holds against the pre-#1495
`worker.py` ("3 of 3 children outlived their SIGKILLed parent by 20s",
now with exactly three pids and no phantom entry).
ChaoWao added a commit to ChaoWao/simpler-fork that referenced this pull request Jul 27, 2026
After hw-native-sys#1499 took a single `Worker.run()` from 150 us to ~51 us, the
obvious follow-up was whether another factor of three was available in
what remained, and whether a blocking wakeup primitive (hw-native-sys#1498) was how to
get it. Measured, and the answer to both is no. Recording it so the next
person does not re-derive it.

A bare fork+shm mailbox round trip with no simpler runtime in it costs
3.5 us against `Worker.run()`'s 53.8 us, which reads as "94% of dispatch
latency is our code, not the IPC". That headline is an artifact of
benchmarking a single task. Sweeping tasks per `run()` from 1 to 256
splits it into **43.3 us fixed per run() and 8.08 us marginal per task**:
the cost is entering and leaving an orchestration, not dispatching work,
and a production run submits a graph per `run()` rather than one task. At
256 tasks the fixed part is 2% of the invocation.

The same numbers settle hw-native-sys#1498 against itself on latency grounds. A pipe
wake measures 4.4 us one way on this box, so replacing the poll with a
blocking wait adds more than the entire 3.5 us IPC floor to every
dispatch, in exchange for CPU, while the latency it was meant to recover
is not in the poll at all. What survives is narrower and unrelated: a
child spinning while nothing is outstanding holds a core for as long as
its parent lives — a CPU question for narrow-core hosts, already smaller
since hw-native-sys#1495 made orphans exit with their parent.

Also records the reconsideration triggers, since both conclusions are
conditional: a workload that issues many small `run()` calls stops
amortizing the fixed cost, and the CPU argument for a spin-then-block
hybrid remains open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ChaoWao added a commit that referenced this pull request Jul 27, 2026
After #1499 took a single `Worker.run()` from 150 us to ~51 us, the
obvious follow-up was whether another factor of three was available in
what remained, and whether a blocking wakeup primitive (#1498) was how to
get it. Measured, and the answer to both is no. Recording it so the next
person does not re-derive it.

A bare fork+shm mailbox round trip with no simpler runtime in it costs
3.5 us against `Worker.run()`'s 53.8 us, which reads as "94% of dispatch
latency is our code, not the IPC". That headline is an artifact of
benchmarking a single task. Sweeping tasks per `run()` from 1 to 256
splits it into **43.3 us fixed per run() and 8.08 us marginal per task**:
the cost is entering and leaving an orchestration, not dispatching work,
and a production run submits a graph per `run()` rather than one task. At
256 tasks the fixed part is 2% of the invocation.

The same numbers settle #1498 against itself on latency grounds. A pipe
wake measures 4.4 us one way on this box, so replacing the poll with a
blocking wait adds more than the entire 3.5 us IPC floor to every
dispatch, in exchange for CPU, while the latency it was meant to recover
is not in the poll at all. What survives is narrower and unrelated: a
child spinning while nothing is outstanding holds a core for as long as
its parent lives — a CPU question for narrow-core hosts, already smaller
since #1495 made orphans exit with their parent.

Also records the reconsideration triggers, since both conclusions are
conditional: a workload that issues many small `run()` calls stops
amortizing the fixed cost, and the CPU argument for a spin-then-block
hybrid remains open.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Forked worker children survive parent death and spin at 100% CPU forever

1 participant