staysfixed 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -47,6 +47,7 @@ import { StaysFixedError, isExpected } from '../core/errors.js';
47
47
  import { findChrome, freePort, resolveElectronBinary } from '../drive/find.js';
48
48
  import { waitForEndpoint } from '../drive/cdp.js';
49
49
  import { keepOutput, stopProcess } from '../drive/browser.js';
50
+ import { stopTree } from '../core/stop-tree.js';
50
51
 
51
52
  const exec = promisify(execFile);
52
53
 
@@ -309,6 +310,21 @@ export async function probeBrowser(binary) {
309
310
  const version = /\d+\.\d+\.\d+(\.\d+)?/.exec(said)?.[0];
310
311
  // It answered, but with no version in it. That is what a broken bundle does:
311
312
  // it exits zero and prints the library it could not load.
313
+ //
314
+ // It is ALSO what a perfectly good Windows browser does when a copy of itself is already
315
+ // open: the new process hands the request to the running one, prints "Opening in existing
316
+ // browser session." and exits zero, and no amount of waiting produces a version. On a real
317
+ // Windows 11 machine on 2026-08-31 that was true of every browser on the box, so the tool
318
+ // said "There is no browser on this machine that will run" while standing next to a
319
+ // perfectly good Edge — which is the whole product unusable there, not a cosmetic wrong
320
+ // note. So Windows gets a second question, and only in this one case: the version Windows
321
+ // keeps inside the file, which starts nothing and cannot be answered on by another copy.
322
+ // A browser that failed outright still falls through to the catch below and is still
323
+ // reported broken, so nothing about a genuinely broken browser has been softened.
324
+ if (!version && process.platform === 'win32') {
325
+ const fromTheFile = await windowsFileVersion(binary);
326
+ if (fromTheFile) return { ok: true, version: fromTheFile };
327
+ }
312
328
  if (!version) return { ok: false, why: said.split('\n')[0]?.slice(0, 200) || 'it printed nothing when asked its version' };
313
329
  return { ok: true, version };
314
330
  } catch (e) {
@@ -318,6 +334,52 @@ export async function probeBrowser(binary) {
318
334
  }
319
335
  }
320
336
 
337
+ /**
338
+ * The version Windows keeps inside the .exe, read without starting it.
339
+ *
340
+ * Every Windows program carries its version as part of the file, and PowerShell reads it out
341
+ * in one line. Nothing is launched, so a browser the person already has open cannot answer on
342
+ * this one's behalf. Returns null whenever the answer is not a version, and the caller then
343
+ * falls back to asking the program itself.
344
+ *
345
+ * @param {string} binary
346
+ * @returns {Promise<string|null>}
347
+ */
348
+ async function windowsFileVersion(binary) {
349
+ try {
350
+ const { stdout } = await exec(
351
+ 'powershell.exe',
352
+ [
353
+ '-NoProfile',
354
+ '-NonInteractive',
355
+ '-ExecutionPolicy',
356
+ 'Bypass',
357
+ '-Command',
358
+ `(Get-Item -LiteralPath ${powershellString(binary)}).VersionInfo.ProductVersion`,
359
+ ],
360
+ { timeout: PROBE_MS, maxBuffer: 1 << 20, windowsHide: true },
361
+ );
362
+ return /\d+\.\d+\.\d+(\.\d+)?/.exec(String(stdout))?.[0] ?? null;
363
+ } catch {
364
+ // No PowerShell, or a file with no version in it. Either way the caller has a fallback.
365
+ return null;
366
+ }
367
+ }
368
+
369
+ /**
370
+ * One PowerShell string literal, with the quotes it needs.
371
+ *
372
+ * Single quotes, because PowerShell does not look inside them for variables — a path with a
373
+ * `$` in it is a path, not something to expand. A single quote inside the value is doubled,
374
+ * which is how PowerShell escapes one.
375
+ *
376
+ * @param {string} value
377
+ * @returns {string}
378
+ */
379
+ function powershellString(value) {
380
+ return `'${String(value).replace(/'/g, "''")}'`;
381
+ }
382
+
321
383
  /**
322
384
  * @param {BrowserKind} kind
323
385
  * @returns {{name: string, why: string}}
@@ -549,9 +611,13 @@ function installGuards() {
549
611
  * @returns {Promise<void>}
550
612
  */
551
613
  async function removeStubbornly(home) {
552
- for (let attempt = 0; attempt < 6; attempt += 1) {
614
+ for (let attempt = 0; attempt < 10; attempt += 1) {
553
615
  try {
554
- await fsp.rm(home, { recursive: true, force: true });
616
+ // `maxRetries` as well as the loop, because the two answer different questions: the
617
+ // loop is for a file that reappears behind the sweep, and the retries are for Windows
618
+ // refusing to delete a folder anything still has a handle open inside. Measured on a
619
+ // real Windows 11 machine on 2026-08-31, where the second is what actually happens.
620
+ await fsp.rm(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
555
621
  if (!fs.existsSync(home)) return;
556
622
  } catch {
557
623
  // A file recreated a millisecond after the sweep began. Wait for whoever wrote it to
@@ -570,11 +636,13 @@ async function removeStubbornly(home) {
570
636
  */
571
637
  function killNow(pid, home) {
572
638
  if (pid) {
573
- try {
574
- process.kill(pid, 'SIGKILL');
575
- } catch {
576
- // Already gone. That is the outcome we wanted.
577
- }
639
+ // The whole tree. `process.kill` stops the one process it is given, and a browser is a
640
+ // parent plus a renderer for every page it has open — on Windows those renderers survive
641
+ // their parent, keep writing into the throwaway profile, and the sweep below can then
642
+ // never finish. Measured on a real Windows 11 machine on 2026-08-31: the profile of an
643
+ // interrupted run was still on disk afterwards. `stopTree` is deliberately synchronous,
644
+ // because this runs from an exit handler where there is no event loop left to await on.
645
+ stopTree(pid, 'SIGKILL');
578
646
  // SIGKILL is a request to the kernel, not something that has already happened, and a
579
647
  // browser is not one process. While the parent is being reaped its children are still
580
648
  // writing into the profile, so deleting the folder in the same breath loses the race:
@@ -586,12 +654,21 @@ function killNow(pid, home) {
586
654
  }
587
655
  // Then remove it, and more than once. One rmSync is a snapshot; a file recreated a
588
656
  // millisecond after the sweep began turns it into ENOTEMPTY and a leftover folder.
589
- for (let attempt = 0; attempt < 5; attempt += 1) {
657
+ //
658
+ // The budget is bigger than it looks it needs to be, because Windows does not fail the same
659
+ // way. There a folder cannot be deleted while ANY process still has a handle open inside it,
660
+ // and the operating system releases those handles a little after the processes are gone —
661
+ // so the answer is not ENOTEMPTY, it is EBUSY, and it stays EBUSY for as long as the reaping
662
+ // takes. Measured on a real Windows 11 machine on 2026-08-31: five attempts a hundred
663
+ // milliseconds apart were not enough, and a run that fell over left its throwaway profile
664
+ // on disk. Twelve attempts is about a third of a second in the worst case, which is what an
665
+ // exit handler can honestly spend.
666
+ for (let attempt = 0; attempt < 12; attempt += 1) {
590
667
  try {
591
- fs.rmSync(home, { recursive: true, force: true });
668
+ fs.rmSync(home, { recursive: true, force: true, maxRetries: 3, retryDelay: 20 });
592
669
  return;
593
670
  } catch {
594
- waitSync(20);
671
+ waitSync(25);
595
672
  }
596
673
  }
597
674
  // A profile left in the temporary folder is untidy, not harmful — and it is exactly what
@@ -1093,10 +1170,18 @@ async function isOurs(pid, userDataDir) {
1093
1170
  if (!userDataDir.startsWith(SCRATCH_ROOT)) return false;
1094
1171
  if (!isAlive(pid)) return false;
1095
1172
  if (process.platform === 'win32') {
1096
- // No cheap command line read here. The profile folder is under our own
1097
- // scratch root and the record was written by us, which is the same
1098
- // guarantee arrived at a different way.
1099
- return true;
1173
+ // Windows is asked the same question, in Windows' words.
1174
+ //
1175
+ // This used to answer `true` here without asking anything, on the reasoning that the
1176
+ // profile folder was ours and the record was written by us. That reasoning drops the half
1177
+ // that matters: the record says which process id was ours THEN, and the paragraph above
1178
+ // is about an id that has since been handed to somebody else. Measured on a real Windows
1179
+ // 11 machine on 2026-08-31 — a record naming a stranger's running program was reported as
1180
+ // one of our browsers, and `--clean` would have killed it. Windows has no `ps`, so the
1181
+ // command line is read out of the process table instead; a query that cannot be answered
1182
+ // means no, because claiming somebody else's program is the one outcome forbidden here.
1183
+ const line = await windowsCommandLine(pid);
1184
+ return line !== null && line.includes(userDataDir);
1100
1185
  }
1101
1186
  try {
1102
1187
  const { stdout } = await exec('ps', ['-o', 'command=', '-p', String(pid)], { timeout: PROBE_MS, windowsHide: true });
@@ -1106,6 +1191,38 @@ async function isOurs(pid, userDataDir) {
1106
1191
  }
1107
1192
  }
1108
1193
 
1194
+ /**
1195
+ * The command line one running process was started with, on Windows.
1196
+ *
1197
+ * This is Windows' `ps -o command=`. `tasklist` cannot do it — it reports the program's name
1198
+ * and nothing about its arguments, and the argument is the whole question here. Returns null
1199
+ * when the process is gone or the question could not be asked at all, and the caller reads
1200
+ * null as "not ours".
1201
+ *
1202
+ * @param {number} pid
1203
+ * @returns {Promise<string|null>}
1204
+ */
1205
+ async function windowsCommandLine(pid) {
1206
+ try {
1207
+ const { stdout } = await exec(
1208
+ 'powershell.exe',
1209
+ [
1210
+ '-NoProfile',
1211
+ '-NonInteractive',
1212
+ '-ExecutionPolicy',
1213
+ 'Bypass',
1214
+ '-Command',
1215
+ `(Get-CimInstance Win32_Process -Filter "ProcessId=${Number(pid)}").CommandLine`,
1216
+ ],
1217
+ { timeout: PROBE_MS, maxBuffer: 1 << 20, windowsHide: true },
1218
+ );
1219
+ const said = String(stdout).trim();
1220
+ return said.length > 0 ? said : null;
1221
+ } catch {
1222
+ return null;
1223
+ }
1224
+ }
1225
+
1109
1226
  /**
1110
1227
  * Quit everything an earlier run left behind, and delete its throwaway profiles.
1111
1228
  *
@@ -1134,17 +1251,12 @@ export async function cleanStrays() {
1134
1251
  continue;
1135
1252
  }
1136
1253
  if (stray.running && stray.pid !== null) {
1137
- try {
1138
- process.kill(stray.pid, 'SIGTERM');
1139
- } catch {
1140
- // It went away between being listed and being asked. Fine.
1141
- }
1254
+ // The tree again, for the same reason as `killNow`: a leftover browser's renderers are
1255
+ // separate processes, and on Windows they hold the profile folder open long after their
1256
+ // parent has gone. `--clean` that leaves half a browser behind has not cleaned anything.
1257
+ stopTree(stray.pid, 'SIGTERM');
1142
1258
  await waitForGone(stray.pid, GRACE_MS);
1143
- try {
1144
- process.kill(stray.pid, 'SIGKILL');
1145
- } catch {
1146
- // Already gone, which is what we wanted.
1147
- }
1259
+ stopTree(stray.pid, 'SIGKILL');
1148
1260
  quit.push(stray);
1149
1261
  } else {
1150
1262
  swept.push(stray);
package/src/v2/cause.js CHANGED
@@ -55,6 +55,15 @@ const run = promisify(execFile);
55
55
  * @property {{file: string, header: string}|null} hunk
56
56
  * @property {number} checked How many of the finding's differences were re-checked.
57
57
  * @property {number} disappeared How many of them went away.
58
+ * @property {number} reran How many journeys were actually walked again. Zero means
59
+ * nothing was measured at all — no build was started, no
60
+ * journey was walked, and whatever this says is not a
61
+ * measurement. On 2026-08-31 a `prove` on a real website
62
+ * came back in five seconds, with zero server starts in the
63
+ * run log, wearing the same sentence as a real eleven-minute
64
+ * measurement. This number is what tells the two apart, so
65
+ * it is carried on every answer this file gives, including
66
+ * the ones it gives from the catch block.
58
67
  * @property {string} [why] Why it could not be tested, when it could not.
59
68
  * @property {ChangedHunk[]} [candidates] Hunks it could have tested, when it could not choose.
60
69
  * @property {string} [worktree] Where it ran, when `keep` was asked for.
@@ -170,6 +179,14 @@ export async function proveCause(finding, opts) {
170
179
  // verdict; `gitQuiet` hands back whether it worked and nothing used to look.
171
180
  /** @type {{proof: CauseProof|null}} */
172
181
  const held = { proof: null };
182
+ // How many journeys have actually been walked again by the time anything answers.
183
+ //
184
+ // Held out here, rather than inside the try, because the catch block below answers with
185
+ // `cannot()` and that answer has to say whether any measuring happened. A throw halfway
186
+ // through the second of three journeys is a genuinely different thing from a throw before
187
+ // the scratch checkout existed, and until 2026-08-31 both came back wearing the same
188
+ // sentence as a full eleven-minute measurement.
189
+ const walked = { count: 0 };
173
190
  /**
174
191
  * @param {CauseProof} p
175
192
  * @returns {CauseProof}
@@ -212,6 +229,7 @@ export async function proveCause(finding, opts) {
212
229
  return done(cannot(
213
230
  `${notCarried.length} new ${notCarried.length === 1 ? 'file' : 'files'} could not be copied into the scratch checkout: ${notCarried.join('; ')}. Anything that went away in a copy missing those files would have gone away for the wrong reason, so nothing is claimed here.`,
214
231
  hunk,
232
+ walked.count,
215
233
  ));
216
234
  }
217
235
 
@@ -230,6 +248,7 @@ export async function proveCause(finding, opts) {
230
248
  return done(cannot(
231
249
  `That change could not be undone on its own: ${undone.why}. It probably overlaps another change in the same place.`,
232
250
  hunk,
251
+ walked.count,
233
252
  ));
234
253
  }
235
254
  }
@@ -257,6 +276,9 @@ export async function proveCause(finding, opts) {
257
276
  });
258
277
  const settled = opts.normalise ? opts.normalise(capture) : capture;
259
278
  without.set(journey.name, indexByPath(settled.observations));
279
+ // Counted AFTER the walk returns, never before it starts. A journey that threw halfway
280
+ // was not walked, and saying it was is how "could not test" starts sounding measured.
281
+ walked.count += 1;
260
282
  }
261
283
 
262
284
  const differences = finding.differences;
@@ -292,22 +314,23 @@ export async function proveCause(finding, opts) {
292
314
  : partly
293
315
  ? `Undoing that one change in ${hunk.file} took away every address that could be re-walked.${unseen} So this is not proved: what was not walked may be the half that matters.`
294
316
  : proved
295
- ? `Undoing that one change in ${hunk.file} made this go away. It is yours, and it is explained.`
317
+ ? `Undoing that one change in ${hunk.file} made this go away. It is yours, and it is explained. That rests on real work: ${walked.count} ${walked.count === 1 ? 'journey was' : 'journeys were'} walked again with the change undone, and ${rechecked.length === 1 ? 'the one address that was re-checked matched' : `all ${rechecked.length} re-checked addresses matched`} the old build again.`
296
318
  : disappeared > 0
297
319
  ? `Undoing that change in ${hunk.file} took away ${disappeared} of the ${rechecked.length} addresses that were re-checked and left ${rechecked.length - disappeared} exactly as ${rechecked.length - disappeared === 1 ? 'it was' : 'they were'}. So that change explains part of this and not the rest, and the rest has another cause nothing has looked for yet. It is not covered by undoing that one change.${unseen}`
298
- : `This is still here with that change undone, so ${hunk.file} is not what caused it. Something else did, and nothing knows what yet.${unseen}`,
320
+ : `This is still here with that change undone, so ${hunk.file} is not what caused it. Something else did, and nothing knows what yet. That was measured, not assumed: ${walked.count} ${walked.count === 1 ? 'journey was' : 'journeys were'} walked again without your change and ${rechecked.length === 1 ? 'the one address that was re-checked still differed' : `all ${rechecked.length} re-checked addresses still differed`}.${unseen}`,
299
321
  hunk: { file: hunk.file, header: hunk.header },
300
322
  // What was actually re-walked. It used to be every difference in the finding, including
301
323
  // the ones no journey ever went near, so the number said the work had been done.
302
324
  checked: rechecked.length,
303
325
  disappeared,
326
+ reran: walked.count,
304
327
  };
305
328
  if (notRechecked > 0) result.why = unseen.trim();
306
329
  if (opts.keep === true) result.worktree = tree;
307
330
  if (events) events.emit({ type: 'proof:done', at: events.elapsed(), message: result.what });
308
331
  return done(result);
309
332
  } catch (e) {
310
- return done(cannot(messageOf(e), hunk));
333
+ return done(cannot(messageOf(e), hunk, walked.count));
311
334
  } finally {
312
335
  // Even when it throws. A leftover worktree makes the next `git status`
313
336
  // confusing and the one after that frightening — and until 2026-08-30, if the removal
@@ -449,20 +472,38 @@ function sameFile(a, b) {
449
472
  }
450
473
 
451
474
  /**
475
+ * "I could not test this" — which is a third answer, not a soft version of the second one.
476
+ *
477
+ * The `what` sentence says out loud whether anything was re-walked, because the number
478
+ * alone travels badly. On 2026-08-31 the facade in src/v2/check.js forwarded only
479
+ * `{gone, verdict, escalates, detail}` to the MCP surface, so a caller reading `detail`
480
+ * had no way to know that the five-second answer in front of it had started no build and
481
+ * walked no journey. A fact that matters this much belongs in the sentence as well as in
482
+ * the field, so that it survives every surface that only passes the words along.
483
+ *
452
484
  * @param {string} why
453
485
  * @param {ChangedHunk|null} hunk
486
+ * @param {number} [reran] Journeys walked again before this gave up. Usually none — but the
487
+ * catch block in `proveCause` reaches here AFTER a walk may already
488
+ * have happened, and claiming "nothing was re-run" there would be
489
+ * its own small lie.
454
490
  * @returns {CauseProof}
455
491
  */
456
- function cannot(why, hunk) {
492
+ function cannot(why, hunk, reran = 0) {
493
+ const measured =
494
+ reran === 0
495
+ ? ' Nothing was re-run: no build was started and no journey was walked again, so nothing here is a measurement of your product.'
496
+ : ` ${reran} ${reran === 1 ? 'journey was' : 'journeys were'} walked again before this gave up, and that work proved nothing either way.`;
457
497
  return {
458
498
  verdict: 'could not test',
459
499
  // Not proven either way is not the same as proven innocent, and it must
460
500
  // never be reported as if it were.
461
501
  escalates: false,
462
- what: `This could not be tested by undoing a change. ${why}`,
502
+ what: `This could not be tested by undoing a change, so your edit is neither cleared nor blamed. ${why}${measured}`,
463
503
  hunk: hunk ? { file: hunk.file, header: hunk.header } : null,
464
504
  checked: 0,
465
505
  disappeared: 0,
506
+ reran,
466
507
  why,
467
508
  };
468
509
  }