Fix L3 worker child-exit hangs#1003
Conversation
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
There was a problem hiding this comment.
Code Review
This pull request introduces child process PID tracking and liveness checks to prevent the parent process from spin-polling indefinitely if a child worker dies. The review feedback highlights several critical improvements: replacing the Python 3.9-incompatible strict=True in zip() with manual length checks, improving the robustness of check_child_alive in C++ (such as handling EINTR and correcting child_reaped_ logic), and resetting the mailbox state to IDLE when exceptions are thrown during polling to prevent deadlocks.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| for shm, pid in zip(self._chip_shms, self._chip_pids, strict=True): | ||
| dw.add_next_level_worker(_mailbox_addr(shm), pid) | ||
|
|
||
| # Register Worker children as NEXT_LEVEL (L4+) | ||
| for shm in self._next_level_shms: | ||
| dw.add_next_level_worker(_mailbox_addr(shm)) | ||
| for shm, pid in zip(self._next_level_shms, self._next_level_pids, strict=True): | ||
| dw.add_next_level_worker(_mailbox_addr(shm), pid) | ||
|
|
||
| for shm in self._sub_shms: | ||
| dw.add_sub_worker(_mailbox_addr(shm)) | ||
| for shm, pid in zip(self._sub_shms, self._sub_pids, strict=True): | ||
| dw.add_sub_worker(_mailbox_addr(shm), pid) |
There was a problem hiding this comment.
Since this project targets Python 3.9+, using strict=True in zip() is not supported and will raise a TypeError at runtime. To maintain compatibility while preventing silent truncation on sequences expected to be of equal length, we should explicitly verify their lengths are identical and raise a ValueError on mismatch before zipping.
| for shm, pid in zip(self._chip_shms, self._chip_pids, strict=True): | |
| dw.add_next_level_worker(_mailbox_addr(shm), pid) | |
| # Register Worker children as NEXT_LEVEL (L4+) | |
| for shm in self._next_level_shms: | |
| dw.add_next_level_worker(_mailbox_addr(shm)) | |
| for shm, pid in zip(self._next_level_shms, self._next_level_pids, strict=True): | |
| dw.add_next_level_worker(_mailbox_addr(shm), pid) | |
| for shm in self._sub_shms: | |
| dw.add_sub_worker(_mailbox_addr(shm)) | |
| for shm, pid in zip(self._sub_shms, self._sub_pids, strict=True): | |
| dw.add_sub_worker(_mailbox_addr(shm), pid) | |
| if len(self._chip_shms) != len(self._chip_pids): | |
| raise ValueError("Sequences must be of equal length") | |
| for shm, pid in zip(self._chip_shms, self._chip_pids): | |
| dw.add_next_level_worker(_mailbox_addr(shm), pid) | |
| # Register Worker children as NEXT_LEVEL (L4+) | |
| if len(self._next_level_shms) != len(self._next_level_pids): | |
| raise ValueError("Sequences must be of equal length") | |
| for shm, pid in zip(self._next_level_shms, self._next_level_pids): | |
| dw.add_next_level_worker(_mailbox_addr(shm), pid) | |
| if len(self._sub_shms) != len(self._sub_pids): | |
| raise ValueError("Sequences must be of equal length") | |
| for shm, pid in zip(self._sub_shms, self._sub_pids): | |
| dw.add_sub_worker(_mailbox_addr(shm), pid) |
References
- Before using
zipon sequences that are expected to be of equal length, verify their lengths are identical and raise an error on mismatch to prevent silent truncation.
| void WorkerThread::check_child_alive(const char *op_name) { | ||
| if (child_pid_ <= 0 || child_reaped_) return; | ||
|
|
||
| int status = 0; | ||
| pid_t r = waitpid(static_cast<pid_t>(child_pid_), &status, WNOHANG); | ||
| if (r == 0) return; | ||
|
|
||
| child_reaped_ = true; | ||
| if (r == static_cast<pid_t>(child_pid_)) { | ||
| throw std::runtime_error(std::string(op_name) + ": " + child_status_message(child_pid_, status)); | ||
| } | ||
| if (r < 0 && errno == ECHILD) { | ||
| throw std::runtime_error( | ||
| std::string(op_name) + ": child process pid=" + std::to_string(child_pid_) + | ||
| " is no longer waitable before mailbox completion" | ||
| ); | ||
| } | ||
| if (r < 0) { | ||
| throw std::runtime_error( | ||
| std::string(op_name) + ": waitpid(pid=" + std::to_string(child_pid_) + | ||
| ") failed: " + std::strerror(errno) | ||
| ); | ||
| } | ||
| } |
There was a problem hiding this comment.
There are a few robustness and correctness issues in check_child_alive:
- If
child_reaped_is alreadytrue, the child process is dead. Any subsequent call should fail immediately with an error instead of returning silently, which would otherwise cause the parent to spin-poll or timeout on the dead mailbox. waitpidcan be interrupted by a signal and return-1witherrno == EINTR. We should retrywaitpidin a loop whenEINTRoccurs to prevent transient failures from being treated as fatal errors.child_reaped_is set totrueunconditionally before checking ifwaitpidactually reaped the child. It should only be set totrueif the child was successfully reaped (i.e.,r == child_pid_orerrno == ECHILD).
void WorkerThread::check_child_alive(const char *op_name) {
if (child_pid_ <= 0) return;
if (child_reaped_) {
throw std::runtime_error(
std::string(op_name) + ": child process pid=" + std::to_string(child_pid_) +
" is dead (already reaped)"
);
}
int status = 0;
pid_t r;
do {
r = waitpid(static_cast<pid_t>(child_pid_), &status, WNOHANG);
} while (r < 0 && errno == EINTR);
if (r == 0) return;
if (r == static_cast<pid_t>(child_pid_)) {
child_reaped_ = true;
throw std::runtime_error(std::string(op_name) + ": " + child_status_message(child_pid_, status));
}
if (r < 0 && errno == ECHILD) {
child_reaped_ = true;
throw std::runtime_error(
std::string(op_name) + ": child process pid=" + std::to_string(child_pid_) +
" is no longer waitable before mailbox completion"
);
}
if (r < 0) {
throw std::runtime_error(
std::string(op_name) + ": waitpid(pid=" + std::to_string(child_pid_) +
") failed: " + std::strerror(errno)
);
}
}| int poll_count = 0; | ||
| while (read_mailbox_state() != MailboxState::TASK_DONE) { | ||
| if ((++poll_count % 200) == 0) { | ||
| check_child_alive("WorkerThread::dispatch_process"); | ||
| } | ||
| std::this_thread::sleep_for(std::chrono::microseconds(50)); | ||
| } |
There was a problem hiding this comment.
When performing IPC or control commands that can fail in a child process, we must ensure the communication state (e.g., mailbox state) is reset to a safe or idle state before throwing an exception or unwinding to prevent deadlocks in subsequent operations. Additionally, we can avoid potential signed overflow and modulo overhead by using a simple counter that resets to 0.
try {
int poll_count = 0;
while (read_mailbox_state() != MailboxState::TASK_DONE) {
if (++poll_count >= 200) {
poll_count = 0;
check_child_alive("WorkerThread::dispatch_process");
}
std::this_thread::sleep_for(std::chrono::microseconds(50));
}
} catch (...) {
write_mailbox_state(MailboxState::IDLE);
throw;
}References
- When performing IPC or control commands that can fail in a child process, always check the returned error code. Ensure the communication state (e.g., mailbox state) is reset to a safe or idle state before throwing an exception or unwinding to prevent deadlocks in subsequent operations.
| int poll_count = 0; | ||
| while (read_mailbox_state() != MailboxState::CONTROL_DONE) { | ||
| if ((++poll_count % 200) == 0) { | ||
| check_child_alive(op_name); | ||
| } |
There was a problem hiding this comment.
When performing IPC or control commands that can fail in a child process, we must ensure the communication state (e.g., mailbox state) is reset to a safe or idle state before throwing an exception or unwinding to prevent deadlocks in subsequent operations. Additionally, we can avoid potential signed overflow and modulo overhead by using a simple counter that resets to 0.
try {
int poll_count = 0;
while (read_mailbox_state() != MailboxState::CONTROL_DONE) {
if (++poll_count >= 200) {
poll_count = 0;
check_child_alive(op_name);
}
std::this_thread::sleep_for(std::chrono::microseconds(50));
}
} catch (...) {
write_mailbox_state(MailboxState::IDLE);
throw;
}References
- When performing IPC or control commands that can fail in a child process, always check the returned error code. Ensure the communication state (e.g., mailbox state) is reset to a safe or idle state before throwing an exception or unwinding to prevent deadlocks in subsequent operations.
a8bfbc4 to
7aa5071
Compare
7aa5071 to
109b63e
Compare
Hierarchical Worker startup could hang forever. A chip (L2) child that failed ChipWorker.init only wrote its error and exited, and the parent spun on `while state != INIT_DONE` with no timeout and no liveness check, so a dead or slow child deadlocked startup (the hw-native-sys#1003 / hw-native-sys#980 class of hang). Next-level (L4->L3) children had no readiness barrier at all: a failing inner init unwound back through the forked copy of the caller's frames instead of reporting a bounded error. Make startup a strong READY boundary: - States: INIT_READY / INIT_FAILED (renamed INIT_DONE), mirrored in the C++ MailboxState enum for parity. - Every forked child (sub, chip, next-level) publishes INIT_READY after its own init succeeds, or INIT_FAILED carrying the original error. A shared `_forked_child_main` guarantees a child always terminates via os._exit and never unwinds an exception into the parent's startup frames (which would let it SIGKILL its inherited-PID siblings); READY is published only after all fallible setup (inner init AND identity tables). - Parent barrier `_await_children_ready` waits per child with a deadline (`startup_timeout_s`, default 300s, validated positive+finite) and a waitpid(WNOHANG) liveness check; INIT_FAILED, a dead child, or a blown deadline raises a bounded RuntimeError. Applied to the sub, chip, and next-level edges (chip and next-level had no/weak barriers before). - Rollback (`_abort_hierarchical`, run on any pre-scheduler BaseException, including KeyboardInterrupt): next-level children that reached their serve loop are closed gracefully (SHUTDOWN + bounded wait) so they unlink their own nested shms; the rest are SIGKILLed; PIDs the barrier already reaped are excluded from the kill (no reused-PID signal); all are reaped and the mailboxes freed. A sibling still mid-init when another fails is SIGKILLed and its nested shms are reclaimed by the resource_tracker at exit — closing that race needs the eager-init handshake (follow-up). Next-level children are still started lazily on first run(); the barrier is recursive plumbing so a later eager-init change makes INIT_READY propagate up only after the whole subtree is ready, with no protocol change. Adds device-free ut coverage (next-level + sub init injection, faked ChipWorker on a2a3sim): bounded init-failure error, child-exit-before- ready, deadline-fires, rollback-reaps-no-leak, graceful-close of a READY sibling, sub-edge liveness, timeout config validation, and healthy-tree happy paths. Every failure test is wrapped in a hard SIGALRM budget so a regression fails fast instead of hanging CI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Hierarchical Worker startup could hang forever. A chip (L2) child that failed ChipWorker.init only wrote its error and exited, and the parent spun on `while state != INIT_DONE` with no timeout and no liveness check, so a dead or slow child deadlocked startup (the hw-native-sys#1003 / hw-native-sys#980 class of hang). Next-level (L4->L3) children had no readiness barrier at all: a failing inner init unwound back through the forked copy of the caller's frames instead of reporting a bounded error. Make startup a strong READY boundary: - States: INIT_READY / INIT_FAILED (renamed INIT_DONE), mirrored in the C++ MailboxState enum for parity. - Every forked child (sub, chip, next-level) publishes INIT_READY after its own init succeeds, or INIT_FAILED carrying the original error. A shared `_forked_child_main` guarantees a child always terminates via os._exit and never unwinds an exception into the parent's startup frames (which would let it SIGKILL its inherited-PID siblings); READY is published only after all fallible setup (inner init AND identity tables). - Parent barrier `_await_children_ready` waits per child with a deadline (`startup_timeout_s`, default 300s, validated positive+finite) and a waitpid(WNOHANG) liveness check; INIT_FAILED, a dead child, or a blown deadline raises a bounded RuntimeError. Applied to the sub, chip, and next-level edges (chip and next-level had no/weak barriers before). - Rollback (`_abort_hierarchical`, run on any pre-scheduler BaseException, including KeyboardInterrupt): next-level children that reached their serve loop are closed gracefully (SHUTDOWN + bounded wait) so they unlink their own nested shms; the rest are SIGKILLed; PIDs the barrier already reaped are excluded from the kill (no reused-PID signal); all are reaped and the mailboxes freed. A sibling still mid-init when another fails is SIGKILLed and its nested shms are reclaimed by the resource_tracker at exit — closing that race needs the eager-init handshake (follow-up). Next-level children are still started lazily on first run(); the barrier is recursive plumbing so a later eager-init change makes INIT_READY propagate up only after the whole subtree is ready, with no protocol change. Adds device-free ut coverage (next-level + sub init injection, faked ChipWorker on a2a3sim): bounded init-failure error, child-exit-before- ready, deadline-fires, rollback-reaps-no-leak, graceful-close of a READY sibling, sub-edge liveness, timeout config validation, and healthy-tree happy paths. Every failure test is wrapped in a hard SIGALRM budget so a regression fails fast instead of hanging CI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Hierarchical Worker startup could hang forever. A chip (L2) child that failed ChipWorker.init only wrote its error and exited, and the parent spun on `while state != INIT_DONE` with no timeout and no liveness check, so a dead or slow child deadlocked startup (the #1003 / #980 class of hang). Next-level (L4->L3) children had no readiness barrier at all: a failing inner init unwound back through the forked copy of the caller's frames instead of reporting a bounded error. Make startup a strong READY boundary: - States: INIT_READY / INIT_FAILED (renamed INIT_DONE), mirrored in the C++ MailboxState enum for parity. - Every forked child (sub, chip, next-level) publishes INIT_READY after its own init succeeds, or INIT_FAILED carrying the original error. A shared `_forked_child_main` guarantees a child always terminates via os._exit and never unwinds an exception into the parent's startup frames (which would let it SIGKILL its inherited-PID siblings); READY is published only after all fallible setup (inner init AND identity tables). - Parent barrier `_await_children_ready` waits per child with a deadline (`startup_timeout_s`, default 300s, validated positive+finite) and a waitpid(WNOHANG) liveness check; INIT_FAILED, a dead child, or a blown deadline raises a bounded RuntimeError. Applied to the sub, chip, and next-level edges (chip and next-level had no/weak barriers before). - Rollback (`_abort_hierarchical`, run on any pre-scheduler BaseException, including KeyboardInterrupt): next-level children that reached their serve loop are closed gracefully (SHUTDOWN + bounded wait) so they unlink their own nested shms; the rest are SIGKILLed; PIDs the barrier already reaped are excluded from the kill (no reused-PID signal); all are reaped and the mailboxes freed. A sibling still mid-init when another fails is SIGKILLed and its nested shms are reclaimed by the resource_tracker at exit — closing that race needs the eager-init handshake (follow-up). Next-level children are still started lazily on first run(); the barrier is recursive plumbing so a later eager-init change makes INIT_READY propagate up only after the whole subtree is ready, with no protocol change. Adds device-free ut coverage (next-level + sub init injection, faked ChipWorker on a2a3sim): bounded init-failure error, child-exit-before- ready, deadline-fires, rollback-reaps-no-leak, graceful-close of a READY sibling, sub-edge liveness, timeout config validation, and healthy-tree happy paths. Every failure test is wrapped in a hard SIGALRM budget so a regression fails fast instead of hanging CI.
Summary
Motivation
Fixes the L3 Worker hang from #980 where a chip child could exit before publishing TASK_DONE, leaving the parent polling the mailbox indefinitely and the child as .
Verification