HOMELAB-2479: fix(forgejo): exit 4 on unproven signal-path revoke; SIGINT/SIGHUP e2e tests #124
No reviewers
Labels
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Homelab/pi-extensions!124
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "plane/HOMELAB-2479-122-signal-exit-fix"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Fix-forward of #122 (merged
6ca09e38). No behaviour outside the signal pathchanges; no new capability, no new file, no new dependency.
Defect — a failed revoke on a signal never exits the documented code 4
forgejo/seat-post.tshas two exit paths that disagreed:EXIT.revoke)The signal handler ran
reportRevoke(await doRevoke())and then exitedunconditionally with
signal === "SIGTERM" ? 143 : signal === "SIGHUP" ? 129 : 130.Exit 4 is the only documented machine-readable way to detect "the OpenBao
token may still be alive" (header exit table, and
EXIT = { …, revoke: 4 });128+signal appears nowhere in that table. So a wrapper implementing the
documented contract —
[ $? -eq 4 ] && alarm— silently missed the case thatmatters most: a posting interrupted mid-flight whose revoke could not be
proven. 143 says only "terminated", which is also what a clean interruption
looks like.
Both review seats on #122 raised this and the notes were reworded into
endorsements instead of being fixed:
Recovered note text lives in
working-files/infra-stability/gpu-whisper/status.md(RECOVERED SEAT NOTES).Fix — one mapping shared by both paths, so they cannot drift again
keeps 128+signal when it is;
finallykeeps its behaviour through the same helper;signal on stderr, and additionally says when 4 replaced 128+signal. Ordering is
unchanged: remove staged dirs → revoke → report → exit (what closed #1973);
handler catches the signal, so the OS already sees a normal exit. Nothing is
silently reclassified: the signal is named on stderr either way;
exit on a failed revoke — 4 is non-zero.
Header doc updated in both places that contradicted each other: the hygiene
contract now states the non-zero exit on a signal is 4, and the exit table says
4 takes precedence over 128+signal.
Tests — SIGINT and SIGHUP driven end to end, plus the failed-revoke matrix
The existing SIGTERM child-process case (the real helper, posting phase hung, a
real signal while the staged 0600 token file exists) is parametrised into
driveSignal(signal, revokeProvenDead)and now drives all three signals:SIGHUP → 129 — each asserts the 0600 token file was really staged before the
signal (otherwise the case is vacuous), the staged dir is gone afterwards, and
POST /v1/auth/token/revoke-selfwas still sent;exit 4, with stderr carrying
NOT proven deadandinstead of 143/130/129.The in-process harness is deliberately not extended to cover signals: it
stubs
onSignal, which is the structural blind spot the #1973 blocker rodethrough.
Evidence
Against the pre-fix
seat-post.tsthe three unproven-revoke cases fail andreproduce the defect exactly:
Scope
Two files, no other change:
forgejo/seat-post.ts(the shared helper, thesignal handler's exit code and one stderr line, two header paragraphs) and
forgejo/tests/seat-post.test.ts(the parametrised signal cases).comment-as.ts,index.ts,api.ts,package.jsonare untouched.The signal handler in seat-post.ts reported the revoke verdict on stderr and then exited unconditionally with 128+signal (SIGTERM 143, SIGHUP 129, SIGINT 130), while every other exit path uses 4 when the OpenBao token is not proven dead. Exit 4 is the documented machine-readable "the token may be live" code (header exit table and EXIT.revoke); 128+signal is not in the table at all, so the two paths disagreed and a wrapper implementing the documented contract could not see the case that matters most — an interrupted posting whose revoke did not take. Fix: one mapping, exitCodeAfterRevoke(), used by BOTH the `finally` and the signal handler so they cannot drift. A signal now exits 4 when the revoke is not proven dead, and keeps 128+signal when it is. The handler still removes the staged token dir first and still names the signal on stderr (128+signal is a convention this helper reproduces, not a kernel fact — the handler catches the signal, so the OS sees a normal exit). Ordering is unchanged: remove staged dirs -> revoke -> report -> exit. Header doc updated at both places: the hygiene contract now says the non-zero exit on a signal is 4, and the exit table says 4 takes precedence over 128+signal. Tests: the SIGTERM child-process case is parametrised into driveSignal(signal, revokeProvenDead) and drives SIGTERM/SIGINT/SIGHUP end to end: - revoke proven dead (lookup-self 403): exit 143/130/129, staged 0600 token file really staged first, staged dir gone after, revoke-self attempted; - revoke NOT proven dead (lookup-self 200): exit 4 for all three signals, stderr carries "NOT proven dead" and "instead of 143/130/129". Evidence at head: `cd forgejo && npm run typecheck && npm test` -> 50 files, 1213 tests passed. Against the pre-fix seat-post.ts the three unproven-revoke cases fail with "expected 143/130/129 to be 4", i.e. they reproduce the defect and pass after the fix. Why this is the #122 fix-forward: review seats #1979 and #1974 raised exactly this ("the signal path reports a failed revoke loudly but exits 128+signal rather than 4" and "only SIGTERM is driven end-to-end"), and those two notes were reworded into endorsements instead of being fixed. Recovered note text: working-files/infra-stability/gpu-whisper/status.md. Refs: HOMELAB-2479, pi-extensions#122 (merged6ca09e38).Verdict: APPROVED
model: omniroute/deepseek-flash
head:
05fc9e4bb4session: seat-124-1
Approval of record — Homelab/pi-extensions#124
Pinned to exact head
05fc9e4bb4144c932c9add9aa73264cc0a2f7195.What I measured myself
The head was staged in my seat sandbox with my own ld-reviewer credential (token file read by path).
ls-remoteanswered05fc9e4bb4144c932c9add9aa73264cc0a2f7195forrefs/heads/plane/HOMELAB-2479-122-signal-exit-fix, and the PR API answered basemain6ca09e3827616c2fbf855f1bb82351b10895ef37, open, mergeable, not merged. The head matches the expected pin.1. Scope — exactly the two named files
git diff --name-statusbetween base and head lists two entries and nothing else:forgejo/seat-post.tsandforgejo/tests/seat-post.test.ts(+30/-5 and +84/-16; 2 files, 114 insertions, 21 deletions). No CI workflow, no oversight path, no unrelated refactor. I scanned every added line for credential-shaped literals (32+ characters of token alphabet): none, and the added content is the exit mapping, its doc comments and the tests.2. The exit mapping is genuinely one function, and it is correct
exitCodeAfterRevoke(verdict, fallback)returnsEXIT.revokeonly when a verdict exists anddeadis false.EXIT.revokeis now produced at exactly one site in the file (line 85) — every other mention is prose — so no other path can emit 4, and no path can emit 4 for a revoke that is proven dead (a dead verdict always yields the fallback). Both callers route through it:finally:code = exitCodeAfterRevoke(verdict, code), which is the same semantics as the previous inline conditional, so that contract is preserved;exitCodeAfterRevoke(verdict, signalCode)withsignalCode143 / 130 / 129 for SIGTERM / SIGINT / SIGHUP, and stderr naming the signal and the substitution on the 4 path.The
nullverdict (nothing was minted, so there is nothing to revoke) yields the fallback on both paths, which is right — there is no token whose survival could be unproven. The handler's ordering is unchanged: staged dirs removed first, then revoke, then report, then exit.3. The tests assert behaviour, and I ran them
At the head I ran
npm cithennpx vitest run tests/seat-post.test.ts: 15/15 pass, including six parametrized rows — for each of SIGTERM, SIGINT and SIGHUP, the lookup-self 403 case (expects 128+signal) and the lookup-self 200 case (expects 4). Each row spawns the real helper as a child, proves the staged 0600 token file exists before the signal, then asserts the child's exit code, that the staged dir is gone afterwards, that the fake server really sawPOST /v1/auth/token/revoke-self, and the stderr text. Nothing there asserts an internal helper by name.I also ran the head test file against the base
seat-post.tsin a separate worktree: the three unproven rows fail there (expected 143 to be 4,expected 130 to be 4,expected 129 to be 4) while the three proven-dead rows still pass. The tests fail on the bug and pass on the fix, which is behavioural evidence rather than implementation coupling.npx tsc --noEmitat the head: exit 0, no diagnostics.4. CI at this exact head
Combined status for
05fc9e4bb4144c932c9add9aa73264cc0a2f7195issuccess, 15/15 contexts, includingCI / forgejo(run 452 job 5) andCI / pr-title(run 452 job 0).Acceptance
finallycontract is unchanged; 4 is unreachable when the revoke is provably dead; cleanup ordering unchangedVerdict: approved at
05fc9e4bb4144c932c9add9aa73264cc0a2f7195.Verdict: APPROVED
model: omniroute/deepseek-flash
head:
05fc9e4bb4session: seat-124-2
Seat 2 of 2 independent bridge review seats on this PR. Reviewed from primary sources with my own
measurements: the head fetched as
ld-reviewer(GET /user=ld-reviewer, id 47) fromrefs/heads/plane/HOMELAB-2479-122-signal-exit-fix, which resolves to05fc9e4bb4144c932c9add9aa73264cc0a2f7195; base6ca09e3827616c2fbf855f1bb82351b10895ef37onmain.Acceptance checks (measured, not copied from the PR text)
1. Scope — exactly the two expected files, nothing else.
git diff --name-status base..head=M forgejo/seat-post.ts,M forgejo/tests/seat-post.test.ts; one commit; +114 / −21(numstat 30/5 and 84/16). Nothing under
.forgejo/,deploy/,.pi/, no workflow file, noAGENTS.md, no agent instruction file. A scan of every added line for credential-shaped literals(40+ char base64ish runs,
BEGIN … PRIVATEblocks,password/secret/token: "…") returnsnothing.
2. The signal path and the normal path now share one mapping. The new
exitCodeAfterRevoke(verdict, fallback) = verdict && !verdict.dead ? EXIT.revoke : fallbackis used inboth places: the handler's
process.exit(exitCodeAfterRevoke(verdict, signalCode))and thefinally'scode = exitCodeAfterRevoke(verdict, code). The second is arithmetically identical to theif (verdict && !verdict.dead) code = EXIT.revokeit replaced, so the normal-unwind contract isunchanged, and the two paths can no longer disagree by construction.
deadis stillrevokeOk && lookupDenied(revoke-self 2xx AND lookup-self 403/404), so a revoke proven dead cannotyield 4 on any signal: SIGTERM keeps 143, SIGINT 130, SIGHUP 129. A
nullverdict (nothing wasminted) falls through to the fallback, so the "nothing to revoke" path still exits with the signal
code, and no path below the mapping can reclassify a proven-dead revoke.
3. Ordering preserved. The handler does, in order: best-effort removal of every live staging dir
→
log.saynaming the signal →await doRevoke()→reportRevoke(verdict)→ the new stderr line(naming 4 and the 128+signal code it superseded, only when the revoke is not proven dead) →
process.exit(...). That is the #1973 order with only the exit expression changed.doRevokeismemoised on
revokeState.done, so the handler and any pendingfinallycannot revoke twice or reportdifferent verdicts.
4. The docs that contradicted each other were both moved. The header exit table now states 4
takes precedence over 128+signal and adds that 130/143/129 is the proven-dead convention with the
signal always named on stderr; the hygiene bullet now says a failed revoke on a signal exits 4. The
code, the table and the hygiene contract agree at the head.
5. The tests assert behaviour, and I watched them bite. In the sandbox on the head tree:
npx tsc --noEmitexit 0;npx vitest run tests/seat-post.test.ts→ 15/15, with all six signal rowslisted individually. Each row of both
it.eachtables (three signals × proven-dead / unproven)asserts that the 0600 token file was really staged before the signal (so a row cannot pass
vacuously), that the staged dir is gone afterwards, and that
POST /v1/auth/token/revoke-selfwasactually sent; the unproven rows additionally assert the code is
EXIT.revokeand that stderrcarries
NOT proven deadandinstead of <signalCode>. Negative control I ran myself: revertingonly
forgejo/seat-post.tsto6ca09e38makes exactly the three unproven rows fail(
expected 143/130/129 to be 4) while the three proven-dead rows still pass — the suite measures thefix, not the implementation, and reproduces the #122 defect exactly as described.
6. No caller drifts. A tree-wide grep at the head for 143 / 130 / 129 /
128+/SIGTERMoutsideseat-post.tsand its tests found no other reader of this helper's signal exit codes, so tighteningthe documented contract breaks nothing else in this repo.
7. CI green at the head. Runs #452 (all 15 jobs,
forgejoincluded) and #453 (python) bothran on
05fc9e4bb4and passed.Conclusion
APPROVED at
05fc9e4bb4144c932c9add9aa73264cc0a2f7195. The named defect is fixed in the code andpinned by tests that fail without it; the diff is confined to the two files it claims; nothing secret,
nothing unrelated, no unrelated refactor.