Skip to content

fix: bind the assistant child to the launcher lifetime - #98

Draft
test1card wants to merge 47 commits into
masterfrom
fix/the-source-child-dies-with-its-engine
Draft

test1card wants to merge 47 commits into
masterfrom
fix/the-source-child-dies-with-its-engine

Conversation

@test1card

@test1card test1card commented Aug 20, 2026 •

Copy link
Copy Markdown
Owner

Purpose

This pull request binds the launcher assistant process to the launcher lifetime on Ubuntu and Windows.

The original USBTMC source-child fix is already in master. This branch updates the existing pull request instead of opening a duplicate.

Mechanism

A shared process_lifetime.py module now owns the operating-system bindings.

  • On Linux, the assistant installs PR_SET_PDEATHSIG before optional runtime work starts. It also checks the captured parent identity before and after prctl.
  • On Windows, the launcher creates the assistant process suspended. It assigns the process to a kill-on-close Job Object before it resumes the process.
  • The launcher keeps the exact process and Job owners when assignment or resume fails. Cleanup can therefore terminate the correct child.
  • Early child diagnostics use a bounded, non-blocking stderr read.
  • Subreaper setup is restored if parent construction fails.
  • The engine process identity is captured before readiness can fail, so cleanup cannot lose that process.

The USBTMC and report-process deletions are moves into the shared module. The branch retains their old private import names.

Scope limit

PR_SET_PDEATHSIG is not transitive. This change binds the launcher assistant and preserves the USBTMC binding. It does not cover the other multiprocessing children.

Review corrections

This head closes four independent review findings:

  1. A Windows child cannot execute before Job assignment.
  2. Early stderr collection cannot wait forever for an open writer.
  3. A failed parent construction restores the previous subreaper state.
  4. A readiness failure cannot lose the engine process identity.

The native-Windows guard uses SIGTERM where SIGKILL is unavailable. Linux still uses the real SIGKILL boundary.

Verification for this head

The coordinator replaced the corrected production blobs with their exact pre-fix blobs. The three new guards failed. The coordinator then restored the corrected blobs byte-for-byte.

Native Windows passed the three affected modules: 95 passed and 8 skipped. The full governance set passed 308 tests. The documentation freshness gate passed 68 tests after the generated pair became the last commit. Read-only Ruff check and format check passed for all five changed Python files.

Ubuntu hosted checks remain pending. This pull request stays a draft until the exact-head review is clean.

Written with AI assistance.

soak measurement added 5 commits August 20, 2026 13:13
Found by review on pull request #95, and it undermines the argument that change
rests on.

USBTMCTransport is the Keithley 2604B transport, and the Keithley drives the
heater. It puts the native VISA session in a separate multiprocessing child so a
blocking native call cannot stall the engine event loop. That child is daemonic,
and multiprocessing terminates a daemonic child from an atexit handler -- which
runs only when the parent exits NORMALLY. An engine that is killed, or that
crashes, never runs it, and the child survives.

The survivor is not merely untidy. The launcher restarts a dead engine, the
replacement connects and commands OFF on every channel -- and the orphan pending
write can land AFTER that, leaving the instrument sourcing while the software
believes it is off. Two owners of one source is precisely the hazard that makes
a crash answerable by a restart at all, so this had to be closed before that
reasoning is sound.

On Linux the kernel does it: PR_SET_PDEATHSIG asks for SIGKILL when the parent
dies, whatever the cause. It carries the classic race -- the parent can die
between the fork and the request, so the signal is asked for against a parent
that is already gone -- so the parent identity is re-read afterwards and a
mismatch exits at once. A child that cannot be bound at all does not run:
refusing costs one failed open, which the transport reports, while continuing
would risk the orphan.

The tests kill a real parent with SIGKILL, the one signal a process cannot
handle and therefore the one case atexit can never cover, and require the child
to be gone. They are Linux-only and verified on the target.
Found by running the falsification rather than by reading. The first version of
this module killed a parent whose child was blocked reading the pipe -- and it
passed with the binding REMOVED, because the parent death closes the write end,
the read returns end-of-file, and that child leaves by itself. It proved nothing
about the guard.

The child actually at risk is the one INSIDE a native VISA call: not reading the
pipe, unable to see the end-of-file until the call returns, and holding the
session that talks to the source. These tests use that child, and they come in a
pair: a control that reproduces the orphan without the binding, and the same
child bound, which the kernel kills with its parent.
spawn re-imports the main module to rebuild the target, and a "python -c" main
module cannot be re-imported: the child died on a traceback before running a
line, and the control reported "no orphan" because the child was never alive to
become one. The parent is a real file now, and the helper waits and re-checks
before killing, so a child that fails to start is reported as that rather than
as an absent hazard.

Measured properly the hazard is real in every shape tried -- a busy child
survives a SIGKILLed parent while sleeping, while spinning in Python, and while
blocked in a native call that holds the GIL. The last of those is the shape a
real VISA call has.

Control and guard now live in one test per shape, so a control that stops
reproducing the orphan fails the guard with it. A guard whose hazard cannot be
reproduced is a guard nobody can trust.
@test1card

Copy link
Copy Markdown
Owner Author

@codex review

Head is 89c12d7cb1c6810522f0ce5f5a814dc2ff9e837c. This closes the descendant finding you raised on #95, and it belongs here rather than there because it is the driver's contract, not the launcher's. USBTMCTransport is the Keithley's transport and the Keithley drives the heater, so an orphaned VISA child can finish a write after a replacement engine has commanded OFF. PR_SET_PDEATHSIG binds the child's lifetime to its parent, the parent identity is re-read afterwards to catch the fork race, and a child that cannot be bound exits rather than running unbound. Two of my own measurement mistakes are written into the module docstring and the pull request body, because the first two controls did not reproduce the hazard at all -- one child died on pipe end-of-file, the other died on a traceback because spawn cannot re-import a -c main module. Measured properly it reproduces while sleeping, while spinning, and while holding the GIL in a native call.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 89c12d7cb1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/cryodaq/drivers/transport/usbtmc.py Outdated
Comment on lines +468 to +470
libc = ctypes.CDLL("libc.so.6", use_errno=True)
if libc.prctl(_PR_SET_PDEATHSIG, signal.SIGKILL, 0, 0, 0) != 0:
raise OSError(ctypes.get_errno(), "prctl(PR_SET_PDEATHSIG) failed")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Fence replacement writes until the old VISA owner is gone

If the engine dies while the VISA child has submitted or is blocked in a source write, PR_SET_PDEATHSIG only requests SIGKILL; it supplies no receipt that the child and its in-flight USB operation settled before the launcher starts a replacement after its three-second backoff (launcher.py:5651-5658, 5743). The new test permits six seconds for death and exercises only interruptible sleep stand-ins rather than _visa_process_main performing a VISA write, so it does not establish the required ordering. An old transfer can therefore still reach the instrument after the replacement's OFF unless restart/source authority is fenced on descendant/I/O settlement or an independent hardware mechanism.

AGENTS.md reference: AGENTS.md:L380-L386

Useful? React with 👍 / 👎.

Comment on lines +208 to +209
usbtmc.os.getppid = lambda: 1
usbtmc._bind_lifetime_to_parent()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Exercise the post-prctl parent-change branch

This stub returns 1 on the first getppid() call, so _bind_lifetime_to_parent exits through the earlier expected_parent <= 1 branch without loading libc, invoking prctl, or reaching the post-call identity comparison that this test claims to guard. Deleting lines 476-479 of the production function therefore leaves this test green; make the stub return a valid original parent first and a different parent on the second read, and verify that prctl was reached.

AGENTS.md reference: AGENTS.md:L370-L378

Useful? React with 👍 / 👎.

Comment on lines +188 to +190
finished = subprocess.run([sys.executable, "-c", program], capture_output=True, timeout=30)
assert b"KEPT RUNNING" not in finished.stdout, (
"a child that could not bind its lifetime to its parent must exit, not continue"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject unrelated subprocess failures in binding guards

When this probe fails to import the module, construct the ctypes double, or execute the helper for any unrelated reason, KEPT RUNNING is also absent and the assertion passes; the parent-race probe at line 214 has the same false-green shape. Require an explicit marker proving the intended prctl path ran and validate the expected exit status so a traceback or premature interpreter failure cannot satisfy these safety guards.

AGENTS.md reference: AGENTS.md:L380-L386

Useful? React with 👍 / 👎.

Comment on lines +123 to +124
time.sleep(1.0)
assert _alive(child_pid), f"the child died before the parent was killed; stderr={parent.stderr.read()[:600]!r}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid reading stderr while the probe parent is alive

If a regression makes the spawned child die after its PID is reported, evaluating this assertion message calls parent.stderr.read() while the parent is still sleeping for 600 seconds and still owns the pipe, so the read blocks and the finally cleanup cannot run. The guard then hangs for roughly ten minutes instead of reporting the startup failure; terminate/wait for the parent before reading stderr, or use a bounded/nonblocking diagnostic read.

AGENTS.md reference: AGENTS.md:L296-L298

Useful? React with 👍 / 👎.

Comment thread src/cryodaq/drivers/transport/usbtmc.py Outdated
Comment on lines +461 to +464
expected_parent = os.getppid()
if expected_parent <= 1:
# Already reparented: the parent died before we got here. Nothing can be bound.
os._exit(0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Capture the engine PID before the child can be reparented

If the engine dies before the spawned child executes this first getppid(), and a launcher or service supervisor is a Linux child subreaper, the call returns that surviving ancestor's PID rather than the dead engine's PID. The child then successfully binds PDEATHSIG to the wrong process, the second identity read matches, and the VISA owner can outlive the engine exactly as before. Capture os.getpid() in the engine before Process.start() and pass that expected PID to the child so reparenting before its first instruction is detected.

AGENTS.md reference: AGENTS.md:L470-L471

Useful? React with 👍 / 👎.

test1card pushed a commit that referenced this pull request Aug 20, 2026
Closes the remaining review finding on pull request #95. The other one, about
engine descendants outliving the engine, is closed by #98 -- it is the driver's
contract rather than the launcher's, and the Keithley's VISA child is the thing
that had to be bound.

_EngineShutdownWorker is a QThread whose run() is blocked inside send_command on
the very bridge recovery is about to shut down. When a replacement exited while
that reply was still outstanding, _stop_engine raised after its grace period,
the exit was reclassified as observed, and retiring the incarnation dropped the
only retained reference to a thread that was still running. Qt is then free to
destroy it -- "QThread: Destroyed while thread is still running" -- which stops
the launcher instead of recovering it. That is the opposite of what this branch
is for.

The worker is now waited out first, bounded twice over: its command carries its
own timeout, and the wait has its own so a wedged worker cannot stall the Qt
main thread. If it finishes, the reference is cleared and the identity retired
as before. If it does not, the reference is KEPT, the identity is NOT retired,
and the operator sees a HOLD saying so -- an owner that cannot be settled is the
one thing that must still hold.

The backoff fixture had to say something out loud. It is a MagicMock, which
answers every attribute, so it was auto-vivifying a shutdown worker that was
permanently "still running" -- a launcher that has not dispatched a shutdown has
none. Three tests failed on that invented worker rather than on any production
behaviour.

Dropping the reference again reddens exactly the new test.
soak measurement added 2 commits August 20, 2026 13:38
…e binder

Four review findings on pull request #98.

The child must not decide which parent it belongs to. If the engine dies before
the child runs its first instruction, and any ancestor is a Linux child
subreaper, getppid() answers with that surviving ancestor -- so the child bound
PDEATHSIG to the wrong process, the second identity read agreed with itself, and
the VISA owner outlived the engine exactly as before. The soak runner IS such a
subreaper, so this was not hypothetical. The engine captures its own pid before
Process.start() and hands it over.

Three false-green shapes in the tests, all the same kind: the absence of a
marker is not proof that the intended path ran.

  - the two refusal probes passed if the module failed to import, the ctypes
    double was wrong, or anything unrelated failed, because the marker was
    absent either way. Every probe now prints one immediately before calling
    the binder, and the exit status is checked as well.
  - the parent-race probe returned 1 from the FIRST getppid(), so the function
    left through the already-reparented branch without loading libc or calling
    prctl at all -- deleting the post-call comparison left it green. It answers
    with a real parent first and a different one after now.
  - the diagnostic on a failed start read the parent stderr while the parent was
    still sleeping for ten minutes and still owned the pipe, so a clear startup
    failure became a ten-minute hang with no message. The parent is killed and
    reaped before its stderr is read.
@test1card

Copy link
Copy Markdown
Owner Author

@codex review

Head is 4ece319c23251dca301c0cfbd27bb5ae922a6f9f. Four of the five findings are closed and the fifth is answered rather than patched.

Closed: the engine captures its own pid before Process.start() and hands it to the child, because a child reading getppid() under a subreaper -- and the soak runner IS one -- can bind to the wrong process and agree with itself. The three false-green test shapes are gone: every probe prints a marker immediately before calling the binder and its exit status is checked, the parent-race probe now answers with a real parent first and a different one after so it actually reaches prctl, and the failed-start diagnostic kills and reaps the parent before reading a pipe the parent still owns.

NOT closed, and I do not think it can be closed in software: your point that PR_SET_PDEATHSIG supplies no RECEIPT that an in-flight USB transfer settled before the replacement's OFF. That is right. This change removes the mechanism by which the owner process survives at all, which shrinks the window from unbounded to the kernel's own delivery, but it does not order the transfer against the replacement. Fencing the restart on descendant settlement would still be an inference from process state, not a receipt from the instrument.

The project's own laboratory checklist already names the closing element: an independent latching de-energize-to-trip cutout, section A8e, currently marked NOT PASSED, with the reference manual quoted as saying the SMU's own digital output is unsuitable for safety circuits. That is hardware, and it is the owner's to build. I would rather record this residual honestly here than imply software closed it.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4ece319c23

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/cryodaq/drivers/transport/usbtmc.py Outdated
os._exit(0)


def _visa_process_main(connection: Any, expected_parent: int = 0) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep direct worker tests from terminating pytest

On Linux, five existing protocol tests call _visa_process_main(connection) with one argument, so this default supplies 0, _bind_lifetime_to_parent reaches os._exit(0), and the entire pytest process exits successfully before those tests or any later tests run. I reproduced this with the combined new/protocol modules: pytest collected 69 items, stopped at the first direct worker test, emitted no session summary, and returned status 0; thus ordinary local runs can falsely report success, while the receipt-enforcing candidate runner should fail for a missing population receipt. Make the parent PID mandatory and adapt the in-process tests to exercise the worker loop without invoking a process-fatal binder.

AGENTS.md reference: AGENTS.md:L366-L369

Useful? React with 👍 / 👎.

one source is the exact hazard the ownership design exists to prevent, and it is the reason
an engine crash may be answered with a restart at all.

WHICH CHILD IS AT RISK, and TWO measurement mistakes made on the way to knowing it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Register the admitted false-green failures

This module explicitly records two confirmed measurement mistakes, plus the underlying source-owner survival defect, but the slice adds no corresponding runtime or false-green entries to governance/agent_preventions.yaml; the existing USBTMC-CLOSE-SETTLEMENT-001B record is limited to cancellation/incomplete-close ownership and does not cover abrupt parent death or either invalid probe shape. Consequently these acknowledged failures have no stable IDs, default-CI guard bindings, or red/green evidence in the repository's mandatory prevention map, so this corrective disposition cannot close until those obligations are registered (or an existing class is explicitly strengthened).

AGENTS.md reference: AGENTS.md:L361-L369

Useful? React with 👍 / 👎.

soak measurement added 2 commits August 20, 2026 14:32
Continuous integration failed on a suite where every one of 693 tests passed,
with "candidate pytest invocation 2 emitted 0 population receipts instead of
one".

The control case leaks a process on purpose, because that IS the hazard. But the
leaked process inherited pytest's own standard output and error, so it kept
those pipes open after pytest had exited and the runner could not close its
accounting. What is under test is a process outliving its parent, not the pipes
it happens to hold, so the child now redirects its own standard streams to the
null device before it does anything else.

It is also reaped rather than merely signalled. It is nobody's child any more,
so nothing will wait on it, and leaving it running is how one test's deliberate
leak becomes the next test's environment.
@test1card

Copy link
Copy Markdown
Owner Author

@codex review

Head is c9d3e02c1e6a28bdddbcb1babb7bb4ab9b3135a3. This push fixes a failure my own test caused, not a production one: continuous integration failed on a suite where all 693 tests PASSED, with candidate pytest invocation 2 emitted 0 population receipts instead of one. The control leaks a process on purpose -- that is the hazard -- but the leaked process inherited pytest's stdout and stderr and kept those pipes open after pytest exited, so the runner could not close its accounting. What is under test is a process outliving its parent, not the pipes it holds, so the child redirects its own standard streams to the null device first, and it is reaped rather than merely signalled: it is nobody's child any more, so nothing would wait on it.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Something went wrong. Try again later by commenting “@codex review”.

Unknown error
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@test1card
test1card marked this pull request as draft August 20, 2026 13:43
soak measurement added 2 commits August 20, 2026 23:02
One review finding, and it is the shape where a safety mechanism turns on the
person trying to test it.

FIVE PROTOCOL TESTS CALL `_visa_process_main(connection)` WITH ONE ARGUMENT. With
the parent-death binding at the top of that function and `expected_parent`
defaulting to 0, those tests bound their own lifetime to a parent that is not the
one they have -- on Linux that is a signal to the test process itself.

The entry point now REQUIRES `expected_parent`, so no caller can reach the binding
by accident, and the request loop is split into `_visa_worker_loop`, which the
direct tests call instead. The binding still happens before any VISA handle
exists, which is the property it was written for.

Authored by a worker lane, reviewed by a second model, and verified here rather
than taken on either one's word: LF endings, ruff clean, and both properties
falsified -- restoring the default parent, and moving the binding after the loop,
each turn exactly one test red.

`tests/drivers/test_usbtmc_process_protocol.py` and
`tests/drivers/test_the_source_child_dies_with_its_engine.py` are green at
65 passed, 5 skipped.
@test1card

Copy link
Copy Markdown
Owner Author

@codex review

Head is e14ff4a8148b2af78c04b0858791d079dd786cca. The finding is closed, and it was the shape where a safety mechanism turns on the person testing it.

Five protocol tests call _visa_process_main(connection) with one argument. With the parent-death binding at the top of that function and expected_parent defaulting to 0, those tests bound their own lifetime to a parent they do not have — on Linux, a signal aimed at the test process.

The entry point now requires expected_parent, so no caller can reach the binding by accident, and the request loop moved into _visa_worker_loop, which the direct tests call instead. The binding still runs before any VISA handle exists, which is the property it was written for.

Authored by a worker lane, reviewed by a second model, and verified here rather than taken on either one's word: LF endings, ruff clean, and both properties falsified — restoring the default parent, and moving the binding after the loop, each turn exactly one test red.

tests/drivers/test_usbtmc_process_protocol.py and tests/drivers/test_the_source_child_dies_with_its_engine.py are green at 65 passed and 5 skipped; tests/docs is green at 68 passed with the derived pair regenerated.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e14ff4a814

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".


try:
assert parent.stdout is not None
line = parent.stdout.readline().decode().strip()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Bound the child-PID read

When the spawned parent remains alive but stalls in Process.start() before printing the child PID, this blocking readline() has no timeout, so the Ubuntu remaining process test hangs indefinitely and never reaches the finally cleanup. Read the startup line with a bounded poll/selector or otherwise terminate the parent after a fixed startup deadline so this safety regression remains deterministic.

AGENTS.md reference: AGENTS.md:L296-L298

Useful? React with 👍 / 👎.

Comment on lines +150 to +152
time.sleep(1.0)
if not _alive(child_pid):
raise AssertionError(_diagnose("the child died before the parent was killed"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Wait for the child to finish binding before killing its parent

When child startup takes longer than this fixed one-second sleep, _alive(child_pid) proves only that the interpreter exists, not that _bind_lifetime_to_parent() reached prctl. The parent can then be killed first; even with the prctl call deleted, the late binder exits through its initial parent-identity mismatch while the unbound control survives, so the real parent-death guard remains green without exercising PDEATHSIG. Require a bounded readiness marker emitted after binding (and at the equivalent point in the control) before killing the parent.

AGENTS.md reference: AGENTS.md:L380-L386

Useful? React with 👍 / 👎.

soak measurement added 2 commits August 21, 2026 09:23
Two review findings on the same regression, both correct.

The child PID was read with an unbounded readline(), so a parent that stalled
inside Process.start() hung the Ubuntu run forever and never reached cleanup.
The read is now bounded by a selector with a fixed deadline.

The one-second sleep before killing the parent proved only that an interpreter
existed, not that _bind_lifetime_to_parent had reached prctl. With the prctl
call deleted, a late binder still exited through its initial parent-identity
mismatch while the unbound control survived -- so the guard stayed green
without ever exercising PDEATHSIG. The child now sends a READY marker after
binding, and at the equivalent point in the no-binding control, and the parent
is killed only after that marker arrives.

Stated plainly rather than glossed: these guards SKIP on Windows, so this
machine can show only that the module imports and the one portable test passes.
The evidence that matters comes from the Ubuntu run.
@test1card

Copy link
Copy Markdown
Owner Author

@codex review this pull request at head b670ca442ce77cc0c937db868bf503ad97474c9e.

Both findings are answered.

Bounded startup read. The unbounded readline() is replaced by a selector with a fixed
deadline, so a parent that stalls inside Process.start() can no longer hang the run past
its cleanup.

Readiness before the kill. The fixed one-second sleep is gone. The child sends a READY
marker after binding — and at the equivalent point in the no-binding control — and the
parent is killed only once that marker arrives. That is what makes the test exercise
PDEATHSIG rather than a late child's initial parent-identity mismatch.

One thing said plainly instead of glossed: these guards SKIP on Windows, where this work
was prepared. On this machine the module imports and the single portable test passes; five
tests skip. The evidence that matters comes from the Ubuntu run, and I am not claiming more
than that here.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Nice work!

Reviewed commit: b670ca442c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

soak measurement added 2 commits August 23, 2026 12:11
# Conflicts:
#	docs/architecture-montana-important.svg
#	docs/current_candidate_metrics.md
@test1card

Copy link
Copy Markdown
Owner Author

@codex review this pull request at head 0d8c04ae23ffdac2909d9bc4578af70d0ec64f40.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0d8c04ae23

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/cryodaq/drivers/transport/usbtmc.py Outdated
Comment on lines +465 to +468
if not sys.platform.startswith("linux"):
# Elsewhere the daemonic flag remains the only mechanism, and an abruptly dead
# parent can still leave this process behind. The laboratory target is Ubuntu.
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Fail closed when parent-death binding is unavailable

On Windows—the real USBTMC/VISA path prescribed by docs/lab_verification_checklist.md:249-250—this branch returns before installing any lifetime coupling, even though the comment acknowledges that an abruptly terminated engine can leave the source-owning child alive. A later manual or supervised engine start can therefore create a second VISA owner while the orphan finishes a source write; either provide equivalent Windows process-tree ownership (such as a kill-on-close Job Object) or refuse non-mock VISA startup on platforms without it.

AGENTS.md reference: AGENTS.md:L470-L471

Useful? React with 👍 / 👎.

@test1card

Copy link
Copy Markdown
Owner Author

@codex review this pull request at head 14508007d7d21379459896b822344660eab7a416.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. More of your lovely PRs please.

Reviewed commit: 14508007d7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

test1card added a commit that referenced this pull request Aug 28, 2026
Integrates #98 as block C. Zero product conflicts with the candidate -- it touches only
src/cryodaq/drivers/transport/usbtmc.py and its two test files.

The property: no Keithley transport subprocess may outlive the Engine that owns it. On Linux
this uses the kernel's own guarantee -- prctl(PR_SET_PDEATHSIG, SIGKILL) in the child, so the
kernel kills it when the parent dies, rather than trusting a cleanup path that a crashed parent
never reaches. If the call fails it raises rather than continuing silently.

Why the campaign requires this before any crash test with real hardware: two processes able to
command the same source is the single-actuator-owner invariant broken, and an orphan holding the
instrument after its Engine died is how that happens in practice. Until this is proven with a
real subprocess on the target platform, Engine crash/restart stays outside the candidate's
physical claim and is exercised only against a dummy load with independent manual protection.

Reversion control on this candidate: 100 passed, 2 skipped with the change; production reverted,
34 fail. The two skips are environment-gated, not silenced assertions.

Block C. Semantic delta only; the merge's governance baseline was reverted to master and
reconciles once at freeze.
@test1card

Copy link
Copy Markdown
Owner Author

The production fix on this branch is already in master, under c014d861d, byte-identical — src/cryodaq/drivers/transport/usbtmc.py and tests/drivers/test_the_source_child_dies_with_its_engine.py. It was correctly not re-applied.

But the fix is narrower than this pull request's title, and the gap is measurable.

What master binds

One child: the USBTMC transport child, with a real kernel binding — Linux prctl(PR_SET_PDEATHSIG, SIGKILL), Windows a Job Object carrying JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE. report_process.py carries a Job Object of its own.

What nothing binds

The launcher's own assistant child. At src/cryodaq/launcher.py:6068 it is started with a plain subprocess.Popen, on Windows with CREATE_NO_WINDOW, and its shutdown path is a file sentinel the launcher writes. A file sentinel only works if the launcher lives long enough to write it.

The measurement

Taken 2026-08-30 against master 9cd72a91b. Twelve CryoDAQ child processes were spawned about forty seconds apart between 14:04:29 and 14:11:54. At 15:03 every one was still running and every one of their parent process ids was gone from the process table:

eleven multiprocessing spawn children      73.5 – 99.2 MiB each
one   cryodaq.agents.assistant_bootstrap          103.1 MiB
combined working set                            1042.6 MiB

Measured separately the same morning, from a genuinely clean slate with every process killed between attempts: the launcher fails to reach engine readiness about one time in three. A failed construction is exactly the path that strands the assistant, because the graceful sentinel is never written.

So a laboratory week that restarts a dozen times accumulates roughly a gigabyte of abandoned children. That is the memory-growth criterion directly, and it is not what master fixes.

What is being done

The gap is being closed on this branch rather than in a new pull request, because it is the same subject one scope wider. The work is to bind the launcher's assistant child the way the transport child is bound — reusing the two mechanisms that already exist rather than writing a third copy — and to cover the failure paths, not only clean shutdown: a launcher that fails during construction, one that is killed, and one whose engine dies under it. The guard must start the real thing and assert no child survives; a test that simulates the process topology cannot see this.

The eleven multiprocessing children are in scope for the answer, not necessarily for the fix: whether a binding on the parent covers them transitively will be stated from evidence or stated as unknown, not assumed.

The branch's own remaining delta against master is otherwise governance records and the regenerated derived pair.

Written with AI assistance; the process measurements above were taken and read directly.

@test1card test1card changed the title fix: the child that owns the source must die with its engine fix: the assistant child must die with its launcher, on both operating systems Aug 30, 2026
@test1card

Copy link
Copy Markdown
Owner Author

@codex review 2b6445f

This branch has been replaced, so please review it as a new change rather than as a revision.
Its previous head's source and test files were byte-identical to master — that work merged with the
integration candidate. What is here now is the follow-up master does not have: the launcher's
assistant child had no kernel lifetime binding at all.

Three things I would most like challenged:

  1. The extraction. usbtmc.py (-47) and report_process.py (-84) are a move into
    process_lifetime.py, not a removal. My equivalence evidence is that master's own merged
    USBTMC proof suite passes unchanged at this head (100 passed, 2 skipped). If that suite does not
    actually cover some behaviour the move changed, that is the finding I most want.
  2. The ordering claim. The assistant binds before logging, configuration, sockets, renderers or
    any optional model import can take runtime ownership. If anything imported earlier in
    assistant_bootstrap can already own a resource, the binding is too late.
  3. The Windows/Linux asymmetry. The three real parent-death tests are @_LINUX_ONLY and skip on
    Windows; the Job Object half is covered by two ownership tests that run on both. I have said so in
    the body rather than letting a green Windows run imply more than it proves.

Controls at this head, each asserting its mutation applied before any result was read, and each
file restored byte-identically and verified by hash:

control result
assistant no longer consumes the parent grant exit 1, 3 failed
launcher no longer publishes the parent grant exit 1, the same 3
launcher no longer creates the kill-on-close Job exit 1, 2 failed, DID NOT RAISE RuntimeError

No test was weakened: both test files are pure additions, 477 insertions and 0 deletions.

It does not claim to fix everything. PR_SET_PDEATHSIG is not transitive, so the eleven
multiprocessing children observed in the same measurement remain uncovered, and nothing here asserts
otherwise.

Local at this head: new tests 67 passed; tests/governance 308 passed; tests/docs 68 passed after
the changed-Python count was re-derived (736, read out of the guard, set difference both ways) and
the derived pair regenerated as the last commit; ruff clean over the seven changed Python files.

Written with AI assistance.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Aug 30, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-08-31T13:07:05.897873Z 1d1da7a Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2b6445f308

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/cryodaq/launcher.py
# bounded report children, assignment failure is fail-closed
# below and the retained job handle remains launcher-owned for
# exactly as long as the assistant incarnation.
parent_job = create_windows_kill_on_close_job(process)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Close the Windows spawn-to-Job gap

On Windows, if the launcher exits after Popen returns but before this assignment completes, the already-running assistant has never joined the kill-on-close Job, so it survives exactly the launcher death this change is intended to cover; it may also create descendants during that interval that are outside the subsequently assigned tree. The two Windows tests mock the post-spawn assignment and therefore cannot detect this window; the child must not run until its Job ownership is established.

AGENTS.md reference: AGENTS.md:L470-L471

Useful? React with 👍 / 👎.

deadline = time.monotonic() + 15.0
while time.monotonic() < deadline and not readiness_path.is_dir():
if _identity_exited(assistant_pidfd):
stderr = b"" if parent.stderr is None else parent.stderr.read(4000)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep early-exit diagnostics from blocking the lifecycle guard

When the assistant exits before creating the readiness directory while the launcher harness remains alive, this bounded-looking diagnostic actually blocks because BufferedReader.read(4000) waits for 4,000 bytes or EOF, but the harness retains the stderr writer in its infinite loop. Fresh evidence in this replacement branch is that the new harness repeats the previously identified live-parent stderr-read shape, so an early bootstrap regression can hang the test instead of entering its bounded cleanup; terminate the parent first or use a nonblocking/bounded diagnostic read.

AGENTS.md reference: AGENTS.md:L296-L298

Useful? React with 👍 / 👎.

Comment on lines +247 to +253
parent = subprocess.Popen(
[sys.executable, "-B", str(harness), mode],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=env,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Restore subreaper state when launcher creation fails

If Popen raises after _set_subreaper(True)—for example under process or descriptor exhaustion—execution never enters the try/finally below, so pytest remains a child subreaper for the rest of the run. Fresh evidence in this replacement harness is that process-global state is enabled at line 233 while the new launcher creation remains outside the cleanup scope; later process tests can consequently adopt unrelated descendants and produce leaks or topology-dependent false results. Put process creation inside the restoration boundary.

AGENTS.md reference: AGENTS.md:L296-L298

Useful? React with 👍 / 👎.

Comment on lines +285 to +288
if mode == "engine-death":
engine_pid = report["engine_pid"]
assert type(engine_pid) is int and engine_pid > 1
engine_pidfd = _pidfd_open(engine_pid)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Track the engine before any fallible readiness checks

In engine-death mode, the harness has already spawned and reported the 600-second engine, but its PID is not captured until after assistant pidfd setup and readiness. If either of those earlier operations fails or readiness times out, finally kills only the launcher and assistant; the sibling engine is adopted by the test's subreaper and left sleeping, then potentially left as an unreaped child. Fresh evidence here is the new harness report already contains engine_pid, so capture and pin it immediately and settle it unconditionally during cleanup.

AGENTS.md reference: AGENTS.md:L296-L298

Useful? React with 👍 / 👎.

@test1card test1card changed the title fix: the assistant child must die with its launcher, on both operating systems fix: bind the assistant child to the launcher lifetime Aug 30, 2026
@test1card

Copy link
Copy Markdown
Owner Author

@codex review this pull request at exact head 5b8dd261ea81ecf07560e0b234b6c4f3660f766e.

This head closes the Windows pre-Job execution race, the blocking early-stderr read, subreaper restoration on construction failure, and engine cleanup after readiness failure. The coordinator reproduced all three new production controls red against the exact pre-fix blobs, restored them byte-for-byte, and ran the Windows, governance, documentation, lint, and format gates stated in the pull-request body.

@test1card

Copy link
Copy Markdown
Owner Author

Ubuntu 22.04 verification for exact head 5b8dd261ea81ecf07560e0b234b6c4f3660f766e is complete.

The coordinator fetched refs/pull/98/head into a clean worktree in CryoDAQ-Lab-Ubuntu-22.04. The worktree resolved to the exact pushed head. The affected launcher modules passed with the laboratory interpreter:

PYTHONPATH=$PWD/src QT_QPA_PLATFORM=offscreen .venv/bin/python -B -m pytest -q tests/launcher/test_assistant_parent_lifetime.py tests/test_launcher_periodic_runtime.py tests/test_launcher_shutdown_ownership.py

Result: 103 passed. This supplements the native-Windows result in the pull-request body. It does not claim physical hardware coverage.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5b8dd261ea

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +102 to +104
monkeypatch.setattr(module.subprocess, "Popen", popen)
monkeypatch.setattr(module, "create_windows_kill_on_close_job", create_job)
monkeypatch.setattr(module, "resume_windows_process", resume, raising=False)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Exercise the real Windows Job boundary

On native Windows, these registered guards still replace Popen, Job assignment, and resume with Python doubles, so removing JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE or breaking the real AssignProcessToJobObject call leaves every Windows assistant-lifetime guard green. Fresh evidence at this head is that the prevention registry adds only these mocked Windows nodes, while no test kills a real launcher and observes its real assistant tree exit; add a production-boundary Windows test that exercises the actual spawn, assignment, resume, and launcher-death path.

AGENTS.md reference: AGENTS.md:L380-L386

Useful? React with 👍 / 👎.

Comment thread src/cryodaq/process_lifetime.py Outdated
Comment on lines +165 to +167
import psutil

psutil.Process(process.pid).resume()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid requiring a dev-only package at runtime

In a Windows installation resolved from the project's base package metadata rather than the repository's developer lock, psutil is absent because pyproject.toml declares it only in the dev extra and explicitly describes it as dev-only. Every assistant start now reaches this import after creating the child suspended and assigning its Job, so it raises ModuleNotFoundError, reports a post-spawn construction failure, and the assistant never runs; use the existing Windows handle/API approach or declare psutil as a runtime dependency.

Useful? React with 👍 / 👎.

Comment on lines +7728 to +7732
- node: tests/launcher/test_assistant_parent_lifetime.py::test_early_exit_diagnostic_is_bounded_while_writer_remains_open
ci_partition: remaining
- node: tests/launcher/test_assistant_parent_lifetime.py::test_parent_creation_failure_restores_subreaper
ci_partition: remaining
- node: tests/launcher/test_assistant_parent_lifetime.py::test_engine_identity_is_cleaned_if_readiness_fails_after_report

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Register the new assistant false-green escapes

Fresh evidence at this head is that the blocking diagnostic, construction-failure restoration, and readiness-cleanup regressions are added only as guards beneath the existing runtime record, while false_green_pairs still contains only the earlier binder-deletion escape for this prevention. Because each newly reproduced defect remained undetected by the previously green suite, each coverage escape needs its own stable false-green ID, default-CI guard binding, and red/green evidence before this disposition can close.

AGENTS.md reference: AGENTS.md:L366-L369

Useful? React with 👍 / 👎.

Comment on lines +295 to +298
if mode == "engine-death":
engine_pid = report["engine_pid"]
assert type(engine_pid) is int and engine_pid > 1
engine_pidfd = _pidfd_open(engine_pid)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Clean up the engine when its pidfd cannot be opened

If opening the reported engine's pidfd fails here—for example because the process hits EMFILE or pidfds become unavailable after preflight—engine_pid is recorded but engine_pidfd remains None. The finally block consequently kills only the harness parent; its 600-second engine is adopted by the test subreaper without ever being signalled, and _reap_adopted_child times out while leaving that process alive. Establish an unconditional parent-owned engine cleanup path before this fallible open, or otherwise settle the reported identity when pidfd acquisition fails.

AGENTS.md reference: AGENTS.md:L291-L298

Useful? React with 👍 / 👎.

@test1card

Copy link
Copy Markdown
Owner Author

@codex review

Please review exact head b673aafbba7a3855a38b7d21a19ef591252ef4c4.

This head answers the four findings from the prior review:

  1. Windows resume uses NtResumeProcess on the exact suspended child handle. The runtime path no longer imports the development-only psutil package.
  2. A real native Windows guard starts the production launcher and assistant, kills the launcher, and proves that closing the production Job Object terminates the exact assistant process.
  3. The three distinct false-green classes now have separate registry entries and exact default-CI guards.
  4. The Linux launcher harness gives its engine a parent-owned pipe before the later pidfd_open can fail. Launcher death closes the pipe, so the reported engine cannot remain in the old 600-second sleep.

Independent coordinator verification on Windows passed 71 tests with four expected Linux skips. The prevention registry tests, bound documentation gate, fixed-point derived artifacts, Ruff check, Ruff format check, and diff check passed.

The local hosted-equivalent remaining runner is not established on this host. Its first invocation stopped during collection because httpx2 is absent and Starlette promoted its fallback deprecation warning to an error. The second invocation did not produce a bounded result and was stopped. Hosted CI remains an open gate. Please focus on native handle ownership, cleanup after partial setup, and whether either new process guard can pass without observing the real side effect.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b673aafbba

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

def test_windows_resume_path_has_no_dev_only_psutil_import() -> None:
import cryodaq.process_lifetime as module

assert "psutil" not in module.resume_windows_process.__code__.co_names

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Exercise the no-psutil contract without psutil installed

This structural check only inspects resume_windows_process itself, so it remains green if psutil is imported at module scope, through _load_windows_ntdll, or via importlib; the default test environment installs the locked development dependency, allowing the native launcher guard to remain green too, while a base-only Windows installation would still fail before resuming the assistant. Exercise the production start/resume path with psutil imports explicitly blocked or in a base-only environment instead of relying on co_names.

AGENTS.md reference: AGENTS.md:L380-L386

Useful? React with 👍 / 👎.

# This is before logging, configuration, sockets, renderers, or optional
# LLM imports can acquire runtime ownership. A child first scheduled after
# launcher death exits on the captured-parent mismatch.
bind_child_lifetime_from_environment()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Bind Linux lifetime before importing the assistant stack

On Linux this call runs only after Python has imported yaml, report_coordinator, report_process, and their transitive modules, so if the launcher dies while one of those imports is stalled, the child has not installed PDEATHSIG and can survive at the stalled pre-main() instruction indefinitely. The lifecycle guard waits for post-bootstrap readiness before killing the launcher and therefore cannot observe this window; use a minimal pre-import entrypoint that consumes the parent grant and binds lifetime before loading assistant_bootstrap or the frozen assistant dispatcher.

AGENTS.md reference: AGENTS.md:L380-L386

Useful? React with 👍 / 👎.

assert parent.stdout is not None
parent.stdin.write(b"START\n")
parent.stdin.flush()
report = json.loads(_read_line_from_windows_pipe(parent.stdout, timeout=15.0))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Observe the real suspended-before-assignment boundary

The native Windows guard receives this report only after _start_assistant() has completed assignment and resume, so deleting CREATE_SUSPENDED still lets the assistant enter its Job quickly, reach readiness, and die when the launcher is killed; the test remains green while restoring the pre-assignment execution/descendant race. The separate fake-Popen test merely interprets the flag value and does not exercise this OS boundary, so add a native synchronization point that delays real Job assignment and proves the real assistant cannot execute before assignment completes.

AGENTS.md reference: AGENTS.md:L380-L386

Useful? React with 👍 / 👎.

Comment on lines +550 to +552
if parent is not None and not parent_settled:
parent.kill()
parent.wait(timeout=10.0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Attempt assistant cleanup when launcher cleanup fails

If the Windows harness parent’s kill() or bounded wait() raises here, execution leaves the finally block immediately and skips the subsequent exact assistant termination, assistant-handle close, and stream cleanup. Under process-handle failure or timeout, this native guard can therefore leak both the launcher and the production assistant it started; collect the parent cleanup error while still attempting every assistant and handle settlement step, then propagate the preserved failure afterward.

AGENTS.md reference: AGENTS.md:L291-L298

Useful? React with 👍 / 👎.

Comment thread src/cryodaq/process_lifetime.py Outdated
Comment on lines +150 to +151
self._kernel32.CloseHandle(self._handle)
self._handle = None

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Retain the Job handle when CloseHandle fails

When CloseHandle returns false, this method still discards _handle and reports success. If the assistant root or report renderer has exited while a Job-owned descendant remains, that failed close is the operation that was supposed to kill the descendant; losing the only Job handle lets shutdown complete and a replacement start while the old tree remains live. Check the native return value, preserve the exact handle on failure, and raise so the existing settlement path can retry.

AGENTS.md reference: AGENTS.md:L470-L471

Useful? React with 👍 / 👎.

selector.register(stream, selectors.EVENT_READ)
if not selector.select(timeout):
raise AssertionError("launcher harness did not publish its process identities")
line = stream.readline()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Bound reads after partial startup frames

The selector bounds only the arrival of the first byte; once a live harness writes any partial JSON without a newline, BufferedReader.readline() blocks indefinitely while the writer remains open, so the lifecycle test never enters its process, pidfd, or subreaper cleanup. Read incrementally under one deadline, as the hardened USBTMC lifecycle harness does, so a partial startup frame becomes a bounded failure rather than a hung remaining partition.

AGENTS.md reference: AGENTS.md:L296-L298

Useful? React with 👍 / 👎.

Comment on lines +439 to +445
if parent is not None:
if parent.stdin is not None:
parent.stdin.close()
if parent.stdout is not None:
parent.stdout.close()
if parent.stderr is not None:
parent.stderr.close()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Restore subreaper state after stream-close failures

If closing any one of these parent streams raises, the remaining closes, adopted-child reaps, pidfd closes, and _set_subreaper(previous_subreaper) are all skipped because these operations are not part of the attempt-all error collection used immediately above and below. A broken-pipe or descriptor-close failure can consequently leave pytest as a process-global child subreaper and corrupt every later process test; capture each close failure independently and restore subreaper state before propagating the first error.

AGENTS.md reference: AGENTS.md:L291-L298

Useful? React with 👍 / 👎.

@test1card

Copy link
Copy Markdown
Owner Author

@codex review this pull request at exact head 1d1da7aab711847569c8a7decb91eec828e71cc2. The assistant now consumes its parent-lifetime grant before importing YAML, reporting, paths, logging, or other resource-owning modules. Windows Job-handle close failure retains the exact handle for bounded retry. The two affected Windows modules passed 74 tests with 4 platform skips; six focused Linux lifetime guards passed on Ubuntu 22.04. With both production corrections reverted, the two new binding/retry controls collected and failed.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

worker = ZmqCommandWorker({"cmd": "safety_status"}, parent=self)

P1 Badge Keep recurring status workers parentless

Passing parent=self makes every completed safety worker a QObject child of the long-lived launcher window, and the identical annunciation construction below does the same. The registry releases terminal workers without calling deleteLater, while the attributes retain only the newest pair, so Qt retains two historical QThreads on every three-second health tick until the window is destroyed—about 2,400 objects per hour. Restore the parentless ownership model or explicitly dispose terminal workers, and retain the deleted bounded-retention regression.

AGENTS.md reference: AGENTS.md:L540-L542

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +151 to +156
if not self._kernel32.CloseHandle(handle):
# The Job remains the only kill-on-close owner. Retain the
# exact handle so launcher shutdown can retry settlement.
import ctypes

raise OSError(ctypes.get_last_error(), "CloseHandle failed for kill-on-close Job")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve report Job ownership through close retry

When a Windows report renderer exits after leaving a descendant and this first CloseHandle attempt fails, ReportProcessRunner._run_process calls close() only once on its ordinary-exit path and then unwinds with windows_job held only in that local frame. Fresh evidence at this head is that retaining _handle here gives the launcher a retry because it stores the Job on self, but the report path still has no callable retry path, so the descendant can survive after the exception is propagated. Persist that owner or perform a bounded retry before the report frame releases it.

AGENTS.md reference: AGENTS.md:L470-L471

Useful? React with 👍 / 👎.

time.sleep(0.01)
assert assignment_pid_path.is_file(), "launcher never reached delayed Job assignment"
assistant_pid = int(assignment_pid_path.read_text(encoding="ascii"))
kernel32, assistant_handle = _open_windows_process_identity(assistant_pid)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Retain cleanup authority before opening the assistant handle

If OpenProcess fails here after the atomic PID marker is published—for example under handle exhaustion or access denial—the harness parent is still blocked before Job assignment and assistant_handle remains None. The finally block then kills the parent but skips assistant termination, leaving the unassigned suspended assistant (or a running assistant under the regression being tested) alive indefinitely; establish unconditional cleanup authority before this fallible open or release the parent into Job assignment during cleanup.

AGENTS.md reference: AGENTS.md:L291-L298

Useful? React with 👍 / 👎.

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