test(tests): let each bats file declare the EXIT traps it still holds - #3622
Conversation
The ban on EXIT-trap cleanup in hack/*.bats was enforced by two lists inside hack/cozyreport.bats: one naming the files known to be clean, one freezing an exact trap count for each file that was not. Both had to be edited from whatever change moved them, and that is where the guard kept failing. A change adding a trap to its own file had to edit a string in a suite it otherwise never touches, so two changes sharing no line still invalidated each other: each stayed green against its own base, git merged both cleanly, and the guard went red only once the second landed. A file arriving with traps hit the same wall from the other side -- the inventory did notice it, since the string it compared was built by scanning the directory, but absorbing it meant an edit in a file nothing in the author's diff pointed at. Replace both lists with a declaration each file makes about itself: one "# EXIT-TRAP DEBT: N" comment, exact rather than a ceiling. A file carrying none must install no EXIT trap, which covers the converted files, the files that never had one, and every file added later. Growing or shedding a trap now fails in the file the change already edits, so two changes that disagree about a count collide textually instead of silently. The declaration is read from the leading comment block, not from anywhere in the file. A .bats file is shell that writes shell, so the same line turns up inside a heredoc, a fixture writer or an expected-output string, where it is data belonging to one test. Reading it there as a statement about the whole file would let an unrelated fixture excuse a real trap, and would do it silently, since nothing in that test's own diff looks like a declaration. A comment block is the region with no interior: stopping instead at the first @test would still read a line out of a helper's heredoc. Not every counted handler is debt. A trap inside an explicit subshell does not replace the bats binary's own, so a test failing inside `( ... )` still prints its `not ok`. hack/e2e-test-openapi.bats kills a backgrounded kubectl proxy that way, and moving the kill to the end of the body would leak a process holding a fixed port. Its declaration records that rather than scheduling a conversion, and because the ratchet is exact, removing the trap fails too -- the count protects the construct. What the count cannot do is tell the two apart: substituting a test-level trap for the subshell one keeps the total at 1 and stays green, which the header states rather than leaves to be found. Counting bounds the keyword and the signal the same way, at any character that cannot be part of an identifier, and matches the signal in either case. Whitespace on the right missed `trap ... EXIT; cd "$tmp"`; whitespace on the left missed `tmp=$(mktemp -d);trap ... EXIT` and `(trap ... EXIT; true)`; upper case missed `trap ... exit`, which bash and dash both install. All are real handlers that scored zero, and the left boundary matters most, since the inventory being replaced had none and caught the semicolon form. A bare word boundary is not enough either way: `bootstrap ` ends in `trap `, and it must keep scoring nothing. A quoted signal counts for the same reason as the rest. Two handlers sharing one line are reported rather than counted, since the count is a count of lines and the second would otherwise arrive without moving the total; splitting the line properly needs a shell parser, a semicolon inside a handler's own action not being a separator, and guessing wrong undercounts -- the one direction a ratchet cannot afford. The scan recurses, so hack/e2e-apps/*.bats is covered rather than sitting one directory below the guard that claims the tree. It reads .bats and nothing else, so a handler arriving through a sourced .sh stays outside it -- hack/e2e-chainsaw/_lib/run-kubernetes.sh installs two, each benign for its own reason rather than by design: one sits in a function declared with `(` and so runs in a subshell, the other in a brace function no @test calls. That boundary is stated in the header rather than papered over. The guard moves to hack/bats-no-exit-trap.bats: its subject is every unit suite under hack/, not the report collector it grew up in. Its fixture helpers assemble the trap keyword and the signal from separate arguments, because the guard scans its own source and a fixture written as a literal would be counted as a real trap in it. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
Eight of the ten tests in this file removed their scratch directory from a `trap ... EXIT`. Such a trap replaces the one the bats binary installs for its own bookkeeping, so a test that fails prints no TAP line at all: not `not ok`, nothing. The run ends with a warning that it executed nine of ten tests, and anyone reading the tail of the output, or grepping it for `not ok`, sees a suite with no failures in it. Break one assertion in this file and the difference is the whole point. Before: `1..10` followed by `# bats warning: Executed 9 instead of expected 10 tests`, with no line naming the test that failed. After: `not ok 2 reader Certificate is short-lived and never embeds credential data`. Remove the directory at the end of each body instead. Both runners set -e, so on failure the removal is unreachable and the directory survives for inspection, which is what a failed test wants anyway. The one test that runs the orchestrator from inside its scratch directory returns to where it started first -- not because rm would refuse, since BSD, busybox and GNU rm all remove a directory from inside it and return 0, but because the runner's own bookkeeping still runs in that subshell after the body returns, and would be doing so on an unlinked cwd. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
📝 WalkthroughWalkthroughThe PR defines scoped EXIT-trap exceptions, adds recursive Bats trap-debt auditing, records remaining debt in affected tests, removes obsolete trap-count checks, and replaces Talos diagnostics cleanup traps with explicit cleanup. ChangesTrap policy and debt tracking
Bats trap audit
Explicit test cleanup
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant BatsAudit as bats-no-exit-trap.bats
participant HackFiles as hack/**/*.bats
participant DebtHeader as EXIT-TRAP DEBT header
BatsAudit->>HackFiles: Recursively scan Bats files
HackFiles-->>BatsAudit: Provide shell lines
BatsAudit->>DebtHeader: Parse leading declaration
DebtHeader-->>BatsAudit: Return declared count
BatsAudit-->>BatsAudit: Compare detected and declared counts
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/agents/e2e-testing.md`:
- Line 125: Align the trap policy with the existing EXIT-only debt marker and
audit: update the wording at docs/agents/e2e-testing.md:125 and AGENTS.md:31 to
refer only to EXIT traps, preserving the existing exceptions and debt-counting
guidance; no RETURN-trap validation is required.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: f7305e29-a9a0-48e4-b950-b6ff94115326
📒 Files selected for processing (14)
AGENTS.mddocs/agents/e2e-testing.mdhack/bats-no-exit-trap.batshack/build-matrix_test.batshack/capture-dataplane.batshack/cozyreport.batshack/e2e-test-openapi.batshack/multus-install-cni-plugins.batshack/nightly-mirror_test.batshack/overlay-main-images_test.batshack/release-changelog-behaviour.batshack/release-changelog-contract.batshack/run-kubernetes-talos-diagnostics_test.batshack/select-e2e_test.bats
💤 Files with no reviewable changes (1)
- hack/cozyreport.bats
| 2. Resource readiness uses a Chainsaw `assert` (not an imperative `until kubectl get …; kubectl wait`); condition checks use the filter-as-list form `(conditions[?type == 'Ready'])`. | ||
| 3. Imperative-only waits live in a `script` step; any bare `sleep` carries a `TODO(e2e-replace-fixed-timeouts):` justification. | ||
| 4. No test-level `EXIT`/`RETURN` trap — rely on Chainsaw cleanup; a self-contained trap is allowed only inside a single `script` step (port-forward / temp dir). | ||
| 4. No test-level `EXIT`/`RETURN` trap — rely on Chainsaw cleanup; a self-contained trap is allowed inside a single `script` step (port-forward / temp dir), and inside an explicit subshell within a BATS `@test`, where it does not displace the runner's own handler. A BATS file that still carries test-level traps declares the count itself in `# EXIT-TRAP DEBT: N`; do not ask for one to be removed without checking that line, because the count is exact in both directions. |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/usr/bin/env bash
set -euo pipefail
echo "BATS RETURN traps:"
rg -nP '\btrap\b.*\bRETURN\b' hack --glob '*.bats' || true
echo "Guard and policy references:"
rg -n 'RETURN|EXIT-TRAP DEBT' hack/bats-no-exit-trap.bats docs/agents/e2e-testing.md AGENTS.mdRepository: cozystack/cozystack
Length of output: 4734
🏁 Script executed:
#!/usr/bin/env bash
set -euo pipefail
echo "Guard outline/size:"
wc -l hack/bats-no-exit-trap.bats docs/agents/e2e-testing.md AGENTS.md
echo
echo "Guard relevant sections:"
sed -n '1,120p' hack/bats-no-exit-trap.bats
echo "---"
sed -n '180,235p' hack/bats-no-exit-trap.bats
echo "---"
sed -n '320,365p' hack/bats-no-exit-trap.bats
echo
echo "Docs/policy relevant lines:"
sed -n '35,130p' docs/agents/e2e-testing.md
echo "---"
sed -n '25,35p' AGENTS.mdRepository: cozystack/cozystack
Length of output: 45457
Align the documented policy with the guard scope.
The policy names both EXIT and RETURN traps, but the debt marker and audit only track EXIT traps. Either add RETURN-trap validation or update the wording at docs/agents/e2e-testing.md and AGENTS.md to target EXIT traps only.
📍 Affects 2 files
docs/agents/e2e-testing.md#L125-L125(this comment)AGENTS.md#L31-L31
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/agents/e2e-testing.md` at line 125, Align the trap policy with the
existing EXIT-only debt marker and audit: update the wording at
docs/agents/e2e-testing.md:125 and AGENTS.md:31 to refer only to EXIT traps,
preserving the existing exceptions and debt-counting guidance; no RETURN-trap
validation is required.
What this PR does
The ban on EXIT-trap cleanup in
hack/*.batswas enforced by two lists insidehack/cozyreport.bats: one naming the files known to be clean, one freezing an exact trap count for each file that was not. Both had to be edited from whatever change moved them, and that is where the guard kept failing.It is worth being exact about how badly, because the history is sharper than "it went stale a few times". The inventory was never correct on main for a single commit. It landed in #3567 at 15:25:20. #3195 had landed
multus-install-cni-plugins.batscarrying twelve traps at 15:24:37, forty-three seconds earlier. Replaying the old guard's own logic against the tree at the very commit that introduced it already givesfound != frozen. Main then stayed red for roughly twenty-two and three quarter hours. The first repair, #3584, landed already red the next morning at 10:42:43, because #3548 had brought inrun-kubernetes-talos-diagnostics_test.batswith eight traps at 10:40:03, under three minutes ahead of it. That fix was correct and bought zero green time. Green arrived only with the second repair, #3602.Neither pair of PRs shared a line, and each was green against its own base. That is the whole mechanism, and it is why a third one-line repair is not the answer.
The mechanism is structural rather than careless. A change that adds a trap to its own file had to edit a string in a suite it otherwise never touches, so two changes sharing no line still invalidated each other: each stayed green against its own base, git merged both cleanly, and the guard went red only once the second one landed. A file arriving with traps was worse still, because nothing in its author's diff pointed at that string at all. That is exactly how
run-kubernetes-talos-diagnostics_test.batsgot in, twice.That contention is not in the past tense. Two open PRs are editing that one line right now, and they disagree about what it should say: #3575 adds
run-kubernetes-talos-diagnostics_test.bats=8to it, repeating a repair that has already landed, and #3441 removesselect-e2e_test.bats=15from it, because it converts that file. Neither PR is about EXIT traps. Both have to touch that string anyway, and whichever lands second is wrong until someone edits it again.So this PR is not fixing a red main. It removes the thing that keeps making main red, which is why it is worth more than the one-line fix that is now the established habit.
One honest caveat about its own landing. The textual-conflict property is steady-state: a branch forked before the declaration exists has no line to disagree with, so it converts traps in its own file, merges clean, and the count only goes wrong once both sides are on main. I checked this against #3441, which takes
select-e2e_test.batsfrom fifteen traps to zero. Merged after this, that file would declare fifteen and hold none. What the move buys even in that case is that the red names a file the branch already edited, the repair is one line inside it, and rebasing before merge catches it on the branch's own CI. None of those three held against the central inventory. Whoever merges this should expect one such adjustment on the conversion branches still in flight.So this replaces both lists with a declaration each file makes about itself: one
# EXIT-TRAP DEBT: Ncomment in its leading comment block. A file carrying no declaration must install no EXIT trap. Growing or shedding a trap now fails in the file the change already edits, so two changes that disagree about a count get a real textual conflict instead of silently invalidating each other, and a change that leaves the traps alone edits nothing.The include list is redundant, not lost. It named the files proven clean, so that a trap reappearing in one of them would fail. Under the new rule those files carry no declaration, and a file with no declaration must hold zero traps, so a trap reappearing in any of them fails on its own, with no list to be on. Coverage widens rather than narrows: the two lists named twenty files between them, and the rule covers all fifty bats files under
hack/, subdirectories included, plus the ones added tomorrow.Rebasing this branch onto current main is the property working. Main has since gained
hack/kubernetes-pre-delete-hook.batsandhack/tenant-pre-delete-hook.bats, andhack/cozyreport.batsgrew by some eight hundred lines. Neither new file installs an EXIT trap, so neither needed a declaration and neither needed an edit here; the rebase took no conflict at all. Under the inventory, each arriving file was a coin toss on whether somebody had remembered the string.To be precise about what the inventory could and could not do, since it is easy to overstate: it did notice a new file carrying traps. The string it compared was built by scanning the directory, so an arriving file appended a token and failed the comparison, which is exactly how main went red. What it could not do is let that file arrive without an edit in a foreign suite. Being seen and being absorbable are different properties, and only the second one decides whether two changes can land independently.
The declaration is pinned in both directions. Declaring N while holding N+1 fails, obviously; declaring N while holding N−1 fails too. Without that second half the number becomes a ceiling and rots upward: somebody converts half a file, the declaration stays, and the guard quietly licenses traps that were removed long ago.
It is read only from the leading comment block, under the shebang and above the first line of code. A
.batsfile is shell that writes shell, so the same line turns up inside a heredoc, a fixture writer or an expected-output string, where it is data belonging to one test; honouring it there would let an unrelated fixture excuse a real trap, silently, with nothing in that test's own diff looking like a declaration. A comment block is the region with no interior; stopping instead at the first@testwould still read a line out of a helper's heredoc.Not every counted handler is debt. A trap inside an explicit subshell does not replace the one the
batsbinary installs, so a test failing inside( … )still prints itsnot ok, checked against a test-level trap in the same file, where the TAP line vanishes.hack/e2e-test-openapi.batskills a backgroundedkubectl proxyexactly that way, and "convert it like the others" would leak a process holding a fixed port and wedge the next run. Its declaration now records the carve-out instead of scheduling a conversion, and because the ratchet is exact in both directions, removing that trap fails too, so the count protects the construct rather than marking it for deletion.docs/agents/e2e-testing.mdpreviously scoped this exception to Chainsawscriptsteps only; it now names the BATS subshell case as well.The counting bounds the keyword and the signal the same way, at any character that cannot be part of an identifier, and matches the signal in either case. Whitespace on the right missed
trap … EXIT; cd "$tmp"; whitespace on the left missedtmp=$(mktemp -d);trap … EXITand(trap … EXIT; true); upper case missedtrap … exit, which bash and dash both install. All of those are real handlers that scored zero. The left boundary is the one worth dwelling on, because the inventory being replaced had none at all and did catch the semicolon form. Getting it wrong here would have narrowed coverage while the commit claimed to widen it. A plain word boundary is not enough either:bootstrapends intrap, and it has to keep scoring nothing, or the documented answer to a red guard (add a debt line) would buy a file a permanent licence for one real trap to silence a line that has none.Two handlers sharing one line are reported rather than counted, because the count is a count of lines and the second would otherwise arrive free. Splitting such a line properly needs a shell parser, since a semicolon inside a handler's own quoted action is not a separator, and guessing wrong undercounts, the one direction a ratchet cannot afford.
What this does not fix. The declaration is still a loophole: a new file can write
# EXIT-TRAP DEBT: 8instead of cleaning up, and nothing here makes that impossible. What changes is that the admission is local and visible. It sits at the top of the file it excuses, in front of whoever reviews that file, instead of being a number in a neighbouring suite nobody in that review is reading. Today's loophole is the same size and invisible.Three more limits, all stated in the guard's own header rather than left to be discovered. The scan is lexical, so a signal computed at runtime and a quoted action spanning physical lines without a backslash are both invisible. An exact count catches addition and removal but never substitution: swap the openapi file's subshell trap for a test-level one and the total stays 1. And the scan reads
.batsonly, so a handler arriving through a sourced.shis outside it.hack/e2e-chainsaw/_lib/run-kubernetes.shinstalls two right now, and each is benign for its own reason rather than by design: the one incozy_capture_tenant_talosbecause that function is declared with(and so runs in a subshell, the one inrun_kubernetes_testbecause no@testcalls it despite being declared with{. Threehack/*.batssource that library, and two tests in the converted file callcozy_capture_tenant_talos, so flipping a single(to{reinstates a test-level handler in both of them with the guard green. Widening the scan to.shwould mean counting handlers that are correct in a script and wrong only in a test body, so the honest answer is that this is where the instrument stops.Three further boundaries, recorded here so they land as known edges rather than as surprises. The old include list also failed when a file named on it disappeared from the tree; the new rule can only judge a file that is present, so a deleted converted file goes unnoticed. That is a genuinely smaller check, though its absence shows up in the diff that deletes the file. The guard's own failure messages are code lines, so they are scanned by the pattern they belong to: they pass today only because no bare
EXITor0happens to follow the keyword in any of them, and a rewording that introduced one would make the file demand a debt of itself. It fails loudly rather than quietly, and the fixture writers and test titles already split the keyword from the signal for this reason, but the messages do not. Finally,docs/agents/e2e-testing.mdbans test-levelEXITandRETURNtraps, while every mechanical guard this repo has had, the one being deleted included, matches onlyEXITand0.hack/holds no RETURN trap today, so nothing regresses here, but half of that documented rule has never had an executor.The guard moves out of
hack/cozyreport.batsintohack/bats-no-exit-trap.bats, because its subject is every unit suite underhack/and not the report collector it grew up in. Living insidecozyreport.batsis precisely why unrelated PRs kept converging on one line.hack/md-no-hardwrap.batsis the neighbouring precedent.Separately, the eight EXIT traps in
hack/run-kubernetes-talos-diagnostics_test.batsare converted to end-of-body cleanup rather than given a declaration. That file was written six weeks after the ban was documented, so "it predates the rule" is not available to it, and handing it a debt is literally the move that failed the last two times. The remaining files keep their declarations, since a mass conversion would collide with the branches that own them.One note on where the prose sits. The guard's own header carries only what a reader has to keep true: why the ban exists, the two carve-outs, where the declaration lives and why there, that the count is exact in both directions, why the marker is anchored at column zero, and where the lexical scan stops. Everything above about how the inventory failed (the timestamps, the PR numbers, the arithmetic) is deliberately not in the file. It is true today and will rot on its own; a comment nobody is obliged to maintain is how a file grows a paragraph that is quietly false.
Screenshots
Downstream repositories
Release note
Summary by CodeRabbit
Testing
EXITtraps across Bats end-to-end tests, including validation of tracking declarations and edge cases.Documentation