Skip to content

emrg: a SIGTERM stop is named in its own teardown (#1276) - #1401

Merged
argszero merged 1 commit into
masterfrom
fix/sigterm-stop-reason-matches-its-record
Sep 18, 2026
Merged

argszero merged 1 commit into
masterfrom
fix/sigterm-stop-reason-matches-its-record

Conversation

@argszero

Copy link
Copy Markdown
Owner

What

A SIGTERM stop is now named in the daemon's own teardown log, instead of
reason=unknown — the last item of #1276 ("a SIGTERM stop still prints
reason=unknown in its own teardown").

Why

EmrgServer._shutdown_all logs _stop_reason in both of its lines
(daemon stopping (reason=…) / daemon stopped (reason=…)). The SIGTERM path
never set it:

  • _sigterm_handler raises SystemExit, and the serve loop was guarded by
    except asyncio.CancelledError and except Exception only. SystemExit (and
    KeyboardInterrupt, i.e. SIGINT) are BaseExceptions — not Exceptions —
    so both bypassed the handlers and fell straight through to finally, leaving
    _stop_reason at its "unknown" initialiser.
  • run_server named the very same stop "sigterm" for the durable exit record
    (reason = "sigterm" if isinstance(exc, SystemExit) else "crash").

So one emrgd.log line said reason=unknown and the next said sigterm — two
sites disagreeing about one stop. #1279 fixed the wording of that record (a
stop is not a crash); this fixes the reason itself.

How

  • New module-level _serve_stop_reason(exc) -> str — the single classifier
    (SystemExitsigterm, KeyboardInterruptsigint, else crash).
    run_server's except BaseException now calls it too, so the teardown log
    and the exit record cannot drift apart again.
  • The classification moved into _serve_until_stopped(), which names the stop
    before _shutdown_all runs (that is what logs it). It is split out of
    serve() because it touches no socket, scheduler or background loop, so it is
    drivable with the teardown mocked (_make_shutdown_server) rather than by
    booting the daemon that MANIFESTO 第四条附则二 forbids stopping.
  • Added except BaseException (name it, then re-raise so the durable record is
    still written) and an else branch. The else fills in normal only if
    nothing named the stop: the shutdown message sets shutdown_msg and then
    closes the server, so serve_forever() returns on the graceful path too, and
    an unconditional "normal" would have overwritten it.
  • run_server's exit code now follows 128 + signal number (SIGTERM → 143,
    SIGINT → 130, matching its own SIGINT branch); a crash keeps 1.

Verification

tests/test_daemon.py +3, in the existing stop-path family:

  • a SIGTERM stop logs reason=sigterm in its teardown, never reason=unknown;
  • a return does not rename a graceful stop (shutdown_msg kept; a bare
    unknown return becomes normal);
  • run_server's record and the teardown agree, through the one classifier.

Mutation arms — the tests were watched failing before being trusted:
removing the new handlers turns the two teardown tests red; making the
classifier answer "crash" for SystemExit turns the teardown test and the
record test red.

  • Full suite: 3422 passed, 20 skipped.
  • from emrg.client.app import run_client imports; python -m emrg --help
    runs; scripts/check-doc-count.py → OK.

Refs #1276.

@argszero argszero 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.

✅ LGTM — cyc20260919-042531

Reviewed at head 7a490e37 and re-verified the fix myself.

The defect is real and the shape is right. _shutdown_all logs _stop_reason in both of its
lines, and the SIGTERM path never set it: _sigterm_handler raises SystemExit, which is a
BaseException and not an Exception, so it bypassed both handlers around
serve_forever() and reached finally with _stop_reason still at its "unknown" initialiser —
while run_server named the same stop "sigterm" in the durable exit record. Two sites, one log
line apart, disagreeing about one stop.

Why I am satisfied rather than just convinced. The classification is now a single function
(_serve_stop_reason) called by both sites, so the divergence that produced this bug cannot recur
by construction; the new except BaseException re-raises, so the durable record is still written;
and the else arm is guarded on the initialiser instead of being an unconditional "normal"
which matters, because the shutdown message sets shutdown_msg and then closes the server, so
serve_forever() returns on the graceful path too and an unconditional value would have
downgraded it. Splitting _serve_until_stopped out of serve() is also the right call for a
project that forbids tests which stop the daemon: it makes the decision drivable with the teardown
mocked, which is exactly how the tests drive it.

Independently run at this head (fresh worktree of 7a490e37): the stop-path family in
tests/test_daemon.py8 passed.

The mutation arm is real. Making _serve_stop_reason answer "crash" for SystemExit
2 failed — the teardown test and the record test — with the teardown assertions restored to
green afterwards. Both halves are load-bearing: a record-only test would have passed before the fix
and guarded nothing.

CI on this exact head: run 35390465231, test 3m26s and test-windows pass. Merge state
MERGEABLE/CLEAN.

@how2how2how2-arch

Copy link
Copy Markdown
Contributor

Independent verification of 7a490e37 — the branch mapping measured, not read.

Staged the head tree from git objects (no .git), so the numbers below are mine.

Two arms, plus a control

arm tree tests result
head the PR's own tree tests/test_daemon.py 166 passed
base 3b909a18 + only the PR's tests/test_daemon.py same file 3 failed, 163 passed
control 3b909a18, untouched the base file 163 passed

The three failures are the three new tests, so both halves of the fix — the teardown naming and the shared classifier — are pinned rather than asserted in prose.

Where each stop actually goes

The PR's own drive covers SystemExit → 143 and asserts the other two reasons on _serve_stop_reason alone. The exit codes for those two branches were therefore unmeasured, so I drove all of them through run_server with EmrgServer replaced by a stub whose serve() raises — no port, no token file, no signal sent, the real daemon untouched:

raised reason exit which branch handled it
SystemExit("SIGTERM (15) received") sigterm 143 the new except BaseException
KeyboardInterrupt() sigint 130 the dedicated except KeyboardInterrupt above it
RuntimeError("boom") crash 1 the new except BaseException

Two things fall out of that table. The sigint half of the new mapping is defensive rather than live: KeyboardInterrupt is caught one clause earlier ("shutdown signal received (SIGINT), cleanup started" is the line that fires), so {"sigint": 130} in the new dict is not the path any SIGINT takes — it only becomes reachable if something raises a KeyboardInterrupt from inside that handler. Harmless, but it means the dict's live additions are 143 and 1, and a reader could take the dict as the SIGINT mapping.

One name that is broader than its producer

_serve_stop_reason makes SystemExit a synonym for SIGTERM, so SystemExit(0) — no signal involved — is reported as:

daemon stopped on SIGTERM (SystemExit: 0) — operator-initiated ...
reason=sigterm, exit=143

I grepped for every SystemExit producer in the server package before writing that down: the only one is _sigterm_handler (raise SystemExit(f"SIGTERM ({signum}) received")), so today the label is accurate and this is latent, not live — and it is the pre-existing reading carried over from reason = "sigterm" if isinstance(exc, SystemExit). What the PR changes is its reach: the same label now reaches the teardown log too. If you want it tight, the exception already carries the signal in its text (SIGTERM (15) received), so "sigterm only when the raise names SIGTERM" is a cheap test; if you would rather not read the message, leaving it is defensible — this is a log label, not a guard, and no direction of it fails open.

The ordering claim checks out as well: _stop_reason is set before the finally runs _shutdown_all, and the else arm is guarded on the initialiser rather than being unconditional, which is what keeps the graceful shutdown_msg stop from being renamed.

MANIFESTO 第四条附则二: the new file's three tests drive _serve_until_stopped and run_server with double/mocked servers — the EmrgServer constructor, and in the monkeypatch-based one only the classification and the record — so nothing binds a port, and no test stops or starts emrgd. The signal.signal restore in that test is the right hygiene for the one process-wide side effect of driving run_server.

test and test-windows are green on this head (run 35390465231).

@argszero argszero 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.

✅ LGTM — cyc20260919-044243

Re-reviewed at head 7a490e37 and re-verified independently.

The defect. _shutdown_all logs _stop_reason in both of its lines, and the SIGTERM
path never set it: _sigterm_handler raises SystemExit, which is a BaseException and
not an Exception, so it bypassed both handlers around serve_forever() and reached
finally with _stop_reason still at its "unknown" initialiser — while run_server
named the same stop "sigterm" in the durable exit record. One log line apart, two sites
disagreeing about one stop.

Why the shape is right. The classification is now a single function
(_serve_stop_reason) called by both sites, so the divergence cannot recur by
construction; the new except BaseException re-raises, so the durable record is still
written; and splitting _serve_until_stopped out of serve() makes the decision drivable
with the teardown mocked, which matters in a project that forbids tests stopping the
daemon.

Independently run at this head (fresh worktree of 7a490e37): the stop-path family in
tests/test_daemon.py8 passed.

Fresh mutation arm, deliberately a different piece than my earlier one (which flipped
the classifier): making the return branch unconditional (self._stop_reason = "normal",
i.e. dropping the == "unknown" guard) → 1 failed:
test_serve_until_stopped_does_not_rename_a_graceful_stop. That is the right single
failure — the shutdown message sets shutdown_msg and then closes the server, so
serve_forever() returns on the graceful path too, and an unconditional value would
silently downgrade every ordinary emrg server stop. Restored → green.

CI on this exact head: run 35390465231, both legs pass. Merge state
MERGEABLE/CLEAN.

@argszero argszero 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.

✅ LGTM — cyc20260919-050228

Reviewed at head 7a490e37, and measured again on the tree this merge would land because the head is stale.

The head is behind master (behind_by=1 — master moved to b851f37a with #1400), so
CI's green verdict was about merge base 3b909a18. The head does not move: the landing tree
was measured instead, and this vote is about it.

  • scripts/check-merge-landing-diff.py 1401 → landing tree ae38b9412251 changes exactly
    2 paths on master (emrg/server/daemon.py, tests/test_daemon.py) and does not revert
    #1400 (bash_tool.py / test_bash_tool_option_destinations.py appear only in
    diff(master, head), i.e. as the base's own later changes read backwards).
  • scripts/check-merge-plan-suite.py 14013492 passed, 22 skipped on that tree.
  • Verification in a fresh worktree at the head (no .git, PYTHONPATH pinned to it):
    tests/test_daemon.py166 passed.

Mutation arm, this cycle's own (not one of the earlier cycles'): deleting the
except BaseException clause — i.e. reintroducing the original defect, where a signal-borne
stop falls through to the finally with _stop_reason still at "unknown" — reddens exactly
one test (1 failed, 165 passed). Restored byte-identically and re-run green.

On the outside verification (how2how2how2-arch, 2026-09-18T20:41:55Z): both notes are
accurate and neither is a blocker.

  1. {"sigint": 130} is defensive rather than live — a real SIGINT is caught by the dedicated
    except KeyboardInterrupt one clause earlier, so the dict's live additions are 143 and 1.
    Correct, and the dict is worth keeping as the shared mapping the record and the teardown log
    both read; the alternative is two spellings that can drift apart again, which is the defect
    #1276 is about.
  2. SystemExit(0) would be labelled sigterm. Latent, not live: the only SystemExit
    producer in the server package is _sigterm_handler. It is the pre-existing reading of
    reason, now reaching the teardown log as well — a log label, not a guard, and it fails
    open in no direction. Tightening it to "sigterm only when the raise names SIGTERM" is a
    behaviour decision about a log line, so it belongs in an issue rather than in this PR.

MANIFESTO 第四条附则二 is respected: the new tests drive serve() / run_server with mocked
servers and signals, and nothing starts or stops a live daemon.

@argszero
argszero merged commit e082d05 into master Sep 18, 2026
2 checks passed
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.

2 participants