Skip to content

Fix retryable worker teardown convergence - #1689

Open
ChaoWao wants to merge 1 commit into
hw-native-sys:mainfrom
ChaoWao:fix/complete-p0-2-teardown
Open

Fix retryable worker teardown convergence#1689
ChaoWao wants to merge 1 commit into
hw-native-sys:mainfrom
ChaoWao:fix/complete-p0-2-teardown

Conversation

@ChaoWao

@ChaoWao ChaoWao commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • drive startup rollback and READY close through one ordered cleanup-journal sequence
  • preserve unreaped child PID/mailbox ownership until waitpid and reclaim nested SHM through a root-visible, exact tree namespace
  • make region, domain, host-buffer, remote, native, child, and SHM cleanup post-success retryable
  • atomically freeze parent/child topology in add_worker and enforce init-thread ownership for public ChipWorker finalize

Contract

  • CLOSED remains the sole public admission fence; cleanup debt may be retried by a later close
  • signal delivery is not reclamation, so a live child never loses its mailbox
  • pending remote frees complete before remote session and native transport teardown
  • no C++ ABI or mailbox wire-layout change

Testing

  • 583 passed, 2 skipped: tests/ut/py/test_worker + tests/ut/py/test_chip_worker.py
  • ruff: passed
  • pyright: passed
  • pre-commit hooks on all changed files: passed

@coderabbitai

coderabbitai Bot commented Aug 4, 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: 4ebe90b2-e2a2-4fff-b8c0-286e89b83223

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

ChipWorker now enforces initialization-thread ownership. Worker now synchronizes hierarchy attachment, assigns recursive shared-memory namespaces, and retries failed teardown through a cleanup journal. Tests cover lifecycle ownership, rollback, resource retries, child reaping, and namespace isolation.

Changes

Worker lifecycle

Layer / File(s) Summary
ChipWorker lifecycle ownership
python/simpler/task_interface.py, tests/ut/py/test_chip_worker.py
ChipWorker rejects concurrent or repeated initialization. Finalization requires the initialization thread and cannot run during initialization.
Topology attachment and namespaces
python/simpler/worker.py, tests/ut/py/test_worker/test_startup_readiness.py
Worker attachment records parent ownership, rejects invalid duplicate links, synchronizes lifecycle locks, and assigns unique recursive shared-memory namespaces.
Cleanup journal and resource retention
python/simpler/worker.py
Cleanup entries are idempotent and selectively replayable. Failed reclamation remains tracked for child mailboxes, sessions, domains, regions, host buffers, mappings, and native resources.
Unified worker-tree teardown
python/simpler/worker.py, tests/ut/py/test_worker/test_cancellation_journal.py
Startup rollback and close() use shared bounded reclamation paths. Tests cover retry behavior, child survivors, reaped children, and nested namespace cleanup.

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

Sequence Diagram(s)

sequenceDiagram
  participant Worker
  participant CleanupJournal
  participant ChildProcess
  participant SharedMemory
  Worker->>ChildProcess: Reap child process
  ChildProcess-->>Worker: Exit status or ChildProcessError
  Worker->>CleanupJournal: Retain surviving PID and mailbox
  Worker->>SharedMemory: Reclaim namespace-scoped segments
  CleanupJournal->>ChildProcess: Retry child reclamation
  CleanupJournal->>SharedMemory: Retry retained cleanup
Loading

Possibly related PRs

Poem

A rabbit checks the worker tree,
And journals every failed decree.
Threads guard init from drifting feet,
Namespaces keep the nest discrete.
Retry the chores when dawn is bright—
The burrow closes clean and right.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: making worker teardown retryable and convergent.
Description check ✅ Passed The description directly explains the teardown, topology, ownership, cleanup, and testing changes in the pull request.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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.

@ChaoWao
ChaoWao force-pushed the fix/complete-p0-2-teardown branch 2 times, most recently from 1403aa6 to 329340c Compare August 4, 2026 12:44

@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: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
python/simpler/worker.py (1)

8193-8215: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Rename the misnamed loop key, and correct the now-stale docstring.

Two issues in this rewritten loop.

The loop unpacks for token, entry in entries:, but _host_buf_registry is keyed by data_ptr, not by token — see the insertion at line 8087 and the lookups in _free_host_buffer_locked at lines 8144 and 8147. The get(token) and pop(token) calls at lines 8213-8214 are therefore correct, because the value really is the data_ptr key. The name is wrong, and entry.token is a different number entirely (the _host_buf_token_counter value assigned at line 8077). A later reader who trusts the name and "corrects" these calls to use entry.token would introduce a registry leak.

The docstring at lines 8196-8199 no longer matches the new control flow. An unmap failure now raises at line 8208, which skips _close_host_shm and retains the registry entry for a journal retry. The claim that "every buffer's shm is closed even if its unmap broadcast (or a prior buffer) fails" is no longer true, and the retention is the point of the change.

♻️ Proposed fix
     def _release_all_host_buffers(self) -> None:
         """Unmap + free every still-registered host buffer (called from close()).
 
-        Per-buffer best-effort: every buffer's shm is closed even if its unmap
-        broadcast (or a prior buffer) fails, so one failure never strands the
-        rest; the first error is raised after all are attempted so close()
-        reports the leak rather than swallowing it to stderr."""
+        Per-buffer best-effort: one buffer's failure never strands the rest, and
+        the first error is raised after all are attempted so close() reports the
+        leak rather than swallowing it to stderr. A buffer whose unmap broadcast
+        fails keeps its registry entry so the cleanup journal can retry it."""
         with self._registry_lock:
             entries = list(self._host_buf_registry.items())
         errors: list[BaseException] = []
-        for token, entry in entries:
+        for data_ptr, entry in entries:
             try:
                 if self._worker is not None:  # resource presence, not lifecycle (see _close_host_shm)
                     child_errors = self._broadcast_host_unmap(entry.token)
                     if child_errors:
                         raise RuntimeError(f"host buffer token={entry.token} unmap failed: {child_errors[0]}")
                 # Tolerates a still-live view over a zero-copy buffer at close():
                 # unlinks the name regardless so the OS reclaims it once dropped.
                 self._close_host_shm(entry)
                 with self._registry_lock:
-                    if self._host_buf_registry.get(token) is entry:
-                        self._host_buf_registry.pop(token)
+                    if self._host_buf_registry.get(data_ptr) is entry:
+                        self._host_buf_registry.pop(data_ptr)
                         self._rebuild_host_buf_snapshot()
🤖 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 8193 - 8215, Rename the loop key in
_release_all_host_buffers from token to data_ptr to reflect that
_host_buf_registry is keyed by data_ptr, while keeping the existing
get(data_ptr) and pop(data_ptr) behavior unchanged. Update the method docstring
to state that unmap failures retain the registry entry and skip _close_host_shm
for journal retry, rather than claiming every buffer is closed after failures.
🧹 Nitpick comments (6)
python/simpler/task_interface.py (1)

1206-1211: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider storing the owner as a Thread object, not a raw ident.

threading.get_ident() values are recycled after a thread terminates. If the init-owner thread exits and a later thread receives the same ident, that later thread passes this owner check and calls self._impl.finalize() against a device context bound to the dead thread.

Worker already avoids this by storing self._init_owner_thread: threading.Thread | None and comparing with threading.current_thread() (see python/simpler/worker.py line 3867 and the owner check in close() at line 8922). The Worker.close() check masks the hole for the journaled _finalize_chip path, but a direct ChipWorker user — the usage documented in the class docstring at lines 1092-1099 — is not covered.

♻️ Proposed change to a non-recyclable owner reference
-        self._init_owner_thread_id: int | None = None
+        self._init_owner_thread: threading.Thread | None = None
-            self._init_owner_thread_id = threading.get_ident()
+            self._init_owner_thread = threading.current_thread()
         with self._lifecycle_lock:
-            owner = self._init_owner_thread_id
-            if owner is not None and owner != threading.get_ident():
+            owner = self._init_owner_thread
+            if owner is not None and owner is not threading.current_thread():
                 raise RuntimeError("ChipWorker.finalize() must run on the thread that called ChipWorker.init()")

tests/ut/py/test_chip_worker.py line 314 sets worker._init_owner_thread_id directly, so update that assignment to worker._init_owner_thread = threading.current_thread() as well.

🤖 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/task_interface.py` around lines 1206 - 1211, Replace the raw
ident ownership tracking used by ChipWorker.init/finalize with a
threading.Thread reference, storing threading.current_thread() and comparing the
current thread object in the owner check. Update all related state declarations
and references, including the test assignment to _init_owner_thread, while
preserving the existing finalize-in-progress validation.
tests/ut/py/test_chip_worker.py (1)

302-329: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the second new finalize() guard.

This test covers the cross-thread rejection at python/simpler/task_interface.py lines 1207-1209. The other new guard — rejecting finalize() while init() is in progress, at lines 1210-1211 — has no test. That guard protects a concurrent-teardown window, so it is worth pinning.

💚 Proposed additional test
    def test_public_wrapper_rejects_finalize_during_init(self):
        from simpler.task_interface import ChipWorker  # noqa: PLC0415  # pyright: ignore[reportAttributeAccessIssue]

        class FakeImpl:
            initialized = False
            device_id = 0

            def finalize(self):
                raise AssertionError("finalize ran while init was in progress")

        worker = ChipWorker()
        worker._impl = FakeImpl()
        worker._init_in_progress = True
        with pytest.raises(RuntimeError, match="while ChipWorker.init\\(\\) is in progress"):
            worker.finalize()
🤖 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_chip_worker.py` around lines 302 - 329, Add a unit test
alongside test_public_wrapper_rejects_cross_thread_finalize named
test_public_wrapper_rejects_finalize_during_init. Configure a fake
implementation, set worker._init_in_progress to True, call worker.finalize(),
and assert it raises RuntimeError with the message indicating ChipWorker.init()
is in progress, ensuring FakeImpl.finalize is not reached.
python/simpler/worker.py (3)

3092-3101: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Make the orphan-shm journal identity unique per segment.

Every orphan mailbox in one group is journaled under the same identity string, f"{kind} mailbox without live child". This works today because the call at line 3101 uses journal.add, which appends unconditionally.

It becomes lossy if this identity is ever routed through add_once (line 3148) or selected by drive(only=...) (line 3165), because both key on the (kind, identity) pair. Then a second orphan mailbox in the same group would be silently dropped or skipped. Include the index to keep the key unique.

♻️ Proposed change
-        for shm in shms[len(pids) :]:
+        for orphan_index, shm in enumerate(shms[len(pids) :], start=len(pids)):
 
             def _cleanup_orphan_shm(_shm=shm):
                 try:
                     _shm.close()
                 finally:
                     with contextlib.suppress(FileNotFoundError):
                         _shm.unlink()
 
-            journal.add("shm", f"{kind} mailbox without live child", _cleanup_orphan_shm)
+            journal.add("shm", f"{kind} mailbox {orphan_index} without live child", _cleanup_orphan_shm)
🤖 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 3092 - 3101, Update the orphan
shared-memory journal entry in the loop around _cleanup_orphan_shm and
journal.add so its identity includes the segment index, making each orphan
mailbox unique within the group while preserving the existing kind and cleanup
behavior.

8803-8804: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Expose the journal length instead of reading _entries directly.

CleanupJournal publishes empty and errors but no size. This diagnostic reaches into self._cleanup_journal._entries to count items. A __len__ on CleanupJournal keeps the diagnostic on the public surface, and the test at tests/ut/py/test_worker/test_cancellation_journal.py line 376 also inspects _entries.

♻️ Proposed change
     `@property`
     def empty(self):
         return len(self._entries) == 0
 
+    def __len__(self):
+        return len(self._entries)
+
         if not self._cleanup_journal.empty:
-            parts.append(f"{len(self._cleanup_journal._entries)} cleanup journal item(s)")
+            parts.append(f"{len(self._cleanup_journal)} cleanup journal item(s)")
🤖 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 8803 - 8804, Update CleanupJournal to
implement a public __len__ method returning its item count, then replace direct
_entries access in the diagnostic with len(self._cleanup_journal). Update the
cancellation journal test to use len(journal) instead of inspecting _entries
directly.

6901-6904: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider recording the suppressed reclaim error.

contextlib.suppress(BaseException) discards every failure from _reclaim_child_groups, including the TimeoutError naming surviving pids that _reap_child_groups raises at line 9164 and any mailbox close error.

The debt itself is preserved — _journal_child_survivors runs before the raise, and _describe_live_resources reports journal entries at line 8804 — so this is an observability gap, not a leak. A rollback that leaves survivors currently reports nothing about why. Writing the error to stderr, as the other best-effort paths in this file do (for example line 8106), would keep the diagnostic.

🤖 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 6901 - 6904, Record failures from
_reclaim_child_groups instead of silently discarding them in the
contextlib.suppress block, writing the exception details to stderr consistent
with other best-effort paths in worker.py. Preserve the existing suppression
behavior so reclaim errors do not interrupt rollback or signal handling.
tests/ut/py/test_worker/test_startup_readiness.py (1)

1615-1623: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add cases for the other new add_worker rejections.

This test covers the topology-parent assignment and the attached-child init() guard. Three other new rejections in add_worker have no coverage:

  • self-attachment at python/simpler/worker.py lines 6110-6111;
  • an already-attached parent at lines 6119-6120;
  • an already-attached child at lines 6123-6124.

The self-attachment case matters most. _hierarchical_start_cv wraps a plain non-reentrant threading.Lock (lines 3900-3901), so without that guard sorted(...) at line 6115 would yield the same Condition twice and with first, second would deadlock the calling thread. A test pins that the guard stays ahead of the sort.

💚 Proposed additional tests
    def test_add_worker_rejects_self_attachment(self):
        # The self-check must precede the two-lock acquire: _hierarchical_start_cv
        # is non-reentrant, so acquiring it twice would deadlock.
        w = Worker(level=4, num_sub_workers=0)
        with pytest.raises(ValueError, match="cannot add a Worker to itself"):
            w.add_worker(w)

    def test_add_worker_rejects_already_attached_child(self):
        first_parent = Worker(level=4, num_sub_workers=0)
        second_parent = Worker(level=4, num_sub_workers=0)
        child = Worker(level=3, num_sub_workers=0)
        first_parent.add_worker(child)
        with pytest.raises(RuntimeError, match="already attached to another parent"):
            second_parent.add_worker(child)

    def test_add_worker_rejects_attached_parent(self):
        root = Worker(level=5, num_sub_workers=0)
        middle = Worker(level=4, num_sub_workers=0)
        leaf = Worker(level=3, num_sub_workers=0)
        root.add_worker(middle)
        with pytest.raises(RuntimeError, match="already attached as a child"):
            middle.add_worker(leaf)
🤖 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_startup_readiness.py` around lines 1615 - 1623,
Add three tests alongside
test_add_worker_freezes_child_before_topology_publication covering
Worker.add_worker rejection paths: self-attachment must raise ValueError with
the “cannot add a Worker to itself” message before lock acquisition, attaching
an already-parented child must raise RuntimeError with “already attached to
another parent,” and adding a child from an already-attached parent must raise
RuntimeError with “already attached as a child.”
🤖 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`:
- Around line 9267-9280: Restrict the initial self._cleanup_journal.drive() in
the teardown method to an only set containing the six pre-transport keys
registered in the preceding loop, preventing residual child or shm entries from
aborting transport shutdown. Preserve the later child-entry handling in
_reclaim_child_groups, and extend
test_both_teardown_paths_retry_each_resource_class with a case where a
pre-existing child journal entry survives into _teardown_worker_tree.
- Around line 6131-6145: Update the worker lifecycle/linkage guard used by
_linkage.add_worker() to reject additions when the worker or any ancestor is no
longer in the NEW state, thereby freezing the entire subtree during
_assign_shm_namespace(). Ensure the ancestor-state check covers recursive
descendants so no worker can be appended after the namespace traversal begins.

---

Outside diff comments:
In `@python/simpler/worker.py`:
- Around line 8193-8215: Rename the loop key in _release_all_host_buffers from
token to data_ptr to reflect that _host_buf_registry is keyed by data_ptr, while
keeping the existing get(data_ptr) and pop(data_ptr) behavior unchanged. Update
the method docstring to state that unmap failures retain the registry entry and
skip _close_host_shm for journal retry, rather than claiming every buffer is
closed after failures.

---

Nitpick comments:
In `@python/simpler/task_interface.py`:
- Around line 1206-1211: Replace the raw ident ownership tracking used by
ChipWorker.init/finalize with a threading.Thread reference, storing
threading.current_thread() and comparing the current thread object in the owner
check. Update all related state declarations and references, including the test
assignment to _init_owner_thread, while preserving the existing
finalize-in-progress validation.

In `@python/simpler/worker.py`:
- Around line 3092-3101: Update the orphan shared-memory journal entry in the
loop around _cleanup_orphan_shm and journal.add so its identity includes the
segment index, making each orphan mailbox unique within the group while
preserving the existing kind and cleanup behavior.
- Around line 8803-8804: Update CleanupJournal to implement a public __len__
method returning its item count, then replace direct _entries access in the
diagnostic with len(self._cleanup_journal). Update the cancellation journal test
to use len(journal) instead of inspecting _entries directly.
- Around line 6901-6904: Record failures from _reclaim_child_groups instead of
silently discarding them in the contextlib.suppress block, writing the exception
details to stderr consistent with other best-effort paths in worker.py. Preserve
the existing suppression behavior so reclaim errors do not interrupt rollback or
signal handling.

In `@tests/ut/py/test_chip_worker.py`:
- Around line 302-329: Add a unit test alongside
test_public_wrapper_rejects_cross_thread_finalize named
test_public_wrapper_rejects_finalize_during_init. Configure a fake
implementation, set worker._init_in_progress to True, call worker.finalize(),
and assert it raises RuntimeError with the message indicating ChipWorker.init()
is in progress, ensuring FakeImpl.finalize is not reached.

In `@tests/ut/py/test_worker/test_startup_readiness.py`:
- Around line 1615-1623: Add three tests alongside
test_add_worker_freezes_child_before_topology_publication covering
Worker.add_worker rejection paths: self-attachment must raise ValueError with
the “cannot add a Worker to itself” message before lock acquisition, attaching
an already-parented child must raise RuntimeError with “already attached to
another parent,” and adding a child from an already-attached parent must raise
RuntimeError with “already attached as a child.”
🪄 Autofix

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: 19b993b6-e9d7-4562-96dd-3eb2d8ff5bce

📥 Commits

Reviewing files that changed from the base of the PR and between 3165cc8 and 706698c.

📒 Files selected for processing (5)
  • python/simpler/task_interface.py
  • python/simpler/worker.py
  • tests/ut/py/test_chip_worker.py
  • tests/ut/py/test_worker/test_cancellation_journal.py
  • tests/ut/py/test_worker/test_startup_readiness.py

Comment thread python/simpler/worker.py
Comment thread python/simpler/worker.py
@ChaoWao
ChaoWao force-pushed the fix/complete-p0-2-teardown branch from 329340c to fa7fcd1 Compare August 4, 2026 15:06
Startup rollback and close share one journal-driven resource sequence.

Unreaped child mailboxes stay owned until waitpid.

The root reclaims nested SHM through an exact tree namespace.

Parent-child topology freezes atomically.

Public ChipWorker teardown enforces init-thread ownership.
@ChaoWao

ChaoWao commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai Addressed the review-body findings in fa7fcd1:

  • Renamed the host-buffer registry key to data_ptr and corrected the retry docstring.
  • Added finalize-during-init coverage. ChipWorker already stores a threading.Thread object at the current head, so the raw-ident suggestion was already satisfied.
  • Made orphan mailbox journal identities unique and exposed CleanupJournal.len.
  • Preserved best-effort startup rollback while logging child-reclaim failures.
  • Added coverage for self-attachment, already-parented children, and attached-parent mutation.
  • Made the Linux /dev/shm namespace-scan test skip on platforms without /dev/shm, fixing the macOS UT failure.

The branch is also rebased onto current main. Focused tests: 123 passed. Full device-free Worker/ChipWorker tests: 587 passed, 2 skipped. Ruff, pyright, and changed-file pre-commit hooks pass.

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@ChaoWao Acknowledged. The reported changes address the review-body findings. The added coverage and validation results are also noted.

🐇 ✅


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

You are interacting with an AI system.

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.

1 participant