Skip to content

SIGTERM and SIGHUP run the SIGINT drain - #348

Merged
blooop merged 10 commits into
mainfrom
wayfinder/devlaunch-304
Aug 23, 2026
Merged

SIGTERM and SIGHUP run the SIGINT drain#348
blooop merged 10 commits into
mainfrom
wayfinder/devlaunch-304

Conversation

@blooop

@blooop blooop commented Aug 22, 2026

Copy link
Copy Markdown
Owner

Closes #304.

Only SIGINT was handled, so kill <dl> and closing the terminal window ended dl where it stood — leaving the staged plaintext GH_TOKEN file on disk and the devpod up child orphaned. That is the exact pair dl/tests/interrupt.rs exists to prevent, reached by two doors it did not cover.

The shared disposition both binaries install is now a small set of signals over one handler, and install_interrupt_handler is renamed install_signal_handlers to match.

Red → green, one slice each

  1. SIGTERM joins the drain. Red: MidUp::reached().signalled("TERM") returned Aftermath { code: None, token_left: true, up_alive: true } — died by the signal, token on disk, build still running. Green: DRAINED = [SIGINT, SIGTERM] over one handler that derives its exit code from the signal it was passed.
  2. SIGHUP joins. Red: the same three-way failure for a closed terminal window. Green: SIGHUP added to DRAINED.
  3. A signal inherited as ignored stays ignored. Red: slice 2 broke nohup — a dl started behind trap '' HUP; exec … was killed by the SIGHUP it had been told to ignore. Green: the classic POSIX read-the-inherited-disposition idiom, applied to all three signals rather than to SIGHUP alone.

The two sub-decisions the ticket delegated

SIGHUP joins. It is the same leak as SIGTERM through a door people walk through far more often, and the least watched of the three — the window any complaint would have appeared in is the one that just went away. What nearly won was leaving it out on the grounds that a closed terminal is ambiguous (nohup and setsid mean it deliberately), but that reading turned out to argue for slice 3 rather than against joining: the ambiguity is entirely carried by the inherited disposition, and once that is respected, a bare dl in a closed terminal is unambiguous and a nohup dl is untouched. Excluding SIGHUP would also have meant one signal in DRAINED needing a paragraph of exception, which is what rots.

Exit code is 128 + signo — 130 SIGINT, 143 SIGTERM, 129 SIGHUP. Not invented here: INTERRUPTED = 130 was already 128 + SIGINT written out long-hand (inherited from Python's sys.exit(130)), so this generalises what the codebase does rather than adding a convention, and it is derived in the handler from the signal the kernel passes, so no signal can be given the wrong code. Two alternatives were live. Re-raising with the default disposition so the parent sees WIFSIGNALED is the more correct Unix citizenship, but the existing doc comment rejects it explicitly and for a real reason — a caller reading a child's status sees no exit code at all, where it used to see 130 — and reversing that would be a silent behaviour change for everyone scripting dl. Keeping one code (130) for every signal was simpler by one function but throws away the only information distinguishing "someone cancelled this" from "the terminal went away", which is exactly what a CI log needs.

On PR #343 and process groups

This change touches neither process-group membership nor the interrupt handler's foreground-pgid bookkeeping. devlaunch-runner is not in the diff at all: note_foreground_child, clear_foreground_child and the killpg inside drain() are untouched, and nothing here changes which process ends up in which group. The whole diff is which signals reach the existing drain and what code the process exits with — the drain itself is called exactly as SIGINT already called it. So the SIGTTIN hangs and orphaned children under revision on #343 are independent of this, and the two should merge cleanly. Files that PR is rewriting were deliberately avoided.

Testing

Five tests at the binary boundary in dl/tests/interrupt.rs, all against the fake devpod whose up blocks with the token staged and the child live. Each asserts one Aftermath — exit code, whether the token survived, whether the build survived — so a signal that half-cleans up cannot pass. The pre-existing SIGINT test now goes through the same harness and asserts the same three facts it always did.

cargo test --workspace, cargo clippy --locked --all-targets -- -D warnings, cargo fmt --check and pixi run style all green.

Summary by Sourcery

Extend shared signal handling so SIGTERM and SIGHUP perform the same safe cleanup as SIGINT without breaking intentional signal ignores.

Bug Fixes:

  • Run the existing cleanup drain for SIGTERM and SIGHUP so interrupted runs remove staged credentials and terminate their active build process.
  • Preserve inherited SIGTERM and SIGHUP ignores, keeping nohup behavior intact while retaining the existing SIGINT handling semantics.

Enhancements:

  • Report signal-triggered exits using 128 plus the received signal number and share the signal-handler installation across dl and aid.

Documentation:

  • Document signal cleanup behavior, exit codes, inherited signal dispositions, and the remaining limitations of signal-triggered cleanup.

Tests:

  • Expand interrupt coverage to verify SIGINT, SIGTERM, SIGHUP, and inherited-ignore behavior across exit status, token cleanup, and child-process termination.

blooop added 3 commits August 22, 2026 16:10
`kill <dl>` mid-`devpod up` left the plaintext GH_TOKEN file on disk and
orphaned the `up` child — the exact pair the SIGINT handler exists to
prevent — because only SIGINT was handled. The disposition is now a set of
signals sharing one handler, and the exit code is derived from the signal
(128 + signo), which is what 130 already was.
Closing the terminal window is the same leak as a kill, and the least
watched of the three: the window any complaint would have shown up in is
the one that went away. 129 is 128 + SIGHUP, by the same rule.
Draining on SIGHUP took `nohup` away: its whole purpose is outliving the
terminal, and it says so by handing the child a SIG_IGN it expects to
survive the exec. The classic POSIX idiom restores it, applied to all
three signals rather than to SIGHUP alone, because a per-signal exception
is the thing that drifts.

README said SIGHUP was not handled at all; it now says what each signal
does clean up, what it still does not (the --autorm removal, which a
handler may not run), and what code each exits with.

@sourcery-ai sourcery-ai 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.

Sorry @blooop, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@sourcery-ai

sourcery-ai Bot commented Aug 22, 2026

Copy link
Copy Markdown

Reviewer's Guide

Extend the shared signal handling for dl/aid so SIGINT, SIGTERM, and SIGHUP all run the same async-signal-safe drain, derive exit codes as 128+signo, and respect inherited ignored dispositions, with tests and docs updated accordingly.

Sequence diagram for signal-driven dl cleanup

sequenceDiagram
    participant Signal as Signal source
    participant DL as dl or aid
    participant Handler as drain(signal)
    participant Runner as cleanup_and_exit
    participant Child as devpod up process group
    participant Files as Staged token files

    Signal->>DL: SIGINT, SIGTERM, or SIGHUP
    DL->>Handler: invoke handler with signal
    Handler->>Runner: cleanup_and_exit(signalled(signal))
    Runner->>Child: kill foreground process group
    Runner->>Files: unlink registered temporary files
    Runner-->>DL: _exit(128 + signal number)
Loading

Flow diagram for inherited signal dispositions

flowchart TD
    A[install_signal_handlers] --> B{For SIGINT, SIGTERM, and SIGHUP}
    B --> C[Read inherited disposition]
    C --> D{Already SIG_IGN?}
    D -->|Yes| E[Leave signal ignored]
    D -->|No| F[Install drain handler]
    E --> G[Process remains protected from that signal]
    F --> H[Signal runs cleanup drain]
Loading

File-Level Changes

Change Details Files
Unify and extend signal handling so SIGINT, SIGTERM, and SIGHUP all trigger the same cleanup drain with exit code 128+signal.
  • Replace single SIGINT-only handler with a shared handler for SIGINT, SIGTERM, and SIGHUP via a DRAINED array.
  • Introduce signalled(signal) helper to compute exit codes as 128+signal and redefine INTERRUPTED in terms of it.
  • Change install_interrupt_handler to install_signal_handlers, installing handlers for all drained signals before threads start.
  • Ensure signals inherited as SIG_IGN remain ignored by reading prior disposition before installing the drain handler.
rust/dl/src/lib.rs
rust/dl/src/main.rs
rust/aid/src/main.rs
rust/aid/src/interactive.rs
Strengthen interrupt behavior tests to cover SIGTERM, SIGHUP, and inherited-ignored SIGHUP, and factor common test harness.
  • Introduce Aftermath struct and MidUp harness to model a mid-build dl instance and its post-signal state.
  • Add tests asserting SIGTERM and SIGHUP run the drain, remove the token, kill devpod up, and exit with correct codes.
  • Add test asserting SIGHUP inherited as ignored does not end dl, but SIGTERM still drains.
  • Refactor existing SIGINT test to use the shared harness and Aftermath expectations.
rust/dl/tests/interrupt.rs
rust/aid/tests/interrupt.rs
Update documentation and changelog to describe new signal behavior and exit codes, and align wording with shared signal handlers.
  • Document that kill and closed terminal now run the same cleanup as Ctrl-C, including unlinking GH_TOKEN and killing devpod up.
  • Document exit codes as 128+signal (130 SIGINT, 143 SIGTERM, 129 SIGHUP) and that ignored signals (e.g. nohup) remain ignored.
  • Update README and rustdoc comments to refer to install_signal_handlers and describe the shared signal dispositions.
  • Add changelog entry under Unreleased explaining the fixed behavior and remaining autorm constraints.
CHANGELOG.md
README.md
rust/dl/src/lib.rs
rust/dl/src/main.rs
rust/aid/src/interactive.rs

Assessment against linked issues

Issue Objective Addressed Explanation
#304 Install a SIGTERM handler that runs the existing SIGINT cleanup drain, removing staged plaintext credentials, terminating the devpod child, and exiting with a defined status.
#304 Decide whether SIGHUP should use the same cleanup behavior and preserve intentionally ignored inherited signal dispositions.
#304 Define and test the exit-code convention and signal cleanup behavior at the binary boundary, including SIGINT, SIGTERM, and SIGHUP.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@codecov

codecov Bot commented Aug 22, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 82.14286% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.21%. Comparing base (e4d701d) to head (edd59e3).

Files with missing lines Patch % Lines
rust/dl/src/lib.rs 80.76% 5 Missing ⚠️
Additional details and impacted files
Flag Coverage Δ
python 42.98% <ø> (ø)
rust 95.57% <82.14%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Components Coverage Δ
shipped code (rust) 95.57% <82.14%> (+<0.01%) ⬆️
harness and tooling (python) 42.98% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@blooop blooop left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

This was generated by AI during review.

Two independent axes, fresh context, fixed point a1c6fe8...0d0e59a. Verified locally: cargo test --workspace, cargo clippy --locked --all-targets -- -D warnings and cargo fmt --check are all clean (two failures appeared only under parallel contention with other agents on this host — dl/tests/read_side.rs and aid/tests/interactive.rs, both untouched by this diff, both green run alone).

The #343 claim is verified, not accepted. #343 touches rust/devlaunch-runner/src/lib.rs and rust/devlaunch-runner/src/tests.rs and nothing else; this diff touches neither, and note_foreground_child, clear_foreground_child, own_group and the killpg in drain() are byte-identical to main. No process-group membership moves here. Independently confirmed that note_foreground_child is called only on the own_group arm (devlaunch-runner/src/lib.rs:470-476), so the new SIGTERM/SIGHUP paths cannot killpg dl's own foreground group during an interactive devpod ssh.

Signal-safety probes (run against a binary linking the real install_signal_handlers + cleanup_and_exit). All pass:

  • The drain is genuinely async-signal-safe: killpg/unlink/rmdir/_exit only, over AtomicPtr/AtomicI32 slots, no allocation and no lock — so it cannot deadlock against a lock the main thread holds (devlaunch-runner/src/interrupt.rs:160-190).
  • Exit codes are observed as exit codes, not signals: waitpid reports WIFEXITED with 130 / 143 / 129 for INT / TERM / HUP, so a shell's $? is those values. The _exit really does beat the default disposition, as documented.
  • Re-entrancy is safe. Read back from the installed disposition: sa_mask blocks only the delivered signal and SA_NODEFER is clear, so a second DRAINED signal can nest inside the handler. drain() is idempotent under it (killpg again, unlinkENOENT, atomics only); the only observable effect is that the inner _exit wins, so a racing pair may report the second signal's code. 4/4 triple-signal runs exited 143 with the token gone and the child dead — no hang, no double-free, no half-clean state.
  • Inherited SIG_IGN really does survive for all three signals, and a nohup-style run stays reachable by whatever was not disarmed.

Standards

Judged against CLAUDE.md, docs/rust-rewrite-plan.md, the CHANGELOG's Keep-a-Changelog header plus publish.yml's release-notes extraction, and ci.yml's public-api job. The doc prose in rust/dl/src/lib.rs:80-147 is up to this repo's culture — it names SIGQUIT as the alternative that lost and argues the all-three rule over a SIGHUP-only exception. Findings are all in the surrounding docs and surface.

  • non-blocking — the live divergence table was not updated. docs/rust-rewrite-plan.md:12-16 says the divergence table "is the exception and is still live… the only written record of every deliberate behavioural difference from the Python build… Keep it." This PR creates exactly such a difference (Python died by SIGTERM/SIGHUP with no cleanup; dl now drains and exits 143/129), and row 27 at docs/rust-rewrite-plan.md:190 still reads "On SIGINT dl prints no timing summary… the handler is _exit(130)". Row 30 shows post-port rows do get added.
  • non-blocking — "stays ignored" hides a real change to shipped SIGINT behaviour. CHANGELOG.md:20-22 and README.md:248-250 present the inherited-SIG_IGN rule as preserving behaviour, illustrated only with nohup/SIGHUP. For SIGINT it is a change, not a preservation (see Spec 1). publish.yml:72-83 turns this section verbatim into the GitHub release notes, so this is the text users get. (The install_interrupt_handlerinstall_signal_handlers rename correctly needs no Changed entry — dl's lib is unpublished and the CHANGELOG is user-facing.)
  • non-blocking — two premises in devlaunch-runner were widened without being revisited. devlaunch-runner/src/interrupt.rs:3 still asserts "dl's SIGINT disposition is _exit(130)", and the stale-pgid SAFETY argument at :171-174 closes its window partly "by SIGINT delivery being to this process" — which no longer characterises delivery, since a group-wide or cgroup-wide SIGTERM reaches the child too. The conclusion still holds; the stated reason no longer does.
  • non-blocking — dead public surface. INTERRUPTED (rust/dl/src/lib.rs:100) now has zero call sites workspace-wide — the only remaining mention is the doc comment at :87. signalled (:93) is pub with no caller outside the crate; the tests assert literal 130/143/129. Given ci.yml:380-388's stated dislike of an accidental pub, both want pub(crate) or deletion.

Refuted rather than reported: [libc::c_int; 3] (the length is compile-checked); the unguarded dl lib surface (ci.yml:379-395 scopes the snapshot to devlaunch-core deliberately); Ending::code's -signal at rust/dl/src/commands.rs:75 (documented Python parity for a child's status); Aftermath/MidUp (the World + harness-struct idiom every rust/dl/tests/*.rs already uses).

Axis: pass. No blocking findings; four documentation/surface items worth fixing before merge.

Spec

Anchors: #304"Install a SIGTERM handler running the same drain"; "Settle inside this ticket: whether SIGHUP joins (README:233 — closing the terminal today skips both --autorm and the cleanup), and the exit-code convention"; "Test at the binary boundary in the style of dl/tests/interrupt.rs".

  1. blocking — the SIGINT half of the inherited-ignore rule is an undeclared change to the pre-existing path. rust/dl/src/lib.rs:161-170 applies the SIG_IGN probe to all of DRAINED. Measured on this host: a background job of a non-interactive shell (cmd & in a script or CI step) starts with SigIgn containing SIGINT|SIGQUIT — POSIX job control, nobody typed trap. Reproduced directly against the real disposition: a run launched that way survived SIGINT with the staged token still on disk, where on a1c6fe8 it drained and exited 130. In that case the ticket's own leak gets longer-lived rather than shorter: after a Ctrl-C kills the script, dl is an orphan still holding the launch lock, still running devpod up, with the plaintext GH_TOKEN still staged. Declared nowhere. It also sits against the builder's own stated bar for rejecting the re-raise alternative — "reversing that would be a silent behaviour change for everyone scripting dl" — which applies with equal force here. The uniformity argument does not carry across the two signals: inherited SIG_IGN on SIGHUP means "I ran nohup"; on SIGINT it usually means "my shell backgrounded me", which is no statement about interruption at all. Either narrow the rule to SIGHUP, or keep it uniform and say so in CHANGELOG/README as a behaviour change.
  2. blocking — the changed half is the untested half. rust/dl/tests/interrupt.rs:299-311: reached_with_signal_ignored is parametrised by signal and called only with "HUP". The spec's "Test at the binary boundary in the style of dl/tests/interrupt.rs" is otherwise well met — all three signals covered at :278, :286, :295, correct boundary, correct style, and Aftermath makes a half-clean exit unpassable. But the one rule that alters existing behaviour has no test; a one-line ("INT") case would have surfaced finding 1.
  3. non-blocking — README overclaims. README.md:243-245: "no signal leaves a credential on disk or a build running behind you" is false as written — rust/dl/src/lib.rs:118-120 says "SIGQUIT is deliberately absent", and a probe confirms Ctrl-\ leaves the staged token on disk. Scope the sentence to "none of these three".
  4. non-blocking — wrong mechanism named. README.md:249 and rust/dl/src/lib.rs:135 credit the inherited disposition for "a job disowned by a script"; disown sets no SIG_IGN (measured), it works by the shell not sending SIGHUP, and setsid likewise sets none — it is covered by session detachment. So the sub-decision's "the ambiguity is entirely carried by the inherited disposition" is false for two of the three cases it names. The outcome is still right for both; the reasoning and the doc are what need correcting.
  5. non-blocking — the exit-code justification overreaches. devlaunch-core/src/flows/launch.rs:1396-1398 reads "A signal is Python's negative returncode, kept here so the binary renders the same exit code rather than inventing 128+n" — the codebase had explicitly declined 128+n. It is a different question (a child's status vs dl's own death) so the decision stands, but "generalises what the codebase does rather than adding a convention" is not accurate, and the repo now holds two opposite conventions with no cross-reference between them.
  6. non-blocking — stale neighbours: rust/devlaunch-runner/src/interrupt.rs:3 and rust/dl/tests/interrupt.rs:1 still describe a SIGINT-only world.

Checked and clean: the README:233 edit satisfies both halves of the cited line (the cleanup now runs; --autorm is still correctly listed under "These do not fire", since a handler may not run it); scope is otherwise tight (devlaunch-runner untouched, rename fully propagated); the delegation the ticket made was honoured — both sub-decisions are breadcrumbed with what nearly won, as #304, not the map, directed.

Axis: fail. Findings 1 and 2 need resolving before merge.

Verdict

Request changes (posted as a comment: GitHub refuses --request-changes on a PR authored by the same account).

Blocking:

  1. Spec 1 — the inherited-SIG_IGN rule applied to SIGINT silently changes the pre-existing interrupt path: a dl backgrounded from a non-interactive shell no longer drains on SIGINT, and in that case the staged credential and the devpod up child outlive the run that was cancelled. Narrow the rule to SIGHUP, or keep it uniform and declare it in CHANGELOG and README.
  2. Spec 2 — add the ("INT") case to a_signal_already_ignored_when_dl_started_stays_ignored, so whichever way 1 is settled is pinned at the binary boundary.

Non-blocking but worth landing in the same pass: docs/rust-rewrite-plan.md row 27, the two stale premises in devlaunch-runner/src/interrupt.rs, the README.md:243-245 "no signal" overclaim, the disown/setsid mechanism wording, and the now-unused INTERRUPTED / over-pub signalled.

The core of the change is sound: SIGTERM and SIGHUP genuinely close the leak the ticket names, the handler is async-signal-safe and re-entrant-safe, the exit codes are real exit codes, and the process-group claim about #343 holds.

blooop added 2 commits August 22, 2026 16:53
Review found the SIGINT half was a regression, not a preservation. A
non-interactive shell backgrounding a job hands its child an ignored
SIGINT and SIGQUIT under POSIX job control — measured, SigIgn 0x6 — so
every `dl … &` in a script or a CI step stopped draining on Ctrl-C. The
staged plaintext GH_TOKEN and the `devpod up` child then outlive a
cancelled run, which is the leak this ticket exists to close, reopened
through the door that was already shut.

The rule now says which signals it speaks for, as a table pairing each
signal with what an inherited ignore means coming from it: a statement
for SIGTERM and SIGHUP (nohup disarms SIGHUP precisely so the run
outlives the terminal), an accident of job control for SIGINT. Ctrl-C is
byte-for-byte the behaviour it had before this branch.

The changed half was also the untested half. The test is now
parametrised over all three signals from one table, so a signal cannot
be left uncovered by omission — which is how this got through. Proved
red against the previous commit's lib.rs (SIGINT ignored, run continued
to its natural end, exit 1 not 130) and green against this one.

One row expects the build to outlive the finishing Ctrl-C, and that is
inherent rather than a defect: `trap '' TERM` reaches everything dl
spawns, and the drain fells the build with killpg(SIGTERM), so disarming
SIGTERM disarms the drain's own reach into the child. Recorded where the
row is, since it reads as an inconsistency otherwise.

Also drops now-dead public surface: INTERRUPTED had no call sites left,
and `signalled` had no caller outside the crate.
Four review items, all documentation or premise rather than behaviour.

The divergence table is declared live and cited by row number, and this
change created exactly the kind of difference it records. Row 27 still
described a SIGINT-only `_exit(130)`; it now covers all three signals,
the 128+signo codes, and the inherited-ignore asymmetry — including the
cross-reference the exit-code decision was missing, since Session::
exit_status keeps Python's negative returncode for a *child* killed by a
signal and explicitly declines 128+n. Two precedents, each followed in
its own place, neither generalising to the other.

The CHANGELOG and README framed the inherited-ignore rule as preserving
behaviour, illustrated only with nohup. After the scoping that is true of
SIGTERM and SIGHUP and needs saying out loud for SIGINT, which is where a
reader would otherwise assume symmetry. This text becomes the release
notes verbatim, so it says which two signals can be switched off, which
one cannot, and why that is deliberate.

README also claimed no signal leaves a credential behind. SIGQUIT does,
by the same design decision that keeps it out of the drain, so the claim
is now scoped to the three and names the exception.

Two premises in devlaunch-runner were widened without being revisited.
The module still opened on a SIGINT-only disposition. The stale-pgid
SAFETY argument rested on delivery being to this process, which no longer
characterises delivery — a group-wide SIGTERM reaches the child too — and
on clear_foreground_child running before the reap, which it does not: it
runs just after the wait returns. Restated on the reap window itself,
which is what actually bounds it and holds whatever woke the handler.

Finally, disown and setsid were credited to the inherited disposition.
Neither sets SIG_IGN (measured); they work by the shell not sending
SIGHUP and by leaving the session. Corrected in both places.
@blooop

blooop commented Aug 22, 2026

Copy link
Copy Markdown
Owner Author

Addressed in 24888d2 and 4b6539c (additional commits, no force-push). Every finding reproduced before it was fixed.

Blocking

Spec 1 — the SIGINT half of the inherited-ignore rule. Reproduced independently before touching anything: sh -c '/probe & wait' gives the child SigIgn: 0000000000000006 — SIGINT|SIGQUIT — where the same probe run in the foreground gives 0. So the finding is exactly right, and my slice 3 stopped the drain for every dl … & in a script or a CI step. That is the ticket's own leak reopened through the door that was already shut, and it made the pre-existing path worse rather than better.

Scoped as decided on #304: the inherited-ignore rule now applies to SIGTERM and SIGHUP only, and Ctrl-C behaves byte-for-byte as it did before this branch. Rather than a uniform loop with an if, the signal set is now a table pairing each signal with what an inherited ignore means coming from it — InheritedIgnore::Wins for the two this PR adds, Loses for SIGINT — so the asymmetry is stated where the set is declared and cannot be read as an oversight. The argument for each row is in the doc comment.

Spec 2 — the changed half was the untested half. Fixed at the root rather than by adding one case: INHERITED_IGNORE is now a table the test iterates, so a signal cannot be left uncovered by omission, which is precisely how this got through.

Proved both ways:

  • Red: with the new test against the previous commit's lib.rsAftermath { code: Some(1), token_left: false, up_alive: false } against an expected Some(130). The signature is worth noting: the SIGINT was ignored outright, so the run was never cancelled at all and ran to its natural end 30s later. In the real case (the script that owned it is gone) that is the orphan holding the launch lock and the staged token, exactly as described.
  • Green: with the fix, all five tests pass.

One thing the parametrised test surfaced that I had not predicted, and it is not a defect. With SIGTERM disarmed, the finishing Ctrl-C drains and unlinks the token but the build survives. trap '' TERM is inherited by everything dl spawns, and the drain fells the build with killpg(…, SIGTERM) — so disarming SIGTERM for the run also disarms the drain's own reach into the child. That is inherent to tearing a group down with the signal the caller switched off, true of any program that does it that way, and not something this PR can decide. I have pinned it as the expected outcome for that row rather than weakening the assertion, with the reason recorded next to it, because it otherwise reads as an inconsistency between two rows of the same table. nohup is unaffected: it disarms SIGHUP, which the drain does not use.

Non-blocking

  • docs/rust-rewrite-plan.md row 27. Amended rather than superseded by a new row, since the subject is the same and the doc says rows are cited by number. Now covers all three signals, the 128+signo codes, and the inherited-ignore asymmetry. It also carries the cross-reference Spec 5 correctly said was missing: Session::exit_status explicitly declines 128+n for a child killed by a signal, so the repo does hold two opposite conventions — each following its own Python precedent, neither generalising. Spec 5 is right that "generalises what the codebase does" overstated it; the claim is now the narrower and true one (Python's sys.exit(130) was 128+SIGINT long-hand), stated in the signalled doc and in row 27.
  • CHANGELOG / README framing. Both now say which two signals can be switched off, which one cannot, and that Ctrl-C is unchanged from previous releases — with the job-control reason, since a reader would otherwise assume symmetry. Written knowing publish.yml ships it verbatim.
  • README overclaim (Spec 3). Scoped to "none of these three" and names SIGQUIT as the exception, tied back to the reason it is deliberately not drained.
  • devlaunch-runner/src/interrupt.rs premises. Both restated. The module no longer opens on a SIGINT-only disposition. The stale-pgid SAFETY argument rested on two claims that no longer hold — delivery being to this process, and clear_foreground_child running before the reap. Checking the second: it runs after wait returns (devlaunch-runner/src/lib.rs:473-476), so that half was already inaccurate before this PR. Rewritten on the reap window itself — an empty group gives ESRCH, and naming a live group would need the pid space to wrap within a few instructions — which is what actually bounds it and holds whatever woke the handler. lib.rs and tests.rs untouched, so Fix the capture and session timeout hang #343 stays clear.
  • Dead public surface. INTERRUPTED deleted (zero call sites) and signalled is no longer pub.
  • disown/setsid mechanism (Spec 4). Correct — neither sets SIG_IGN. Fixed in the README and in lib.rs, and the doc now says what does cover them (the shell not sending SIGHUP; leaving the session) so the wrong credit is not re-derived.
  • Stale module docs (Spec 6). Both updated.

Gates green locally: cargo test --workspace, cargo clippy --locked --all-targets -- -D warnings, cargo fmt --check, pixi run style.

Thanks for the probe binary — the SigIgn 0x6 measurement is what made this quick to confirm rather than argue about.

@blooop blooop left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

This was generated by AI during review.

Follow-up: blocking findings resolved

Re-reviewed at 4b6539c (fast-forward from 0d0e59a, nothing rebased). Scoped to my own two blocking findings, the non-blocking list, and what the two new commits introduce. cargo test -p dl --test interrupt, -p aid --test interrupt, clippy --locked --all-targets -- -D warnings and fmt --check all clean here.

Spec 1 — resolved, and verified byte-for-byte rather than by inspection. DRAINED now pairs each signal with an InheritedIgnore (rust/dl/src/lib.rs:153-157), and SIGINT's Loses arm is a bare libc::signal(SIGINT, drain) with no SIG_IGN probe (:206-209) — the same single call pre-branch main made, and signalled(SIGINT) is 130, which is what INTERRUPTED was. Measured against the real disposition on this head: a run started with SIGINT already SIG_IGN drains and exits 130 with the token gone, where at 0d0e59a it survived with the token on disk. Ctrl-C is back to exactly what it was. Declared in three places now — CHANGELOG.md:26-31 ("unchanged from previous releases"), README.md:252-260, and divergence-table row 27.

Spec 2 — resolved. INHERITED_IGNORE (rust/dl/tests/interrupt.rs:318-329) is iterated by one test covering all three signals, and the ("INT", StillDrains(130)) row is the assertion whose absence let this through.

Non-blocking list — all six landed. Row 27 rewritten with the SIGINT exception and the two-conventions note; devlaunch-runner/src/interrupt.rs:3-9 and dl/tests/interrupt.rs:1-16 no longer describe a SIGINT-only world; README.md:243-247 scopes the claim to "none of these three" and names Ctrl-\ explicitly; the disown/setsid mis-credit is gone from the README and corrected at rust/dl/src/lib.rs:182-185; signalled's doc and row 27 both record that Session::exit_status declines 128+n and why neither generalises; INTERRUPTED is deleted and signalled is no longer pub.

The correction to my Standards item — you are right and I understated it. Confirmed at rust/devlaunch-runner/src/lib.rs:473-476: note_foreground_childwait(...)clear_foreground_child, so the clear runs after the reap, and the pre-branch claim that the window was "closed by clear_foreground_child running before the reap is observable" was already false independently of which signal delivered. The rewritten argument at interrupt.rs:173-185 — ESRCH on an empty group, and a live group needing full pid-space wraparound within a few instructions — is the correct bound, and stating it on the reap rather than the sender is what makes it hold for all three signals. Accepted; the 128+n narrowing likewise.

The trap '' TERM row — I agree it is not a defect, and it is not even new. Three things settle it:

  1. Pre-existing, provably. With comments stripped, rust/devlaunch-runner/src/interrupt.rs is identical to pre-branch mainkillpg(pgid, SIGTERM) and everything around it. So a trap '' TERM; exec dl plus a Ctrl-C left the build alive on a1c6fe8 too. The new table surfaced a pre-existing property; it did not introduce one.
  2. nohup is genuinely unaffected — measured, not reasoned. nohup leaves SigIgn: 0000000000000001: SIGHUP alone, no SIGTERM, so the drain's killpg(…, SIGTERM) still fells the build. Same for the case that started all this — a non-interactive shell's background job is SigIgn: 0x6 (INT|QUIT), also no SIGTERM. Demonstrated the mechanism directly, too: a parent that disarms SIGTERM and then killpgs its own child group with SIGTERM leaves the child alive; with SIGHUP disarmed instead, the child dies. Exactly the two rows as pinned.
  3. So the caveat bites only a caller who explicitly disarmed SIGTERM for the whole subtree — someone who has said, in as many words, that no SIGTERM should end anything in here. Escalating to SIGKILL to defeat that would override an explicit instruction, deny devpod up its own teardown, and mean editing the one file this PR deliberately kept clear of #343.

Pinning it as that row's expected outcome with the reason beside it is the right resolution, and better than weakening the assertion. If the drain's kill signal is ever worth revisiting it is its own ticket, after #343 lands.

One non-blocking item, newly introduced. The table is not quite the single source: DRAINED (rust/dl/src/lib.rs:153) is private, and INHERITED_IGNORE (rust/dl/tests/interrupt.rs:318) is a second, independent list in a separate crate. Nothing machine-checks that they agree, so a fourth signal added to DRAINED would still be untested by omission — the precise failure mode the comment at :316-317 claims to have closed ("so no signal is left untested by omission"), and that row 27 calls "Pinned by the INHERITED_IGNORE table". True for the three that exist; not true structurally. A unit test inside rust/dl/src/lib.rs — which can see the private const — asserting DRAINED's exact contents and pointing at the integration table would make the claim real for about four lines. Worth doing, not worth blocking on.

Verdict

Approve (posted as a comment: GitHub refuses --approve on a PR authored by the same account). Both blocking findings are resolved and independently verified; the whole non-blocking list landed; the one new item above is a four-line follow-up, not a gate. Merging stays with the human.

The two lists were independent: the handled set is private to dl's
library and the boundary table lives in the integration crate, which is
a separate crate and cannot see it. Confirmed by compiling the reference
rather than reasoning about it — `constant DRAINED is private`, E0603.
So a fourth signal added to the set would still be untested by omission,
which is exactly what the table's own comment and divergence row 27
claimed to have closed.

Closed as far as it can be closed without widening surface: a unit test
beside the set, in the crate that can see it, pinning its exact
contents. A signal added there now fails a test whose message names the
file whose table has to grow with it. Proved by adding a fourth signal
to the set alone — the guard fired with that message — then reverting.

The alternative was making the set `pub` so the integration test could
iterate it, which would be a genuine cross-check. It loses because this
review has just finished trimming dead `pub` off this crate, and paying
for a test with permanent API surface on the library `aid` links is the
wrong trade for a three-element list.

So the claim is narrowed to what is now true — the set cannot grow in
silence — rather than left at "cannot disagree", and both the comment
and row 27 say which one they mean. What still slips through is stated
in the guard: an author who edits the guard and the set together and
still leaves the boundary table alone, which is a deliberate act rather
than an oversight, and the class of thing review catches and a tripwire
cannot.
@blooop

blooop commented Aug 22, 2026

Copy link
Copy Markdown
Owner Author

Addressed in 6218245 (additional commit, no force-push). Both halves — the gap closed as far as it can be, and the claim narrowed to match.

The diagnosis, confirmed by compiling it rather than reasoning about it

I checked the visibility claim directly before acting, because the fix depends on it. Adding let _ = dl::DRAINED; to dl/tests/interrupt.rs gives:

error[E0603]: constant `DRAINED` is private
   |     let _ = dl::DRAINED;
   |                 ^^^^^^^ private constant

So the report is exactly right: an integration test is its own crate, the handled-signal set is private to the library, and no assertion in dl/tests/interrupt.rs can machine-check the two lists against each other. A same-crate unit test, though, sees the set fine.

What I chose

A guard beside the set, in the crate that can see itdrained_signals in rust/dl/src/lib.rs — pinning the set's exact contents, signal by signal, with the Wins/Loses decision included so a row that flips is caught too. A signal added to the set now fails a test whose message says to add the matching row to INHERITED_IGNORE first.

Proved it fires, not just that it passes. Added (libc::SIGUSR1, InheritedIgnore::Wins) to the set and nothing else:

assertion `left == right` failed: the handled signals changed. Add or remove the
matching row in `INHERITED_IGNORE` in `dl/tests/interrupt.rs`, which proves each
one's behaviour at the binary boundary, and only then update this expectation.
  left: [(2, false), (15, true), (1, true), (10, true)]
 right: [(2, false), (15, true), (1, true)]

Then reverted; cargo test --workspace is green (the dl lib suite goes 93 → 94).

The alternative I did not take was pub-ing the set so the integration test could iterate it and derive its expectations. That would be a real cross-check rather than a tripwire — but this review has just finished having me delete dead pub from this crate, and buying a test with permanent API surface on the library aid links is the wrong trade for a three-element list. Recording it here so the next person weighing it has the reason rather than re-deriving it.

And the claim narrowed, since the guard does not make the original one true

You were right that a vaguer claim would not be an improvement, so both places now say which guarantee they mean and what escapes it.

  • The table's own comment says it cannot see the list it mirrors, that the far-side guard is what stands in, and therefore that the guarantee is "the set cannot grow in silence", not "the two lists cannot disagree". It also names the direction still uncovered: a row deleted from the boundary table while the set stays put fails nothing.
  • The guard's module doc states the residual plainly — an author who edits the guard and the set together and still leaves the boundary table alone. That is a deliberate act rather than an oversight, which is the class of mistake a tripwire cannot catch and review can, and it is precisely what keeps this at four lines instead of a pub.
  • Divergence row 27 no longer says only "Pinned by the INHERITED_IGNORE table". It names both, and carries the same distinction, since the row is the thing people cite.

Gates green locally: cargo test --workspace, cargo clippy --locked --all-targets -- -D warnings, cargo fmt --check, pixi run style.

Good catch — this was the same defect class the PR had just been sent back for, one level up, and shipping it in the PR whose review found it would have been the worse outcome.

main released 0.10.0 while this branch was in review, which left the PR
unmergeable and — because GitHub cannot compute a merge commit for a
conflicting PR — stopped CI from running at all on the last push.

One conflict, in CHANGELOG.md: both sides added a Fixed entry under
[Unreleased] and the release moved the section underneath them. Both
entries kept, nothing reworded.

Everything else merged clean, including all four files this branch
touches.
@blooop

blooop commented Aug 22, 2026

Copy link
Copy Markdown
Owner Author

Also merged origin/main into the branch (3771191) — not for the review item, but because it had to happen before CI could speak.

The last push ran no CI at all, and that was not a fluke. main released 0.10.0 while this was in review, which left the PR CONFLICTING; GitHub cannot compute a merge commit for a conflicting PR, so the pull_request trigger produced no run — not a pending one, not a failed one, none. Only Auto-publish (which is a push trigger) fired, which is why the checks list looked sparse rather than red. Worth flagging because a branch in that state looks quietly fine.

One conflict, in CHANGELOG.md: both sides added a ### Fixed entry under [Unreleased] and the release moved the section out from under them. Both entries kept, neither reworded. Everything else merged clean, including all four files this branch touches.

Re-ran the gates on the merged tree: cargo test --workspace, cargo clippy --locked --all-targets -- -D warnings, cargo fmt --check, pixi run style — all green, and the PR is MERGEABLE again with CI now actually running.

One note on the suite, since you hit the same thing: aid/tests/interactive.rs::a_pasted_multi_line_prompt_arrives_whole_rather_than_leaking failed on one contended full-workspace run and passed alone and on the re-run. Same pty/timing flake under host contention you recorded, and confirmed not mine — git diff --name-only origin/main...HEAD shows this branch does not touch that file at all (the only aid changes are the renamed call site and two comments).

blooop added 3 commits August 23, 2026 23:55
Six releases went out on main while this branch was open, and the `###
Fixed` heading this entry was written under became 0.11.0's. Git matched
it textually and merged without a conflict, which recorded an unreleased
fix as having shipped two releases ago. It belongs under [Unreleased].
…as one reach limit

Three corrections to the prose this branch adds.

`--autorm` was renamed to `--rm` in 0.9.0 and now only prints the refusal
that says so, so "the `--autorm` removal" names a flag that removes
nothing. The README section this text points at already says `--rm`.

`nohup` sets SIG_IGN for SIGHUP alone -- it does not touch SIGTERM -- so
it cannot be cited as what disarms the pair. The code comment had this
right and the README generalised it.

And the claim that none of the three leaves a build running behind you
has one exception, which this branch's own INHERITED_IGNORE table already
records: the drain fells the child with killpg(..., SIGTERM), so a run
whose SIGTERM was disarmed before it started disarms that reach too.
@blooop
blooop merged commit fb8b340 into main Aug 23, 2026
14 checks passed
@blooop
blooop deleted the wayfinder/devlaunch-304 branch August 23, 2026 23:08
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.

SIGTERM runs the SIGINT drain

1 participant