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>
|
Also fixed the suppressed comment from this round ( Correct, and it is the same defect class I fixed one round earlier -- my round-5 sweep looked for Under Verified by running the script from a directory outside any git repo (confirming first that 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; parses clean on both; encoding gate 570 files clean. |
There was a problem hiding this comment.
🟡 Changes recommended
The harness has a few correctness/robustness issues (notably timeout input validation and byte-faithful backup/restore) that should be addressed before relying on it for safety-critical sweeps.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
tools/run-sabotage.ps1:155
Invoke-Boundedultimately calls$process.WaitForExit($Seconds * 1000). If-TimeoutSecondsor-BuildTimeoutSecondsis 0/negative, this becomes a runtime error (and breaks the script's "report, don't throw" contract). Add an explicit early validation that exits 2 viaExit-WithMessage.
tools/run-sabotage.ps1:319Format-Patchcurrently prints an empty+line and then+ (removed)when the replacement is empty, because theforeach ($line in $Replace -split "n")runs before theIsNullOrEmpty` check. That makes the injected patch display noisier than needed for deletion sabotages.
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Lite
| $backup = Join-Path $backupDirectory ($stem + '.' + (Split-Path -Leaf $target) + '.bak') | ||
| [System.IO.File]::WriteAllText($backup, $original, $utf8NoBom) | ||
|
|
||
| try { | ||
| # Inside the guarded region, not before it. A write that throws part-way | ||
| # through -- having already truncated the file -- would otherwise never | ||
| # reach the `finally` that restores it, and the tool's whole promise is | ||
| # that it leaves the tree as it found it. | ||
| [System.IO.File]::WriteAllText($target, $patched, $utf8NoBom) | ||
| $run = Invoke-Sabotaged -CargoArgs $testArgs -WorkingDirectory $repoRoot ` | ||
| -TranscriptPath $transcript -BuildSeconds $BuildTimeoutSeconds -TestSeconds $TimeoutSeconds | ||
| } | ||
| finally { | ||
| [System.IO.File]::WriteAllText($target, $original, $utf8NoBom) | ||
| if ([System.IO.File]::ReadAllText($target) -ne $original) { |
There was a problem hiding this comment.
Fixed in 7d60e29 -- the diagnosis is right, and the suggested remedy is the one thing I could not take, because File.Copy introduces a worse defect. Both halves below.
The defect you found is real and I reproduced it end to end. Backup and restore went through ReadAllText/WriteAllText and the verification compared decoded text, so a UTF-8 BOM was silently dropped and the check passed anyway. Run against the pre-fix script:
before: 35 bytes, BOM=True, sha=F56BFC387AAEA8C3...
"All 1 sabotages behaved as declared." exit 0
after : 32 bytes, BOM=False, sha=00B8502FBFFF65FD...
BYTE-IDENTICAL RESTORE: False
It reported success while permanently modifying the file and then deleting the backup. Latent here (570 tracked files, 0 with a BOM) but the tool patches whatever a manifest names.
File.Copy cannot be the fix, though: it preserves LastWriteTime. A restored file would carry its original timestamp -- older than the artifact cargo had just built from the patched source. Cargo compares those mtimes, decides the crate is up to date, and keeps the sabotaged binary in the build cache. The next cargo test -- the next sweep's baseline, or a person's -- then runs against sabotaged code.
I know because I implemented your suggestion first and it bit immediately: an unrelated windows-placement-probe test began failing on a clean tree and kept failing until the source was touched. The failing assertion was precisely the one the sweep's last sabotage inverts, which is what identified the cause:
source mtime : 2026-09-05T19:21:39 <- restored, original timestamp
binary mtime : 2026-09-05T19:41:09 <- built from the PATCHED source
source OLDER than binary (=> cargo skips rebuild): True
WriteAllBytes is equally byte-exact and stamps the file now, so cargo rebuilds. Regression test, which the File.Copy version fails: two consecutive sweeps, both green, and cargo test -p windows-placement-probe afterwards recompiles and reports 257/257. Re-checked that BOM fidelity still holds with the new restore -- byte-identical, SHA equal, BOM intact.
Verification hashing both files with SHA-256 replaces the text comparison, as you suggested.
…timeout Seventh PR #64 review round. Three findings, all taken -- and taking the first one naively introduced a fourth, worse defect, which is also fixed here. 1. Restore was not byte-faithful. Backup and restore went through ReadAllText/WriteAllText, and the verification compared decoded TEXT. That round trip is lossy: ReadAllText strips a UTF-8 BOM and WriteAllText with a no-BOM encoder does not put it back, so a BOM'd file came back three bytes shorter -- while the comparison passed and the backup was then deleted. Measured end to end against the pre-fix script: a 35-byte BOM'd file was restored as 32 bytes, BOM gone, and the sweep reported "All 1 sabotages behaved as declared", exit 0. Latent here (570 tracked files, 0 with a BOM) but the tool patches whatever a manifest names, and "restores what it patched" has to mean the bytes. Backup and restore now move raw bytes, and the verification hashes both files with SHA-256 instead of comparing text. 2. But NOT with File.Copy, which is what the review suggested -- that leaves a sabotaged binary in the build cache. File.Copy preserves the source's LastWriteTime, so a restored file carries its ORIGINAL timestamp, older than the artifact cargo had just built from the patched source. Cargo compares those mtimes, concludes the crate is up to date, and keeps the SABOTAGED binary. The next `cargo test` -- the next sweep's baseline, or a person's -- then silently runs against sabotaged code. Found by doing it: after a File.Copy-based sweep, an unrelated placement-probe test failed on a clean tree and kept failing until the source was touched. The failing assertion was the one the sweep's LAST sabotage had inverted, which is what identified it. Both restores now use WriteAllBytes, which is equally byte-exact and stamps the file now, so cargo rebuilds. 3. A timeout of zero or less was accepted. Both bounds reach WaitForExit($Seconds * 1000). Zero returns immediately without waiting, so every phase would be reported as a hang and every sabotage as `caught` -- a fully green sweep proving nothing, which is the one outcome this tool exists to make impossible. A negative value throws instead, breaking the report-don't-throw contract. Both measured; both now rejected with exit 2 before anything runs. 4. Format-Patch printed an empty '+' line before '+ (removed)'. Splitting an empty replacement yields one empty element, so deletion sabotages rendered both. Five of the 48 entries across the two shipped manifests use the deleting form, so this was every deletion's patch display. Verified: - BOM'd file through a full patch/run/restore: byte-identical, SHA-256 equal, BOM intact. The pre-fix script on the same input: 32 bytes, BOM stripped, still reporting success. - Two consecutive sweeps: both green. Before the mtime fix the SECOND sweep's baseline failed, because the first had left a sabotaged binary cached. `cargo test -p windows-placement-probe` afterwards recompiles and reports 257/257. - -TimeoutSeconds 0, -TimeoutSeconds -5 and -BuildTimeoutSeconds 0 each exit 2 with an explanation. - Format-Patch renders '+ (removed)' alone for a deletion and the replacement lines otherwise. - Full sweep on PowerShell 7 and Windows PowerShell 5.1: 9/9 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. 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." |
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.