Add the sabotage harness, sweeping a copy of the tree - #64
Conversation
…eady cite Peeled from the PR #56 branch, where this was written, because main needs it now rather than when that branch lands. PR #59 and PR #63 landed crates/windows-waitable-queues/sabotage.json and crates/windows-placement-probe/sabotage.json. Both manifests open by saying "Run with tools/run-sabotage.ps1; see tools/README-sabotage.md for the format and for why the results are read the way they are." Neither file existed here: main's tools/ held only check-baseline.ps1, check-borrow-surface.ps1 and check-encoding.ps1. So main has been shipping two manifests pointing at a harness that is not in the tree, for a crate that is published. What the harness is: it takes a manifest of deliberate defects and, for each, patches the source, runs the suite, restores the source, and records whether the suite noticed. That measures the claim a green run does not make -- that the tests would fail if the code were wrong. It exits 0 only when every entry behaves as the manifest declared, and reports MANIFEST STALE when a pattern no longer matches, so a sabotage that silently stopped applying cannot read as a pass. Deliberately not wired into CI. Every entry forces a rebuild and any caught as a hang costs the full test timeout, so the waitable-queues manifest takes about three minutes; the README says to run it when a guard is written or changed. Scope is the two files the manifests name, and nothing else. run-sabotage.ps1 takes a manifest path and references no other script, so the mutation-sweep tooling it sits beside on the source branch is not needed here and is not included. Verified on this branch, which is main plus these two files: - Both manifests enumerate: 39 entries for windows-waitable-queues and 9 for windows-placement-probe, every pattern resolving against the source already in main, so neither is stale. - Both CONTROL entries declare "survives" -- the manifest checking itself. - tools/check-encoding.ps1, which CI runs, passes: 570 files clean. CRLF on disk is required rather than incidental: .gitattributes sets *.ps1 text eol=crlf, and git stores LF, matching the three scripts already here (i/lf w/crlf). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
All three were present on the source branch and are fixed here rather than
carried into main. Each is verified by making the harness demonstrate the old
behaviour and then the new one.
1. -AllowDirty made the tool's own recovery advice destructive.
The pre-patch contents lived only in the in-memory $original, so the restore
advice was "recover it with 'git checkout -- <file>'". That is lossless only
when the file was clean -- which is exactly the precondition -AllowDirty
waives. Following it on a file carrying uncommitted work would revert to HEAD
and destroy that work, and an interruption (Ctrl+C, crash, Stop-Process) took
$original with it, leaving checkout as the only recourse.
Pre-patch contents are now written to <output>/restore/ before the file is
touched, and removed once the restore is verified, so a file left there means
an interrupted run. The failure message names that backup and says plainly not
to reach for git checkout.
2. A patch that only broke a doctest was reported as caught.
The build phase is `cargo test --no-run`, which does not build doctests, and
cargo rejects `--doc --no-run` outright ("can't skip running doc tests" --
verified on 1.98.0), so they cannot be pre-paid. A patch valid in the crate but
not in a `///` example therefore passed the build and failed the run with a
compile error, reported as `caught (suite failed, exit 101)`. That is the
weaker claim wearing the stronger one's label, and it is the specific confusion
the build/test split exists to prevent.
Such a run is now reclassified as MANIFEST DOES NOT COMPILE (a doctest would
not build), detected from rustdoc's fixed "Couldn't compile the test." marker.
Reading a transcript is a deliberate exception to judging by exit code, taken
because the exit code is 101 either way and cannot distinguish them; the marker
was verified on this toolchain to land on stdout.
Latent rather than live for the two shipped manifests -- all 48 entries are
behavioural and none change an item signature -- but nothing prevented the next
one from doing so, and it failed in the direction that looks safe.
3. A failed baseline pointed at a transcript that was stale or absent.
The abort named baseline.txt unconditionally. A baseline that fails in the
BUILD phase never reaches the test phase, so that file is never written by that
run -- and because transcripts were never cleaned between runs, the path could
still hold a green transcript from an earlier sweep, contradicting the message
pointing at it. Confirmed: a 22 KB passing baseline.txt survived a subsequent
build-phase failure.
Messages now name the transcript for the phase that actually failed
(.build.err, since cargo writes diagnostics to stderr), and stale transcripts
are cleared at startup so a named path is always from the current run.
Verified:
- Regression: the placement-probe sweep still reports 9/9 as declared, exit
0, with no leftover backups and a clean tree.
- Defect 2: a probe manifest renaming a function a doctest calls previously
reported "caught (suite failed, exit 101)" and now reports MANIFEST DOES
NOT COMPILE (a doctest would not build), exiting 1 rather than agreeing.
- Defect 3: an induced build-phase baseline failure now names
baseline.txt.build.err, which holds the real rustc error, and the
previously-green baseline.txt is gone rather than stale.
- tools/check-encoding.ps1 passes (570 files clean); the script parses clean;
check-commit-scope.ps1 -Staged -Type fix reports no cross-crate span.
README-sabotage.md is updated in step so the documented behaviour and the
implemented behaviour do not diverge.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Changes recommended
The harness has a couple of correctness/safety edge cases (cargo --no-run placement and restore-backup handling) that can yield misleading results or overwrite recovery data.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds the missing sabotage sweep harness and documentation that existing shipped sabotage.json manifests already reference, so main can actually run those sweeps as intended.
Changes:
- Add
tools/run-sabotage.ps1to apply one sabotage patch at a time, run bounded build+test phases, and record outcomes. - Add
tools/README-sabotage.mddocumenting the sweep workflow, result interpretation, and manifest format.
File summaries
| File | Description |
|---|---|
| tools/run-sabotage.ps1 | Implements the sabotage harness: patch/apply/restore loop with bounded cargo build+test phases and per-sabotage transcripts/backups. |
| tools/README-sabotage.md | Documents how to run sweeps and author manifests, matching the harness behavior and outcomes. |
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| $backupDirectory = Join-Path $OutputDirectory 'restore' | ||
| New-Item -ItemType Directory -Force -Path $backupDirectory | Out-Null | ||
|
|
There was a problem hiding this comment.
Fixed in 60e548d, and this one was a defect in the previous commit rather than in the peeled original -- that commit introduced the backup and asserted "a file left in that directory means a run was interrupted before it could restore", then did nothing to make the assertion hold. A documented claim the code does not enforce is exactly the failure mode, so thank you.
A sweep now refuses to start while any backup is present, names each file, and says what to do with it. Verified by planting one and confirming both halves:
Pre-patch backups from an earlier run are still present:
...\.scratch\sabotage\restore\pretend-interrupted.report.rs.bak
That run was interrupted before it could restore its target, so each of
these may be the only copy of the file it names -- under -AllowDirty,
including uncommitted work that is in no commit.
...
This sweep will not start while they are here, because it would overwrite them.
exit: 2
The planted file was still on disk at 21 bytes afterwards. Preserving it is the point; merely detecting it would not have been.
| $transcript = Join-Path $OutputDirectory ((($sabotage.name -replace '[^A-Za-z0-9]+', '-')) + '.txt') | ||
|
|
||
| # The pre-patch contents, on disk and not only in $original. | ||
| # | ||
| # $original is a variable, so it dies with the process: an interruption -- | ||
| # Ctrl+C, a crash, Stop-Process -- leaves the file patched with no in-memory | ||
| # copy to put back. `git checkout` recovers that only when the file was | ||
| # clean to begin with, which is precisely what -AllowDirty waives. Writing | ||
| # the backup first is what lets the restore advice below be non-destructive | ||
| # in both modes rather than only in the default one. | ||
| $backup = Join-Path $backupDirectory ` | ||
| ((($sabotage.name -replace '[^A-Za-z0-9]+', '-')) + '.' + (Split-Path -Leaf $target) + '.bak') | ||
| [System.IO.File]::WriteAllText($backup, $original, $utf8NoBom) |
There was a problem hiding this comment.
Fixed in 60e548d. Latent rather than live -- I checked both shipped manifests, and all 39 waitable-queues names and all 9 placement-probe names reduce to distinct stems -- but worth closing, and I agree the backup half is the serious one: a shared transcript loses one entry's evidence, while a shared backup path puts two different files' recovery copies at one location.
I took the "compute the stem once" half of your suggestion but not the "fail fast if the path already exists" half, deliberately. Checking for an existing path reports a stale-file symptom, and the actual problem is a manifest containing two entries that cannot be told apart. So the stems are computed and cross-checked up front, and a collision names both offenders:
Two sabotages in this manifest reduce to the same file name stem,
so they would share a transcript and a restore backup:
probe: colliding name
probe - colliding name
Both become 'probe-colliding-name'. Rename one so the two differ by more than
punctuation.
exit: 2
The loop then consumes that precomputed stem rather than re-deriving it, so the sanitiser is spelled in one place and the guard cannot end up checking a name the writes do not use. Leftover backups from an interrupted run are covered separately by the guard in the other thread.
| $build = Invoke-Bounded -CargoArgs ($CargoArgs + '--no-run') -WorkingDirectory $WorkingDirectory ` | ||
| -TranscriptPath "$TranscriptPath.build" -Seconds $BuildSeconds |
There was a problem hiding this comment.
Fixed in 60e548d. Real, and latent rather than live -- verified neither shipped manifest's testArgs contains a --, so nothing currently lands past a separator.
Agreed on the consequence, and it is worse than a wrong-argument error: with --no-run going to libtest, the build phase would actually run the tests, so the phase split that keeps ''the compiler rejected this'' apart from ''the tests caught this'' would have been measuring neither.
--no-run is now inserted before any separator via a small Add-CargoFlag helper. Exercised directly on four vectors:
| in | out |
|---|---|
test -p pkg --locked |
test -p pkg --locked --no-run |
test -p pkg -- --nocapture |
test -p pkg --no-run -- --nocapture |
test -- --test-threads=1 |
test --no-run -- --test-threads=1 |
-- --nocapture |
--no-run -- --nocapture |
The last case is why the helper uses Select-Object rather than a range: a -- at index 0 makes the obvious spelling \[0..(\-1)] into [0..-1], and a negative index counts from the end in PowerShell, so it would have silently reversed the vector instead of yielding nothing.
…indings
All three are latent rather than live against the two shipped manifests --
verified: 39/39 and 9/9 sabotage names reduce to distinct stems, and neither
manifest's testArgs contains a `--` separator. They are fixed anyway, because
two of them can destroy the recovery copy the previous commit just introduced,
and the third silently measures the wrong thing.
1. A sweep would overwrite an interrupted run's only recovery copy.
The previous commit wrote pre-patch contents to <output>/restore/ and claimed a
file left there means an interrupted run. Nothing acted on that claim: the next
sweep would overwrite the backup it tells the reader to go and find, and under
-AllowDirty that copy may hold uncommitted work that is in no commit. A sweep
now refuses to start while any backup is present, names each one, and says what
to do with it -- which is what makes the claim load bearing rather than
decorative.
2. Two sabotage names could collide into one transcript and one backup.
Both file names come from the sabotage's name with runs of non-alphanumerics
collapsed to a dash, so "a: b" and "a - b" both become "a-b". Sharing a
transcript loses one entry's evidence; sharing a backup path is worse, putting
two different files' recovery copies at one location. The stem is now computed
once, up front, and a collision is rejected by naming the two entries that
cannot be told apart -- rather than being defended against at each write, which
would report a stale-file symptom instead of the manifest's actual problem. The
loop consumes that precomputed stem, so the sanitiser is spelled once and the
check cannot end up guarding a name the writes do not use.
3. `--no-run` was appended after any `--`, handing it to the test binary.
Everything after `--` belongs to libtest, not cargo. A manifest whose testArgs
ended in test-binary arguments would have had `--no-run` appended past the
separator, so the build phase would have run the tests instead of building
them, then failed for a reason unrelated to the sabotage -- and the phase split
that keeps "the compiler rejected this" apart from "the tests caught this"
would have been measuring neither. The flag is now inserted before the
separator.
Verified:
- Flag insertion, exercised directly on four vectors: no separator appends;
`test -p pkg -- --nocapture` yields `test -p pkg --no-run -- --nocapture`;
and a `--` at index 0 is handled, which is the case that matters because
the obvious range spelling ($args[0..-1]) counts backwards in PowerShell
and would silently reverse the vector.
- Collision guard: a probe manifest with "probe: colliding name" and
"probe - colliding name" is rejected, naming both and the shared stem.
- Leftover guard: with a planted backup, the sweep refuses and the planted
file is still on disk afterwards -- the point being that it is preserved,
not merely detected.
- Regression: the placement-probe sweep still reports 9/9 as declared, exit
0, no leftover backups, clean tree.
- Script parses clean; check-encoding.ps1 passes (570 files);
check-commit-scope.ps1 -Staged -Type fix reports no cross-crate span.
README-sabotage.md is updated in step, so the documented behaviour and the
implemented behaviour do not diverge.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Changes recommended
The harness has two concrete correctness hazards (git dirtiness check pathspec and a reserved transcript-name collision) that should be fixed before landing.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
tools/run-sabotage.ps1:405
- Transcript file names are derived from a sanitized sabotage name stem, but
baseline.txtis also written into the same directory. A sabotage named (or sanitizing to)baselinewould collide with the baseline transcript and corrupt the run's evidence.
Consider rejecting reserved stems like baseline during the preflight stem-collision check.
foreach ($sabotage in $selected) {
$stem = $sabotage.name -replace '[^A-Za-z0-9]+', '-'
if ($stemOwners.ContainsKey($stem)) {
Exit-WithMessage (@(
"Two sabotages in this manifest reduce to the same file name stem,"
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Lite
| $target = Join-Path $sourceRoot $sabotage.file | ||
| if (-not (Test-Path -LiteralPath $target)) { | ||
| Exit-WithMessage "Sabotage '$($sabotage.name)' names a file that does not exist: $target" 2 | ||
| } | ||
| if (-not $AllowDirty) { | ||
| $status = git -C $repoRoot status --porcelain -- $target | ||
| if ($status) { |
There was a problem hiding this comment.
Fixed in 8a2797b, though not for the stated reason -- and the difference matters, so here is the evidence.
The reported mechanism does not reproduce. Git resolves an absolute pathspec against the repository root; it does not require a repo-relative one. With crates/windows-waitable-queues/src/spsc.rs modified, the exact pathspec this script builds matches, from the repo root and from an unrelated working directory alike:
target pathspec : Q:\github\windows-threadpool-sys\crates\windows-waitable-queues\src\spsc.rs
=== does git match the absolute pathspec? ===
MATCHED -> ' M crates/windows-waitable-queues/src/spsc.rs'
=== same query from an unrelated CWD ===
MATCHED -> ' M crates/windows-waitable-queues/src/spsc.rs'
(git 2.55.0.windows.4. Join-Path also normalises the manifest's forward slashes, so the mixed-separator form never actually reaches git.) There was already evidence for this: the guard fired correctly during the previous round's testing, which it could not have done if absolute paths failed to match.
But steelmanning it found a real defect one layer down, which is now fixed. The script read git's stdout and never checked its exit code. A query that genuinely fails prints to stderr and leaves $status empty -- indistinguishable from "the file is clean". The reachable case is a manifest whose root resolves outside the repository, since root may point anywhere:
Could not determine whether this sabotage target is clean in git:
Q:\github\outside-repo-probe.txt
git exited 128 and said:
fatal: ... is outside repository at 'Q:/github/windows-threadpool-sys'
Refusing to proceed: a failed check is not a clean result, and
treating it as one is how a sweep overwrites uncommitted work.
Before this commit that same case passed the guard silently and went on to patch a file whose cleanliness was never established. So: right instinct, wrong mechanism, real bug. Thanks.
Second PR #64 review round. One finding taken as reported; the other's stated mechanism did not reproduce, but steelmanning it found a real defect nearby, which is what this fixes. 1. A sabotage named "baseline" would overwrite the baseline transcript. Transcripts are named from the sabotage's stem, and baseline.txt plus its phase variants are written into the same directory before the sweep starts. A stem of `baseline` would therefore land on top of the record that the suite was green before any patching -- which is the premise every result in the run depends on. Rejected up front alongside the existing collision check, case-insensitively, because the sanitiser preserves case while the filesystem does not. 2. A git dirtiness query that FAILED was indistinguishable from "clean". The review reported this as absolute pathspecs failing to match. That does not reproduce: git resolves an absolute pathspec against the repository root, and it matches from any working directory. Verified directly -- with crates/windows-waitable-queues/src/spsc.rs modified, the exact pathspec this script builds returns " M crates/windows-waitable-queues/src/spsc.rs" both from the repo root and from an unrelated directory, on git 2.55.0.windows.4. The guard firing correctly in the previous round's testing was already evidence of this. The real defect is one layer down. The script took git's stdout and never looked at its exit code, so any failed query -- a manifest `root` resolving outside the repository is the reachable case, since `root` may point anywhere -- sent its diagnostic to stderr, left $status empty, and read as "this file is clean". The sweep would then patch a file whose cleanliness was never established. A guard that cannot distinguish "clean" from "I could not check" is not a guard, so the exit code is now inspected and a failed query aborts. Verified: - Reserved stem: a manifest entry named "baseline" is rejected, naming the stem and the file it would collide with. (Note "Baseline!" sanitises to "Baseline-" and is correctly NOT rejected -- only an exact collision is.) - Failed query: a manifest whose root resolves outside the repository now aborts with git's own message and exit 128 quoted back. Before this change that same case passed the guard silently. - Regression: the placement-probe sweep still reports 9/9 as declared, exit 0, no leftover backups, clean tree. - Script parses clean; check-encoding.ps1 passes (570 files); check-commit-scope.ps1 -Staged -Type fix reports no cross-crate span. README-sabotage.md is updated in step, so the documented behaviour and the implemented behaviour do not diverge. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Also addressed the suppressed comment from this round ( Transcripts are named from the sabotage's stem, and Rejected up front alongside the existing collision check, case-insensitively, since the sanitiser preserves case while the filesystem does not: One note from testing it: |
There was a problem hiding this comment.
🟡 Changes recommended
tools/run-sabotage.ps1 includes PowerShell 7-only syntax (? :) that will fail to parse in Windows PowerShell 5.1, so the tool may not run reliably in all expected environments.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Lite
| if ($process.WaitForExit($Seconds * 1000)) { | ||
| return [pscustomobject]@{ Outcome = ($process.ExitCode -eq 0 ? 'passed' : 'failed'); Code = $process.ExitCode } | ||
| } |
There was a problem hiding this comment.
Fixed in f987320. Confirmed, and worse than reported -- then acting on it uncovered a second defect behind it that was worse still.
The reported defect, confirmed. It is not just that line: the ternary makes the whole script unparseable under 5.1, so it fails before line 1 executes. Measured with 5.1's own parser (5.1.26100.9168):
PS 5.1 PARSE ERRORS: 9
line 191: Unexpected token '?' in expression or statement.
line 191: Missing closing ')' in expression.
line 191: The hash literal was incomplete.
... 6 more cascading
You were right about the sibling scripts too -- check-baseline.ps1, check-borrow-surface.ps1 and check-encoding.ps1 contain zero PowerShell 7 operators, so this one was the odd man out. Rewritten as an if/else.
But fixing only that would have been worse than leaving it. With the script parseable, I ran the full sweep under 5.1 and the baseline failed -- while its own transcript said Finished `test` profile in 2.36s. The build had succeeded and been reported as failed.
Cause: a Start-Process -PassThru Process object on 5.1 does not cache the native handle, so once the process exits .ExitCode reads back $null. Measured directly:
--- WITHOUT touching .Handle --- ExitCode -> [] isNull=True
--- WITH .Handle touched first --- ExitCode -> [0] isNull=False
$null -eq 0 is false, so every phase would classify as failed -- meaning the baseline can never pass and, had it passed, every sabotage would report caught. That is a clean bill of health that proves nothing, which is the one result this harness exists to make impossible. The ternary at least failed loudly; repairing it alone would have converted a parse error into a silent wrong answer.
Reading and discarding $process.Handle before the wait keeps the handle alive. It is a no-op on PowerShell 7.
Verified on both shells: parses clean under 5.1 and 7; full sweep under 5.1 now reports 9/9 as declared, exit 0, no leftover backups, clean tree (same invocation aborted at the baseline before this commit); full sweep under 7 unchanged at 9/9.
The README now states 5.1/7 support and why the difference bites here, so the constraint reads as deliberate to the next person editing this file. Good catch -- this one paid off well beyond the line it pointed at.
Third PR #64 review round. The reported defect was real, and acting on it uncovered a second one behind it that was strictly worse. 1. A PowerShell 7 ternary made the script unparseable on 5.1. `Invoke-Bounded` computed its outcome with `? :`, which is a PARSE error under Windows PowerShell 5.1 -- so the whole script failed before its first line ran, on the shell `powershell.exe` still starts by default. Measured: 9 cascading parse errors, all from that one expression. The three sibling scripts in this directory use no PowerShell 7 syntax, so this one was also the odd man out. Rewritten as an if/else. 2. Behind it: Start-Process exit codes are $null on 5.1, so every phase "failed". Fixing the parse error alone would have been worse than leaving it. A Process object from `Start-Process -PassThru` on 5.1 does not cache the native handle; once the process exits the handle is released and `.ExitCode` reads back $null -- for a process that exited 0 exactly as for one that failed. `$null -eq 0` is false, so every phase classified as 'failed'. That is not a loud failure. The baseline can never pass, and had it passed, every sabotage would have reported `caught` -- a clean bill of health that proves nothing, which is the single result this harness exists to make impossible. The ternary was failing loudly; repairing only it would have converted that into a silent wrong answer. Reading and discarding `$process.Handle` before the wait keeps the handle alive so the exit code survives. A no-op on PowerShell 7, which caches it itself. Measured rather than assumed: under 5.1, `cmd /c exit 0` reports ExitCode $null without that line and 0 with it. Found by actually running the suite under 5.1 rather than by reading the diff -- the first 5.1 run reported the baseline as build-failed while its own transcript said "Finished `test` profile in 2.36s", which is what pointed at the exit code rather than at the build. Verified: - Parses clean under both 5.1 and 7. - FULL sweep under 5.1: 9/9 behaved as declared, exit 0, no leftover backups, clean tree. Before this commit the same invocation aborted at the baseline. - FULL sweep under 7: unchanged, 9/9, exit 0. - check-encoding.ps1 passes (570 files); check-commit-scope.ps1 -Staged -Type fix reports no cross-crate span. README-sabotage.md now states 5.1/7 support and why the difference matters here, so the next change to this file knows the constraint is deliberate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Changes recommended
The new harness currently has a couple of operational footguns (over-broad output-dir cleanup and allowing out-of-repo mutation under -AllowDirty) that should be tightened before approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
tools/run-sabotage.ps1:348
OutputDirectorycleanup deletes all files in the directory (Get-ChildItem ... -File | Remove-Item). Since-OutputDirectoryis user-supplied, this can unintentionally remove unrelated files that aren’t transcripts. The docs only promise stale transcripts are cleared, so it’s safer to restrict deletion to the transcript naming convention this script writes (".txt") instead of every file in the directory.
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Lite
| foreach ($sabotage in $selected) { | ||
| $target = Join-Path $sourceRoot $sabotage.file | ||
| if (-not (Test-Path -LiteralPath $target)) { | ||
| Exit-WithMessage "Sabotage '$($sabotage.name)' names a file that does not exist: $target" 2 | ||
| } | ||
| if (-not $AllowDirty) { |
There was a problem hiding this comment.
Fixed in bc689b8. Correct, and this one is a defect this PR introduced rather than inherited -- my round-2 exit-code check only guarded the -not $AllowDirty branch, so containment was never checked at all under the switch.
Agreed on the framing: -AllowDirty is documented as waiving the cleanliness requirement, and it was in effect waiving the boundary too. Those are different things, and the boundary is the one with no git checkout behind it. The check now runs for every target on every run, before the dirtiness query so an out-of-repo path is reported as what it is rather than as a git pathspec failure:
=== containment, default (no -AllowDirty) ===
Sabotage 'probe outside repo' names a file outside this repository:
Q:\github\outside-repo-probe.txt
...refused whether or not -AllowDirty was passed -- that switch
waives the cleanliness check, not the boundary.
exit: 2
=== containment, WITH -AllowDirty (the reported gap) ===
[identical output]
exit: 2
=== was the out-of-repo file left untouched? ===
nonexistent
Before this commit the second case patched that file. The comparison is against a canonicalised root with a trailing separator, so a sibling directory whose name merely starts with the root's (...\repo-notes vs ...\repo) cannot pass as inside it.
…ystanders Fourth PR #64 review round. Both findings are defects this PR introduced rather than ones it inherited, in the guards added for the first and second rounds. 1. -AllowDirty widened what could be modified, not just what was checked. The switch is documented as waiving the CLEANLINESS requirement. It was also, in effect, waiving containment: with it passed, the git guard was skipped entirely and the tool would patch whatever path a manifest's `root` resolved to, including one outside the repository. Those are two different things, and the second is the one with no `git checkout` behind it. Containment is now checked for every target on every run, before the dirtiness query so an out-of-repo path is reported as what it is rather than as a git pathspec failure. The comparison is against a canonicalised repository root with a trailing separator, so a sibling directory whose name merely starts with the root's cannot pass as being inside it. 2. Transcript cleanup deleted every file in a caller-supplied directory. The stale-transcript clearing added in the second round was `Get-ChildItem $OutputDirectory -File | Remove-Item`. $OutputDirectory is a parameter, so pointing it anywhere holding other files meant the sweep took them -- a destructive surprise well outside anything the tool documents. It now removes precisely the paths this invocation can write: `baseline` plus each selected sabotage's stem, times the four transcript suffixes. That meets the original goal exactly -- a transcript named in an error message is always from this run or absent -- with no collateral. It is also stricter than filtering on the `*.txt*` naming convention, which would still have taken an unrelated `notes.txt`. Moved after the -List exit and after target validation, so neither listing a manifest nor being rejected by one deletes anything. Verified: - Containment: a manifest rooted outside the repository is refused identically with and without -AllowDirty, and the out-of-repo file is afterwards byte-for-byte untouched. Before this commit, -AllowDirty patched it. - Cleanup: with `unrelated-notes.txt`, `important.dat` and a stale `baseline.txt` planted in the output directory, `-List` deletes nothing, and a full sweep replaces only `baseline.txt` while both bystanders survive with their contents intact -- including the one that matches `*.txt*`. - Full sweep on PowerShell 7 and on Windows PowerShell 5.1: 9/9 behaved as declared, exit 0, no leftover backups, clean tree. Parses clean on both. - check-encoding.ps1 passes (570 files); check-commit-scope.ps1 -Staged -Type fix reports no cross-crate span. README-sabotage.md is updated in step: the boundary is stated as not waivable by -AllowDirty, and the cleanup is described as covering only this run's own transcripts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Also fixed the suppressed comment from this round ( I went further than the suggested Verified by planting bystanders, including one that It also moved after the Both fixes re-verified with a full sweep on PowerShell 7 and Windows PowerShell 5.1: 9/9 as declared, exit 0, no leftover backups, clean tree. |
There was a problem hiding this comment.
🔵 Needs a closer look
The new harness should handle malformed/invalid manifests via controlled Exit-WithMessage paths (parse + required-field validation) to match its stated behavior and avoid hard-to-diagnose failures.
Review details
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
tools/run-sabotage.ps1:338
- Manifest JSON parse errors currently surface as a terminating PowerShell error (from
ConvertFrom-Json) rather than a controlledExit-WithMessageexit code. That contradicts the script's own intent to report a bad manifest without aborting the caller's session/script, and it also makes the failure mode less actionable (no consistent exit code 2).
This issue also appears on line 376 of the same file.
tools/run-sabotage.ps1:378
- The script assumes
packageandsabotagesare present in the manifest, but doesn't validate them. Ifpackageis missing (andtestArgsisn't provided), the baseline will runcargo test -p --lockedand fail later with a cargo error; ifsabotagesis missing/empty, the script reports a misleading "No sabotage ... matches name filter" even when the manifest is malformed. Validating these fields up front makes failures deterministic and actionable.
$package = $spec.package
$testArgs = @('test', '-p', $package, '--locked')
if ($spec.PSObject.Properties.Name -contains 'testArgs' -and $spec.testArgs) {
- Files reviewed: 2/2 changed files
- Comments generated: 0 new
- Review effort level: Lite
Fifth PR #64 review round. Both findings taken, and the class turned out to be six failure modes rather than the two reported, so all six are fixed together. The script sets $ErrorActionPreference = 'Stop', so an unguarded Resolve-Path or ConvertFrom-Json failure raised a terminating error that propagated out and took the caller's session with it. That is exactly what Exit-WithMessage exists to prevent -- its own comment says so, and has since the original file -- so this was a contract the code stated and did not keep. It also surfaced as a PowerShell stack frame naming a line of this script when the thing that was wrong was the manifest, which is the wrong direction to send a reader. Measured before, across both invocation paths: missing manifest file -List=1 sweep=1 raw Resolve-Path error malformed JSON -List=1 sweep=1 raw ConvertFrom-Json error missing package -List=1 sweep=1 "The property 'package' cannot be found" missing sabotages -List=1 sweep=1 "The property 'sabotages' cannot be found" empty sabotages -List=0 sweep=2 -List reported SUCCESS on a manifest with nothing in it bad root -List=1 sweep=1 raw Resolve-Path error And after: every one is exit 2 on both paths, with a message naming the file and the field. 2 is the code this script already used for "the manifest or the invocation is wrong", as distinct from 1 for "a sabotage did not behave as declared", so the three outcomes a caller cares about are now actually distinguishable. An empty `sabotages` is reported as an empty manifest rather than as "no sabotage matches name filter '*'". The old wording sent the reader to look at a filter they had not set. Per-sabotage fields are validated too, for the same reason: every field in the README's format table is required, and under Set-StrictMode a missing one surfaced as "The property 'x' cannot be found" against a line of this script. `expect` is additionally checked to be `caught` or `survives`, because an unrecognised value is not inert -- it is compared for equality when scoring, so it could never match and the entry would read as misbehaving on every run whatever the suite did. The validator deliberately uses the same case-insensitive comparison the scorer uses, so it cannot reject a manifest that would in fact score correctly: `expect: "Caught"` is accepted by both, `expect: "maybe"` by neither. Verified both ways. Verified: - All six original modes, plus a bad `expect` and a missing per-sabotage field, now exit 2 on both the -List and sweep paths, each with an actionable message. A valid manifest still exits 0 on -List. - Both shipped manifests satisfy every rule now enforced -- 48 entries, zero violations -- so nothing that already works is rejected. - Full sweep on PowerShell 7 and Windows PowerShell 5.1: 9/9 behaved as declared, exit 0, no leftover backups, clean tree. Parses clean on both. - check-encoding.ps1 passes (570 files); check-commit-scope.ps1 -Staged -Type fix reports no cross-crate span. README-sabotage.md now documents the exit codes as a table and states that a bad manifest is always a reported exit 2, never a raw PowerShell error. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Both findings from this round fixed in 7886a60. Taken as reported, and the class turned out to be six failure modes rather than two, so all six are fixed together. The framing was right and is worth stating sharply: this was a contract the code stated and did not keep. Measured before, across both invocation paths: That Your point about the misleading filter message is handled specifically: an empty I extended it to per-sabotage fields for the same reason -- every field in the README's format table is required, and under One deliberate subtlety: the validator uses the same case-insensitive comparison the scorer uses, so it cannot reject a manifest that would in fact score correctly. Verified: all eight modes exit 2 on both paths with actionable messages; a valid manifest still exits 0 on |
There was a problem hiding this comment.
🟡 Changes recommended
The new harness has a couple of confirmed behavioral/reporting issues (including a -List side-effect/behavior mismatch and an unhandled throw path) that should be corrected before approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
tools/run-sabotage.ps1:159
Get-RepoRootusesthrowwhen not inside a git repository. With$ErrorActionPreference = 'Stop'this becomes an unhandled terminating error (stack trace) and can abort a calling script/session, which conflicts with the script's own goal of reporting invocation/manifest problems viaExit-WithMessagewith a specific exit code.
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Lite
| if (-not $OutputDirectory) { $OutputDirectory = Join-Path $repoRoot '.scratch\sabotage' } | ||
| New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null | ||
|
|
||
| # Pre-patch copies live in their own subdirectory, so the clearing of stale | ||
| # transcripts below cannot reach them, and so a leftover here is unambiguous. | ||
| $backupDirectory = Join-Path $OutputDirectory 'restore' | ||
| New-Item -ItemType Directory -Force -Path $backupDirectory | Out-Null | ||
|
|
||
| # A leftover backup means the previous run did not get to restore its target, | ||
| # so that copy may be the only surviving version of the file -- and under | ||
| # -AllowDirty it may hold uncommitted work that exists nowhere else. Refusing | ||
| # to start is what makes the "a file here means an interrupted run" claim load | ||
| # bearing: without it, the next sweep would quietly overwrite the evidence it | ||
| # tells the reader to look for. | ||
| $leftover = @(Get-ChildItem -LiteralPath $backupDirectory -File -ErrorAction SilentlyContinue) | ||
| if ($leftover.Count -gt 0) { | ||
| Exit-WithMessage (@( | ||
| "Pre-patch backups from an earlier run are still present:" | ||
| ($leftover | ForEach-Object { " $($_.FullName)" }) | ||
| "That run was interrupted before it could restore its target, so each of" | ||
| "these may be the only copy of the file it names -- under -AllowDirty," | ||
| "including uncommitted work that is in no commit. Compare each against its" | ||
| "target and copy it back if the target is still sabotaged, then delete it." | ||
| "This sweep will not start while they are here, because it would overwrite" | ||
| "them." | ||
| ) -join "`n") 2 | ||
| } |
There was a problem hiding this comment.
Fixed in ca638c0 -- and this one is the sharpest catch of the six rounds, because it is a claim I wrote one round ago that the code did not keep.
Round four moved the transcript clearing after the -List exit, and I added to the README: "Listing a manifest with -List writes and deletes nothing." That was false. The output directory and its restore/ subdirectory were still created before the listing path, and the leftover-backup guard also ran before it -- so -List both wrote to disk and could be refused outright because an earlier run had been interrupted. As you say, that is exactly when someone wants to read the manifest.
All of that setup now happens after the -List exit. Verified all three properties:
=== A. -List must create NOTHING ===
before: .scratch\sabotage exists = False
-List exit=0
after : .scratch\sabotage exists = False
=== B. -List must work despite a leftover backup ===
[manifest lists normally]
-List exit=0
=== C. a real sweep must still refuse ===
Pre-patch backups from an earlier run are still present: ...
sweep exit=2
backup preserved = True
The guard's message now also mentions that -List still works meanwhile.
Sixth PR #64 review round. Both findings are defects this PR introduced, and the first is a claim I wrote one round ago that the code did not keep. 1. -List had side effects and could be refused. Round four moved the transcript clearing after the -List exit and the README gained the line "listing a manifest with -List writes and deletes nothing". That was not true. The output directory and its restore/ subdirectory were still created before the listing path, so -List wrote to disk; and the leftover-backup guard also ran before it, so -List could be refused outright because an earlier run had been interrupted -- which is precisely the moment someone wants to read the manifest. All of that setup now happens after the -List exit. Verified: with no output directory present, -List leaves none behind; with a backup planted, -List still lists and exits 0 while a real sweep still refuses with exit 2 and the backup still on disk afterwards. 2. Get-RepoRoot threw instead of reporting. The same defect class as the fifth round's manifest failures, missed because that sweep looked for Resolve-Path and ConvertFrom-Json and not for `throw`. Under $ErrorActionPreference = 'Stop' it printed a stack trace and propagated into the caller. Now a reported exit 2 saying what to do about it. Swept again for the whole class: this was the only remaining `throw` in the file. Verified: - -List creates nothing, and is not blocked by a leftover backup, while the sweep still is. - Run from a directory outside any git repository, the script now prints three lines of explanation and exits 2 rather than a stack trace. - Full sweep on PowerShell 7 and Windows PowerShell 5.1: 9/9 behaved as declared, exit 0, no leftover backups, clean tree. Parses clean on both. - check-encoding.ps1 passes (570 files); check-commit-scope.ps1 -Staged -Type fix reports no cross-crate span. README-sabotage.md now states the -List guarantee in the form the code actually implements -- inert, and never blocked by a pending recovery -- and lists running outside a repository among the reported exit-2 conditions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Both suppressed comments from this round are also fixed in 7d60e29.
So Verified for
with the substitution case unchanged. |
There was a problem hiding this comment.
🔵 Needs a closer look
Manifest validation still allows some invalid find/replace shapes to degrade into confusing outcomes or unintended destructive patches instead of failing fast with a clear exit-2 error.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
tools/run-sabotage.ps1:469
- Manifest-entry validation only checks that
find/replaceproperties exist, not that they have a valid shape/value. In particular, an empty/nullfindproduces a confusingMANIFEST STALE: pattern found ... timesresult, and anullreplaceis currently treated like deletion (because-joinyields an empty string), which is likely to be an accidental destructive patch.
Consider rejecting empty find and null replace up-front with an exit-2 manifest error so invalid manifests fail fast with a clear message.
- Files reviewed: 2/2 changed files
- Comments generated: 0 new
- Review effort level: Lite
… files
The premise was wrong, and most of this PR's review findings were consequences
of it rather than independent bugs.
Patching the developer's own files means every sabotage needs a backup, a
restore, a check that the restore worked, a guard against running on a dirty
tree, a containment check, and a recovery path for when any of that is
interrupted. Each is a chance to damage work that was never in a commit. Of the
eighteen defects found across seven review rounds on this PR, eight were in that
machinery -- and every data-loss risk was. The last one was the sharpest: a
byte-exact restore via File.Copy carried the original mtime, so cargo judged the
crate up to date against an artifact built from PATCHED source and left the
sabotaged binary in the developer's build cache.
The sweep now runs against a copy, the way cargo-mutants does. The real tree is
read and never written.
WHAT THIS DELETES. The backup files and their directory, the restore, the
restore verification, the leftover-backup guard, the dirty-tree guard, the git
exit-code check that guard needed, the -AllowDirty switch and the whole notion
of waiving cleanliness, and exit code 3. What remains is the part that was
always the point: manifest validation, exactly-once matching, the build/test
phase split, doctest reclassification, and transcripts.
WHAT IT ADDS. Sync-Tree, which refreshes the copy from the working tree at the
start of a run. Files are enumerated by git -- tracked plus untracked-not-
ignored -- so target/ (28 GB here) and the scratch directory stay out without a
second exclusion list to drift from .gitignore. Only files whose contents differ
are copied, which is what keeps the copy's build warm; files the source no
longer has are deleted, so a rename cannot leave a stale twin to compile.
The copy builds into its OWN target directory, set through CARGO_TARGET_DIR
rather than --target-dir so a manifest's testArgs cannot redirect a sabotaged
build into the developer's real one.
BEHAVIOUR CHANGES, both improvements:
- A dirty tree is now swept exactly as it stands. The copy is made from the
working tree, not from a commit, so uncommitted edits are what get measured
-- usually the code whose guards you are asking about. -AllowDirty is gone
because there is nothing left to waive.
- Getting a bug in this tool wrong now costs a scratch directory, not work.
Verified:
- The real tree is untouched: all 570 tracked files fingerprinted by content
hash AND mtime before and after a sweep, sets identical.
- The copy is byte-identical to the real tree: 570 files compared, 0 differ.
- placement-probe, 9 entries: 9/9 as declared, exit 0, on PowerShell 7 and on
Windows PowerShell 5.1. Parses clean on both.
- waitable-queues, 39 entries: 34 as declared; the other 5 are MANIFEST STALE
against main's own source and are a pre-existing defect in main, not a
regression here -- their patterns occur 0 times in the real tree, measured
directly, and the copy is proven identical to it. Reported separately.
- Uncommitted work is swept: an uncommitted edit that breaks a test turns the
baseline red, proving the copy reflects the working tree rather than HEAD.
- Cold 69s, warm 49s for the 9-entry manifest; scratch is 7 MB of tree and
379 MB of target.
- check-encoding.ps1 passes (570 files).
README-sabotage.md is rewritten to match: Safety now describes the copy rather
than a backup protocol, and its timing claim is corrected -- the 39-entry sweep
measures 853s, of which twelve hangs at the default 60s bound are 720. It had
said "about three minutes", which is not reachable at that bound.
Marked ! because -AllowDirty and exit code 3 are removed. Nothing in the
repository passes either; both shipped manifests are unaffected.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🔵 Needs a closer look
It introduces a large new execution harness (process management + filesystem mutation in a working copy) that should get final human validation on Windows in addition to the small doc/message fixes noted.
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 2
- Review effort level: Lite
| | `expect` | yes | `caught` for a defect, `survives` for a control. | | ||
| | `why` | yes | What breaks, and why the suite should or should not notice. This is the part a future reader needs; the patch only says what changed. | | ||
| | `find` | yes | Lines to replace. Must match **exactly once**. | | ||
| | `replace` | yes | Replacement lines. `[""]` deletes. | |
| "Two sabotages in this manifest reduce to the same file name stem," | ||
| "so they would share a transcript and a restore backup:" | ||
| " $($stemOwners[$stem])" | ||
| " $($sabotage.name)" | ||
| "Both become '$stem'. Rename one so the two differ by more than" | ||
| "punctuation." |
…thing
Found by the harness this PR adds, on its first full run against main's own
source: five of the 39 entries reported MANIFEST STALE, meaning their `find`
patterns occurred zero times and those sabotages were never applied at all.
That is the finding the tool exists to make visible. Five guards on a published
crate were silently unverified, and a green sweep would never have said so --
which is why a pattern that no longer matches is reported as a manifest problem
rather than counted as caught.
Not a regression from this PR: the sweep's working copy was confirmed
byte-identical to the real tree (570 files, 0 differing) and the patterns were
then measured directly against the real files, with the same result.
The manifest had drifted from the source in specific ways:
the final drain returns nothing spsc's finish() now calls take(),
not pop()
a best-effort push may take a reserved the room check now compares against
slot u64::from(reserved)
reserve does not check for room the claim is now read through
L::Word::load rather than a direct
atomic load
redeeming does not release the claim_word and its position helpers
reservation are now generic over the layout L,
dropping a reservation does not return and advance::<L>() replaced
the slot wrapping_add(1)
Each pattern is re-anchored to what the source actually says, and each keeps its
original intent -- the `why` fields are unchanged, because what these sabotages
are meant to break has not changed, only how the code spells it.
Verified: all 39 patterns now match exactly once, and a full sweep reports 39/39
behaving as declared, exit 0. The five are genuinely CAUGHT rather than merely
matching -- four by test failures and one by a hang -- so the guards they
exercise are real.
Typed chore rather than fix: this repairs test tooling that ships inside the
crate directory, changes nothing a consumer of the crate can observe, and should
not cut a release.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…rceable
Two changes, and the second is a correctness bug the first exposed.
1. The bound is derived from the baseline instead of fixed at 60 seconds.
Hangs dominate a sweep's wall clock -- 720 of 853 seconds on the 39-entry
waitable-queues manifest -- so the bound is what moves the total. A fixed
default is wrong in both directions: too tight on a loaded machine or a large
suite, where a slow-but-finite run is scored as CAUGHT and quietly inflates the
result; too loose on a fast one, where every hang pays the difference.
The baseline already runs the unmodified suite first, so its measured TEST
duration is the best available statement of how long this suite legitimately
takes on this machine. The bound is now
`max(-TimeoutFloorSeconds, -TimeoutMultiplier x baseline)`, default
`max(15, 3x)`, and the sweep prints what it derived and why.
Derived from the test phase alone, not build-plus-test: the bound governs test
execution, and the build is the volatile part -- the first run against a fresh
copy pays a cold build, which would inflate the bound on the one run least able
to judge what is normal. Measured: 25s build-plus-test cold against 3s for the
tests.
Raising it where it is needed, rather than everywhere, is now expressible three
ways: -TimeoutSeconds for a run, a manifest-level `timeoutSeconds` for a suite,
and a per-sabotage `timeoutSeconds` for the one entry that legitimately runs far
longer than the rest. A per-entry value only ever RAISES the sweep's bound,
because the reason to lower one is speed and the cost of being wrong is a false
`caught`.
2. WaitForExit(ms) did not enforce the bound at all.
Found while measuring the above: a sweep sat on a single hung sabotage for 31
MINUTES against a 60-second bound. The kill was never reached, and the run
resumed the moment that test binary was killed by hand -- so this was not a slow
sweep, it was a stalled one, and a tool whose whole job is to detect hangs must
not be hangable by one.
cargo's stdout and stderr are redirected to files and the test binary cargo
spawns inherits those handles. .NET's timed WaitForExit waits for the redirected
streams to reach EOF as well as for the process, so the wait outlives the bound
for as long as the grandchild holds the handles -- for a hung test, forever.
Replaced with a deadline poll on HasExited, which only asks whether the process
object is signalled and never touches the streams, so it cannot overrun.
Verified:
- 39-entry waitable-queues manifest: 39/39 as declared, exit 0, in 335s
against 853s before, with no stray cargo or test processes left behind and
the real tree untouched.
- 9-entry placement-probe manifest: 9/9 as declared, exit 0, on PowerShell 7
and Windows PowerShell 5.1. Parses clean on both.
- The derived bound is reported: "Baseline is green in 4s. Hang bound: 15s
(3x the 4s baseline, floor 15s)."
- -TimeoutMultiplier 0, -TimeoutFloorSeconds 0 and an explicit
-TimeoutSeconds 0 are each rejected with exit 2; a manifest or per-entry
timeoutSeconds below 1 likewise.
- check-encoding.ps1 passes (570 files).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Changes recommended
The new README has a couple of concrete doc/schema mismatches (notably a fixed “60s” claim vs derived default timeout behavior) that should be corrected before merging.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 2
- Review effort level: Lite
|
|
||
| **Build and test are timed separately, and the split is what keeps the test | ||
| bound tight.** A hang is what a lost wakeup looks like and it happens during | ||
| test execution, so that phase gets a short bound (60s). A build is merely slow |
| | `timeoutSeconds` | no | Hang bound for the whole sweep, overriding the derived one. `-TimeoutSeconds` still wins over it. | | ||
| | `name` | yes | Unique; also the `-Name` filter key and the transcript filename. | | ||
| | `file` | yes | Source to patch, relative to `root`. | | ||
| | `expect` | yes | `caught` for a defect, `survives` for a control. | | ||
| | `why` | yes | What breaks, and why the suite should or should not notice. This is the part a future reader needs; the patch only says what changed. | | ||
| | `find` | yes | Lines to replace. Must match **exactly once**. | | ||
| | `replace` | yes | Replacement lines. `[""]` deletes. | | ||
| | `timeoutSeconds` | no | Hang bound for this entry alone, when it legitimately runs far longer than the rest. Only ever raises the sweep's bound. | |
…own copy
Four findings from a review of the copy-based rewrite. The first is the serious
one, and it is a defect that rewrite introduced.
1. CARGO_TARGET_DIR was set on the CALLER's session, and never restored.
A .ps1 runs in the hosting PowerShell process, so `$env:CARGO_TARGET_DIR = ...`
mutated the developer's session and outlived the script -- every later cargo
command in that session, in this repository or any other, would have built into
this tool's scratch directory. Measured: the variable is still set in the
calling session after the script exits. That is precisely the harm the rewrite
was for, arriving by a different route, and it contradicted both the README's
"your build cache is not touched either" and the script's claim that the worst
case is a scratch directory to delete.
The comment justifying the environment variable was also backwards. It said
setting it there kept it out of the argument vector "so a manifest cannot
accidentally aim a sabotaged build at the developer's real target directory".
Cargo's precedence is CLI flag OVER environment variable, so a `testArgs`
carrying --target-dir would have overridden it, not the reverse. Measured that
too: with CARGO_TARGET_DIR set and --target-dir passed, only the CLI directory
was created.
The directory is now passed as --target-dir on each cargo command line, which
touches nothing outside the process it launches and wins over any
CARGO_TARGET_DIR the developer already has. The one thing a CLI flag cannot
survive is a manifest passing its own, which cargo rejects as a duplicate, so
that is refused up front with a message that says what it is.
2. Sync-Tree silently dropped any file whose name git quotes.
core.quotePath defaults on, so a path containing a non-ASCII byte is emitted as
`"crates/.../zz-caf\303\251.txt"`. Test-Path -LiteralPath on that is false, so
the file was neither copied into the working copy NOR recorded as wanted: the
copy diverged from the real tree by exactly the files nothing could see, and the
symptom would have been a red baseline blamed on the suite. Both enumerations
now run with -c core.quotePath=false.
Latent rather than active -- no such file exists here today -- but it also
silently weakened the "570 files compared, 0 differ" check, which enumerated
through the same quoting.
3. An -OutputDirectory git does not ignore made the copy copy itself.
What keeps the working copy out of its own enumeration is that `.scratch/` is
ignored, which covers the default and nothing else. Pointed anywhere else inside
the repository, the second run would enumerate the first run's copy and nest it
one level deeper, marking every nested file as wanted so the deletion pass
preserved it -- unbounded growth that looks like nothing until it does not. Now
refused unless git ignores the location.
4. Two stale comments, both from the last two commits: the bounds guard still
blamed WaitForExit's argument validation for rejecting a zero bound (the guard
is still right, but the poll that replaced that wait classifies zero as an
expired deadline instead), and the README still named a fixed "(60s)" test bound
that the derived one replaced.
Verified:
- CARGO_TARGET_DIR is empty in the calling session before and after a sweep.
- An -OutputDirectory of tools\sab-probe is refused with exit 2; a manifest
whose testArgs passes --target-dir is refused with exit 2.
- A non-ASCII filename now reaches the copy with matching contents, and is
removed from the copy on the next sync after the source deletes it.
- waitable-queues 39/39 as declared, exit 0, 352s; placement-probe 9/9 on
PowerShell 7 and Windows PowerShell 5.1; parses clean on both.
- Real tree untouched, no stray cargo or test processes, check-encoding.ps1
passes (570 files).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🔵 Needs a closer look
There are a few concrete documentation inconsistencies/ambiguities (and one misleading in-script comment) that should be corrected to avoid future misuse of the harness/manifest format.
Review details
Suppressed comments (4)
Previously missed (2) — in code that hasn't changed since the last review.
tools/README-sabotage.md:64
- The formula uses leading '-' characters (
-TimeoutFloorSeconds,-TimeoutMultiplier) inside themax(...)expression. In Markdown this reads like subtraction, andmax(15, 3x)is also ambiguous about what is being multiplied. Rephrasing the formula to use bare parameter names and an explicitbaselineterm is clearer.
tools/run-sabotage.ps1:920 - The timeout-derivation comment says the baseline duration includes the build, but
Invoke-Sabotagedreturns the run/test phase seconds (build seconds are not included). This makes the comment contradict the very next paragraph and can mislead future edits about whatbaseline.Secondsrepresents.
tools/README-sabotage.md:218
- The manifest-format table lists
timeoutSecondstwice with the same field name, once for the whole sweep and once for per-entry. This is ambiguous for readers and for anyone validating manifests manually.
| `timeoutSeconds` | no | Hang bound for this entry alone, when it legitimately runs far longer than the rest. Only ever raises the sweep's bound. |
tools/README-sabotage.md:217
- The docs say
replacedeletes with[''], but the shipped manifest also uses an empty array ("replace": []) to delete a matched block. Since the script joins the array with newlines, both spellings are equivalent; documenting both avoids confusing manifest authors.
| `replace` | yes | Replacement lines. `[""]` deletes. |
- Files reviewed: 3/3 changed files
- Comments generated: 0 new
- Review effort level: Lite
…ion notes All documentation, all confirmed against the code rather than taken on report. 1. A comment contradicted the line below it. The timeout-derivation note said the baseline measures the suite's duration "including its build", while the very next paragraph said the figure used is the test phase alone. The second is correct -- $baseline.Seconds carries the test phase and nothing else -- so the first is deleted and the two blocks are now one argument instead of two halves separated by nothing. The same note cited "8s for the tests themselves", which is not a number this was ever measured at. Corrected to the figures actually recorded on the placement-probe manifest: 25s for build-plus-test against a cold copy, 3-4s for the tests alone. 2. `timeoutSeconds` appeared twice in one flat table. The manifest format table listed every field at both levels in a single run -- `package` and `root` beside `name` and `find` -- which was survivable until this PR added a field that exists at BOTH levels with different meanings. Split into a top-level table and a per-sabotage one, which resolves the collision and the pre-existing ambiguity together, with a line stating plainly that the two `timeoutSeconds` are different fields. 3. The delete spelling was documented as `[""]` only. Both `[]` and `[""]` delete: the lines are joined with newlines, so both produce the empty string. The shipped waitable-queues manifest uses `[]` once and `[""]` three times, so a reader following the table would have found the manifest contradicting it. 4. The derived-bound formula read as arithmetic on the parameter names. `max(-TimeoutFloorSeconds, -TimeoutMultiplier x baseline)` looks like subtraction in prose, and `max(15, 3x)` did not say what was being multiplied. Rewritten as a fenced formula with bare names and an explicit `baselineTestSeconds` term, plus a worked example that matches the line the tool actually prints. Two rows were added while the tables were being split, both stating behaviour that was already implemented and verified but undocumented: `sabotages` is required and must be non-empty, and `testArgs` must not carry `--target-dir`. Verified: every claim the tables now make was checked against the code that enforces it; the floor example matches the arithmetic; a sweep of both manifests still reports as declared, exit 0, including the one entry that uses the `[]` delete spelling; parses clean on PowerShell 7 and Windows PowerShell 5.1; check-encoding.ps1 passes (570 files). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🔵 Needs a closer look
The new README’s exit-code semantics don’t fully match the script’s behavior (baseline failures also exit 2), and there is also unused code that should be cleaned up.
Review details
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
tools/README-sabotage.md:33
- The exit-code table says exit 2 only means “manifest or invocation is wrong”, but
run-sabotage.ps1also exits 2 when the baseline suite is red. The table should mention the baseline-failed case so callers don’t misinterpret exit 2 as strictly a manifest problem.
tools/run-sabotage.ps1:870 $treeRootPrefixis computed but never used. This looks like leftover code from an earlier version and adds noise to a script that already has a lot of moving parts.
- Files reviewed: 3/3 changed files
- Comments generated: 0 new
- Review effort level: Lite
…ad code Two findings, both confirmed. 1. The exit-code table under-described exit 2. It said exit 2 means "the manifest or the invocation is wrong". Enumerated: the script has 20 exit-2 sites, and 19 of them are exactly that -- but the twentieth is the baseline suite being red, which is neither. That case is the caller's tests failing before any sabotage is applied, and a wrapper reading only the code would have gone looking for a manifest problem it would never find. The table now names both, and the prose says which message distinguishes them. Left as one code rather than splitting the baseline case onto its own: that is a change to a documented contract and worth deciding deliberately rather than in passing, and the message already says plainly which case it is. The same paragraph was also missing three exit-2 conditions added during this PR: an -OutputDirectory git does not ignore, a testArgs carrying --target-dir, and the timeoutSeconds validations. 2. $treeRootPrefix was computed and never read. Left over from the copy-based rewrite, where the re-basing was first written against a tree-root prefix before being changed to use $repoRootPrefix at the point of use. Three lines of misleading noise in the middle of the path logic, suggesting a second prefix mattered when only one does. Removed, and the comment above it now names the prefix that is actually used. Swept the rest of the script for the same class while there: every other assigned variable is read. Verified: a deliberately-broken suite still exits 2 with the baseline-specific message and transcript path; a normal sweep reports 9/9 as declared, exit 0; parses clean on PowerShell 7 and Windows PowerShell 5.1; check-encoding.ps1 passes (570 files). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🔵 Needs a closer look
Manifest timeoutSeconds validation currently treats 0 as “not provided” due to truthiness checks, making the documented/reported “must be at least 1” behavior unenforced.
Review details
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
tools/run-sabotage.ps1:597
- Manifest-level
timeoutSeconds = 0will currently bypass validation because the condition uses truthiness (-and $spec.timeoutSeconds). In PowerShell,0is$false, so the "must be at least 1" error path becomes unreachable and an explicitly invalid manifest value is silently ignored (contradicting the README text and the error message).
This issue also appears on line 599 of the same file.
tools/run-sabotage.ps1:607
- Per-sabotage
timeoutSeconds = 0is also not rejected for the same reason: the guard uses$entry.timeoutSecondsas a truthiness check, so0skips validation. If0is meant to be invalid (as the error message says), the check should treat "present and 0" as an error rather than as "not provided".
foreach ($entry in @($spec.sabotages)) {
if (($entry.PSObject.Properties.Name -contains 'timeoutSeconds') -and
$entry.timeoutSeconds -and [int]$entry.timeoutSeconds -lt 1) {
Exit-WithMessage (@(
"The sabotage '$($entry.name)' sets timeoutSeconds = $($entry.timeoutSeconds)."
"It must be at least 1; a bound of zero reports it as caught without running."
) -join "`n") 2
}
}
- Files reviewed: 3/3 changed files
- Comments generated: 0 new
- Review effort level: Lite
…ough Three defects, one reported and two found by chasing it. 1. A manifest timeoutSeconds of 0 was silently ignored. PowerShell counts 0 as false, so `-and $spec.timeoutSeconds` short-circuited before reaching the `-lt 1` check inside it. The error message names zero specifically -- "a bound of zero reports every sabotage as caught" -- and zero was the one value that could not reach it. Measured: 0 exited 0 at both the manifest and per-sabotage level, while -5 was correctly rejected at both, because a non-zero number is truthy. The consequence was mild by luck rather than design -- an ignored bound falls back to the derived one, which is safe -- but an author who typed 0 was told nothing and got a different bound than the file asked for. Presence is now tested with `$null -ne`. 2. Manifest validation was skipped whenever -TimeoutSeconds was passed. The whole block was gated on `$TimeoutSeconds -eq 0`, so validation only ran on the invocations that were going to use the value. Measured: a manifest declaring -5 exits 2 normally and exited 0 with `-TimeoutSeconds 30`. A manifest carrying a nonsense bound would therefore be diagnosed only on the runs not overriding it, which is the run an author debugging an override is least likely to make. Validation and use are now separate: every timeoutSeconds in the file is checked whatever the command line said, and the sweep-wide one is applied only when -TimeoutSeconds did not already name a number. 3. A bound set by the manifest was reported as "-TimeoutSeconds". Found while verifying that a manifest's value is actually applied. The source label was chosen from `$TimeoutSeconds -gt 0`, which is true however the value arrived, so the sweep credited a flag the runner never passed -- and would have sent anyone trying to change it to the wrong place. Now attributed to whichever of the three actually supplied it: Hang bound: 40s (the manifest's timeoutSeconds) Hang bound: 25s (-TimeoutSeconds) Hang bound: 15s (3x the 3s baseline, floor 15s) Also checked the other truthiness guards in the file for the same trap. None carry a falsy value that is meaningful-but-invalid the way 0 is here: an empty `package` is as bad as a missing one and is already reported that way, and an empty `root` or `testArgs` is properly equivalent to omitting it. Verified: manifest 0, per-entry 0, manifest -5, and manifest -5 with -TimeoutSeconds 30 all exit 2; valid values at both levels exit 0 and are applied with correct attribution; waitable-queues 39/39 in 341s and placement-probe 9/9 on PowerShell 7 and Windows PowerShell 5.1; caller's environment untouched; check-encoding.ps1 passes (570 files). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🟢 Approval recommended
The harness and documentation are internally consistent with the stated “copy-only” safety model, and the updated manifest patterns match the current source locations they target.
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 0 new
- Review effort level: Lite
Peels the sabotage harness out of the PR #56 branch, because
mainneeds it nowrather than whenever that branch lands.
Why this is not just tidying
PRs #59 and #63 landed
crates/windows-waitable-queues/sabotage.jsonandcrates/windows-placement-probe/sabotage.json. Both open with:Neither file was in
main. Somainhas been shipping two manifests pointingat a harness that is not in the tree -- one of them for a published crate.
What the harness does
It takes a manifest of deliberate defects and, for each: patches the source,
runs the suite, and records whether the suite noticed. That measures the claim a
green run does not make -- that the tests would fail if the code were wrong.
It exits 0 only when every entry behaves as the manifest declared, and reports
MANIFEST STALEwhen a pattern stops matching exactly one site, so a sabotagethat silently stopped applying cannot read as a pass. It is deliberately not
a CI gate.
It never touches your working tree
The sweep runs against a copy, refreshed from the working tree at the start
of each run, with its own cargo target directory -- the same approach
cargo mutantstakes.This is the PR's main design decision, and it was made late, after review had
worked through the in-place version. It is a premise rather than a precaution:
patching the developer's own files means every sabotage needs a backup, a
restore, a check that the restore worked, a dirty-tree guard, a containment
check, and a recovery path for when any of that is interrupted -- and each is a
chance to damage work that was never in a commit.
Of the eighteen defects found across seven review rounds here, eight were in
that machinery, and every data-loss risk was one of them. The last was the
sharpest: a byte-exact restore via
File.Copycarried the original mtime, socargo judged the crate up to date against an artifact built from patched
source and left the sabotaged binary in the developer's build cache.
Working against a copy deletes the backup files, the restore, the restore
verification, the leftover-backup guard, the dirty-tree guard, the git
exit-code check it needed, the
-AllowDirtyswitch, and exit code 3. Twobehaviour improvements fall out: a dirty tree is now swept exactly as it stands
(uncommitted edits are usually the code whose guards you are asking about), and
a bug in this tool now costs a scratch directory rather than your work.
Verification
and mtime before and after a sweep; the sets match exactly.
Windows PowerShell 5.1. Parses clean on both.
MANIFEST STALEagainstmain's own source -- see below.the baseline red, proving the copy reflects the working tree, not
HEAD.tree and 379 MB of target.
tools/check-encoding.ps1, which CI runs, passes: 570 files clean.A pre-existing defect this found in
mainFive of the 39 entries in
main'swindows-waitable-queues/sabotage.jsonhavefindpatterns that occur zero times inmain's ownwindows-waitable-queuessource:the final drain returns nothingand fourreserving_mpsc:entries.This is not a regression from this PR -- the copy is proven byte-identical to
the real tree, and the patterns were measured directly against the real files.
It means five guards on a published crate are currently unverified, and a
green sweep would never have said so. That the harness reports it as
MANIFEST STALErather than counting those entries as caught is the wholereason it distinguishes the two.
Repairing them needs judgement about what
reserving_mpscshould now besabotaged at, which is a different piece of work from adding the tool, so it is
not fixed here.
Note for whoever lands PR #56
tools/run-sabotage.ps1andtools/README-sabotage.mdalso exist onmikegrier/deferred-namespace-ops. Once this merges, that branch's next mergefrom
mainhits an add/add conflict on both -- the same thing that happenedwith the placement probe. Deleting them there and taking
main's copies is theclean resolution;
main's are substantially further along.