Skip to content

test(tests): let each bats file declare the EXIT traps it still holds - #3622

Merged
Aleksei Sviridkin (lexfrei) merged 2 commits into
mainfrom
fix/bats-exit-trap-ratchet-per-file
Aug 8, 2026
Merged

test(tests): let each bats file declare the EXIT traps it still holds#3622
Aleksei Sviridkin (lexfrei) merged 2 commits into
mainfrom
fix/bats-exit-trap-ratchet-per-file

Conversation

@lexfrei

@lexfrei Aleksei Sviridkin (lexfrei) commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

What this PR does

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.

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.bats carrying 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 gives found != 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 in run-kubernetes-talos-diagnostics_test.bats with 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.bats got 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=8 to it, repeating a repair that has already landed, and #3441 removes select-e2e_test.bats=15 from 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.bats from 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: N comment 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.bats and hack/tenant-pre-delete-hook.bats, and hack/cozyreport.bats grew 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 .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; 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 @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 one the bats binary installs, so a test failing inside ( … ) still prints its not ok, checked against a test-level trap in the same file, where the TAP line vanishes. hack/e2e-test-openapi.bats kills a backgrounded kubectl proxy exactly 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.md previously scoped this exception to Chainsaw script steps 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 missed tmp=$(mktemp -d);trap … EXIT and (trap … EXIT; true); upper case missed trap … 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: bootstrap ends in trap , 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: 8 instead 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 .bats only, so a handler arriving through a sourced .sh is outside it. hack/e2e-chainsaw/_lib/run-kubernetes.sh installs two right now, and each is benign for its own reason rather than by design: the one in cozy_capture_tenant_talos because that function is declared with ( and so runs in a subshell, the one in run_kubernetes_test because no @test calls it despite being declared with {. Three hack/*.bats source that library, and two tests in the converted file call cozy_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 .sh would 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 EXIT or 0 happens 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.md bans test-level EXIT and RETURN traps, while every mechanical guard this repo has had, the one being deleted included, matches only EXIT and 0. 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.bats into hack/bats-no-exit-trap.bats, because its subject is every unit suite under hack/ and not the report collector it grew up in. Living inside cozyreport.bats is precisely why unrelated PRs kept converging on one line. hack/md-no-hardwrap.bats is the neighbouring precedent.

Separately, the eight EXIT traps in hack/run-kubernetes-talos-diagnostics_test.bats are 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

test(tests): each hack/*.bats file now declares its own remaining EXIT-trap debt in a `# EXIT-TRAP DEBT: N` header comment, checked by hack/bats-no-exit-trap.bats, replacing the central inventory in hack/cozyreport.bats

Summary by CodeRabbit

  • Testing

    • Added automated auditing for EXIT traps across Bats end-to-end tests, including validation of tracking declarations and edge cases.
    • Improved diagnostics tests by replacing trap-based temporary-directory cleanup with explicit cleanup steps.
    • Added tracking annotations for remaining trap-related cleanup work.
  • Documentation

    • Clarified when traps are permitted inside self-contained subshells and how remaining cleanup debt is reported.
    • Updated review guidance for consistent end-to-end test maintenance.

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>
@github-actions github-actions Bot added area/testing Issues or PRs related to testing (e2e, bats, unit tests) size/XL This PR changes 500-999 lines, ignoring generated files labels Aug 7, 2026
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The 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.

Changes

Trap policy and debt tracking

Layer / File(s) Summary
Trap policy and debt declarations
AGENTS.md, docs/agents/e2e-testing.md, hack/*.bats
Guidance now permits only scoped traps and requires EXIT-TRAP DEBT declarations for remaining test-level traps. Affected Bats files record current debt counts or intentional subshell traps.

Bats trap audit

Layer / File(s) Summary
Audit scanner and declaration validation
hack/bats-no-exit-trap.bats
A recursive scanner detects EXIT and signal 0 handlers, parses leading debt declarations, and reports count, format, placement, duplicate, and ambiguity errors.
Scanner edge-case coverage
hack/bats-no-exit-trap.bats
Tests cover continuations, comments, quoting, punctuation, case-insensitive signals, recursive paths, malformed declarations, duplicate headers, and multiple handlers.

Explicit test cleanup

Layer / File(s) Summary
Talos diagnostics cleanup flow
hack/run-kubernetes-talos-diagnostics_test.bats
Diagnostics tests remove EXIT cleanup traps, clean temporary directories explicitly, and restore the original working directory before cleanup. Obsolete EXIT-trap count checks were removed from hack/cozyreport.bats.

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
Loading

Possibly related PRs

Suggested labels: area/ci

Suggested reviewers: myasnikovdaniil

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: each Bats file declares the EXIT traps that remain.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/bats-exit-trap-ratchet-per-file

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between 605030b and 6cfeadd.

📒 Files selected for processing (14)
  • AGENTS.md
  • docs/agents/e2e-testing.md
  • hack/bats-no-exit-trap.bats
  • hack/build-matrix_test.bats
  • hack/capture-dataplane.bats
  • hack/cozyreport.bats
  • hack/e2e-test-openapi.bats
  • hack/multus-install-cni-plugins.bats
  • hack/nightly-mirror_test.bats
  • hack/overlay-main-images_test.bats
  • hack/release-changelog-behaviour.bats
  • hack/release-changelog-contract.bats
  • hack/run-kubernetes-talos-diagnostics_test.bats
  • hack/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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 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.md

Repository: 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.md

Repository: 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/testing Issues or PRs related to testing (e2e, bats, unit tests) size/XL This PR changes 500-999 lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant