Skip to content

fix(oom-staged): a re-run whose rebuild fails restored a STALE backup over /etc/nixos - #1459

Closed
ZacxDev wants to merge 1 commit into
mainfrom
fix/oom-staged-stale-restore
Closed

fix(oom-staged): a re-run whose rebuild fails restored a STALE backup over /etc/nixos#1459
ZacxDev wants to merge 1 commit into
mainfrom
fix/oom-staged-stale-restore

Conversation

@ZacxDev

@ZacxDev ZacxDev commented Sep 9, 2026

Copy link
Copy Markdown
Member

fix(oom-staged): a re-run whose rebuild fails restored a STALE backup over /etc/nixos

MEASURED DATA LOSS in the staged script #1415 merged. Found by the round-2
delta audit of that PR, reproduced by executing the script rather than reading
it, and it is a defect the round-1 fix pass INTRODUCED.

THE DEFECT

BACKUP="${CFG}.bak.tmux-oom" is a FIXED name (correctly — the timestamped
spelling it replaced added one file per run to a directory already holding 18).
But the backup is only taken on the branch that WIRES the import, while
trap restore ERR covers the whole script including the closing
nixos-rebuild switch. And restore() asked only whether the backup FILE
EXISTS.

So on a re-run — which takes the already-wired path and creates no backup — any
rebuild failure, for any unrelated reason, found the PREVIOUS run's file and
cp'd it over the live config:

run 1 wires the import, writes $BACKUP, rebuild OK, $BACKUP survives
operator hand-edits /etc/nixos/configuration.nix
run 2 already wired -> no backup taken; rebuild fails -> ERR trap
"FAILED - restoring ... from ...bak.tmux-oom"
import present: 0 <- the live, correctly-applied import GONE
operator edit present: 0 <- an unrelated edit GONE
config byte-identical to the pre-wiring backup: YES

cp -a restores the old mtime as well, so there is no tell. The message says
"restoring", which reads as correct unwinding.

WHY THE EXISTING TEST COULD NOT SEE IT

test_restore_does_not_claim_to_restore_a_backup_that_does_not_exist asserted
the literal -f "$BACKUP" appeared inside restore(). That is a guard on the
SOURCE SPELLING, and the spelling it pinned is exactly what makes the bug fire:
-f is TRUE precisely because a stale backup exists. It read as coverage while
providing none.

THE FIX

  • restore() gates on BACKUP_TAKEN_THIS_RUN, not on file existence. A run
    that changed nothing restores nothing.
  • the backup moves to the one branch that actually writes $CFG, immediately
    before the write. Taken earlier, a refusal that modified nothing still
    deposited a .bak file — which then armed the stale restore on the next run.

TESTS: BEHAVIOURAL, because the structural form is what missed this

Three tests execute the real script with only its environment couplings
replaced (paths, the $EUID test, the selector pre-flight, nixos-rebuild); the
trap, the backup and every branch are untouched. Every substitution is asserted
to have applied, so a harness that patched nothing cannot report a pass.

  • a re-run whose rebuild fails must NOT restore a stale backup
  • POSITIVE CONTROL: a FIRST run whose rebuild fails MUST still roll back
  • a refusal that modifies nothing leaves no backup behind

MUTATION: 3 mutants, 3 killed, each by its own named assertion, control green.

M1 restore() back to a bare -f "$BACKUP" KILLED ("RESTORED A STALE
BACKUP ... destroyed an unrelated operator edit")
and the positive control still PASSED under M1, so the new test
discriminates rather than merely asserting restore never runs.
M2 backup moved back above the awk KILLED ("a refusal that
modified nothing left a backup behind")
M3 BACKUP_TAKEN_THIS_RUN pinned to 0 KILLED ("a FAILED first run
must restore the config it modified")

M2 SURVIVED on the first sweep, and that was a real finding about the TEST, not
noise: the fixture used a config with no imports = at all, which exits at the
count guard ABOVE both the backup and the awk — so early-cp and late-cp behave
identically and the guarded branch never executes. The fixture now uses
imports = present exactly once with no [ after it, which is the only shape
that reaches the awk-found-nothing refusal. Also recorded: an earlier M2 attempt
scored SURVIVED without running at all, because the mutating python3 was not
on PATH and the failure was not read; the sweep now verifies the mutant differs
from the original before scoring it.

The module docstring claimed this script's behaviour "is not reachable from the
suite". Its privileged EFFECT is not; its CONTROL FLOW is, and that claim is
what let a spelled guard stand in for a real one. Corrected in the same commit.

Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_013jdbmhCKa6edhTmiADsziR

… over /etc/nixos

MEASURED DATA LOSS in the staged script #1415 merged. Found by the round-2
delta audit of that PR, reproduced by executing the script rather than reading
it, and it is a defect the round-1 fix pass INTRODUCED.

THE DEFECT

`BACKUP="${CFG}.bak.tmux-oom"` is a FIXED name (correctly — the timestamped
spelling it replaced added one file per run to a directory already holding 18).
But the backup is only taken on the branch that WIRES the import, while
`trap restore ERR` covers the whole script including the closing
`nixos-rebuild switch`. And `restore()` asked only whether the backup FILE
EXISTS.

So on a re-run — which takes the already-wired path and creates no backup — any
rebuild failure, for any unrelated reason, found the PREVIOUS run's file and
cp'd it over the live config:

  run 1  wires the import, writes $BACKUP, rebuild OK, $BACKUP survives
  operator hand-edits /etc/nixos/configuration.nix
  run 2  already wired -> no backup taken; rebuild fails -> ERR trap
         "FAILED - restoring ... from ...bak.tmux-oom"
         import present: 0      <- the live, correctly-applied import GONE
         operator edit present: 0  <- an unrelated edit GONE
         config byte-identical to the pre-wiring backup: YES

`cp -a` restores the old mtime as well, so there is no tell. The message says
"restoring", which reads as correct unwinding.

WHY THE EXISTING TEST COULD NOT SEE IT

`test_restore_does_not_claim_to_restore_a_backup_that_does_not_exist` asserted
the literal `-f "$BACKUP"` appeared inside `restore()`. That is a guard on the
SOURCE SPELLING, and the spelling it pinned is exactly what makes the bug fire:
`-f` is TRUE precisely because a stale backup exists. It read as coverage while
providing none.

THE FIX

  * `restore()` gates on BACKUP_TAKEN_THIS_RUN, not on file existence. A run
    that changed nothing restores nothing.
  * the backup moves to the one branch that actually writes $CFG, immediately
    before the write. Taken earlier, a refusal that modified nothing still
    deposited a .bak file — which then armed the stale restore on the next run.

TESTS: BEHAVIOURAL, because the structural form is what missed this

Three tests execute the real script with only its environment couplings
replaced (paths, the $EUID test, the selector pre-flight, nixos-rebuild); the
trap, the backup and every branch are untouched. Every substitution is asserted
to have applied, so a harness that patched nothing cannot report a pass.

  * a re-run whose rebuild fails must NOT restore a stale backup
  * POSITIVE CONTROL: a FIRST run whose rebuild fails MUST still roll back
  * a refusal that modifies nothing leaves no backup behind

MUTATION: 3 mutants, 3 killed, each by its own named assertion, control green.

  M1  restore() back to a bare `-f "$BACKUP"`   KILLED  ("RESTORED A STALE
      BACKUP ... destroyed an unrelated operator edit")
      and the positive control still PASSED under M1, so the new test
      discriminates rather than merely asserting restore never runs.
  M2  backup moved back above the awk           KILLED  ("a refusal that
      modified nothing left a backup behind")
  M3  BACKUP_TAKEN_THIS_RUN pinned to 0         KILLED  ("a FAILED first run
      must restore the config it modified")

M2 SURVIVED on the first sweep, and that was a real finding about the TEST, not
noise: the fixture used a config with no `imports =` at all, which exits at the
count guard ABOVE both the backup and the awk — so early-cp and late-cp behave
identically and the guarded branch never executes. The fixture now uses
`imports =` present exactly once with no `[` after it, which is the only shape
that reaches the awk-found-nothing refusal. Also recorded: an earlier M2 attempt
scored SURVIVED without running at all, because the mutating `python3` was not
on PATH and the failure was not read; the sweep now verifies the mutant differs
from the original before scoring it.

The module docstring claimed this script's behaviour "is not reachable from the
suite". Its privileged EFFECT is not; its CONTROL FLOW is, and that claim is
what let a spelled guard stand in for a real one. Corrected in the same commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013jdbmhCKa6edhTmiADsziR
Claude-Session-Id: 097b404c-db17-4472-bd37-dc90cf8fa675
@ZacxDev

ZacxDev commented Sep 9, 2026

Copy link
Copy Markdown
Member Author

Closing in favour of #1464, which deletes the script this PR fixes.

Operator decision after a round-0 (requirements & deletion) audit of this PR. The fix here
is real and correct — a re-run whose nixos-rebuild failed restored a stale backup over
/etc/nixos/configuration.nix, destroying the live import and any unrelated operator edits —
but the round-0 pass asked the question the nine correctness axes never do: should this script
exist at all?

What decided it:

  • The requirement is attributed to the operator verbatim, at 2026-09-07 22:17 CDT — 23
    minutes after
    the incident, while memory exhaustion was still the working diagnosis. That
    diagnosis was refuted the next day (the cause was an agent's wide tmux kill; zero kernel
    OOM lines for the incident boot, with a positive control showing the pattern can match real
    OOM kills from 2026-08-28). A sweep of every transcript since found no re-confirmation of
    the ask against the corrected diagnosis — one match, the original, with a positive control
    proving the scanner fires.
  • The payload is inert against every OOM this host has recorded. Four events across five
    retained boots, all CONSTRAINT_MEMCG inside one kubepods pod, zero global. A memcg OOM
    picks its victim from inside the offending cgroup; tmux lives in session-3.scope. Lowering
    oom_score_adj on tmux can only change a global OOM's outcome.
  • Never run, four ways: no import in /etc/nixos/configuration.nix, no module file, no
    .bak.tmux-oom (so the wiring branch never executed even partially), and the live server's
    oom_score_adj still reads 0.

The counter-argument is recorded in #1464's body rather than dropped, because it is real and
someone will find it again: the script's header claim that Claude panes "already outrank" tmux is
false — measured today, tmux is the #2 process by RSS at 776 MB and oom_score ties with
the Claude panes at 668, so oom_score_adj would be the only lever in a global OOM. Weighed
against zero global OOMs in ~66 days, 86 GiB of zram, a refuted premise and 840 lines of
never-executed code, the operator chose deletion.

Two findings from this PR's own review that die with it, noted so they are not re-derived: the
re-added assert "BACKUP_TAKEN_THIS_RUN" in restore was a spelled guard of exactly the class
this PR existed to retire
, and the structural restore() test was duplicated by the three
behavioural ones.

The measured cause of the actual incident is fixed and live on both hosts
check_tmux_kill_shared_server (#1415), verified by reproducing the incident command against the
deployed hook. Nothing about this closure touches that.

@ZacxDev ZacxDev closed this Sep 9, 2026
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.

1 participant