staysfixed 0.3.1 → 0.4.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.
Files changed (46) hide show
  1. package/README.md +534 -402
  2. package/package.json +8 -3
  3. package/src/cli/index.js +14 -0
  4. package/src/v2/adapters/android-driver.js +1705 -0
  5. package/src/v2/adapters/android.js +1117 -0
  6. package/src/v2/adapters/contract.js +565 -0
  7. package/src/v2/adapters/electron.js +1594 -0
  8. package/src/v2/adapters/http.js +733 -0
  9. package/src/v2/adapters/ios-driver.js +1551 -0
  10. package/src/v2/adapters/ios.js +989 -0
  11. package/src/v2/adapters/isolate.js +739 -0
  12. package/src/v2/adapters/process.js +920 -0
  13. package/src/v2/adapters/source.js +1241 -0
  14. package/src/v2/adapters/web-driver.js +1532 -0
  15. package/src/v2/adapters/web.js +1009 -0
  16. package/src/v2/adapters/windows.js +1329 -0
  17. package/src/v2/browsers.js +1203 -0
  18. package/src/v2/cause.js +364 -0
  19. package/src/v2/check.js +1331 -0
  20. package/src/v2/ci.js +1209 -0
  21. package/src/v2/cli.js +657 -0
  22. package/src/v2/cluster.js +372 -0
  23. package/src/v2/coverage.js +1116 -0
  24. package/src/v2/detect.js +1199 -0
  25. package/src/v2/doctor.js +1690 -0
  26. package/src/v2/escalate.js +679 -0
  27. package/src/v2/init.js +1394 -0
  28. package/src/v2/intent.js +659 -0
  29. package/src/v2/journeys/from-routes.js +498 -0
  30. package/src/v2/journeys/from-suite.js +988 -0
  31. package/src/v2/journeys/index.js +651 -0
  32. package/src/v2/journeys/record.js +516 -0
  33. package/src/v2/mcp/server.js +374 -0
  34. package/src/v2/mcp/tools.js +1571 -0
  35. package/src/v2/normalise.js +783 -0
  36. package/src/v2/observation.js +877 -0
  37. package/src/v2/rank.js +672 -0
  38. package/src/v2/reference.js +1051 -0
  39. package/src/v2/remote.js +911 -0
  40. package/src/v2/run.js +964 -0
  41. package/src/v2/sealed.js +564 -0
  42. package/src/v2/selfcheck.js +564 -0
  43. package/src/v2/ship.js +684 -0
  44. package/src/v2/store.js +703 -0
  45. package/src/v2/types.js +503 -0
  46. package/src/v2/waiver.js +511 -0
@@ -0,0 +1,739 @@
1
+ /**
2
+ * One run of a desktop app, genuinely alone — and proved gone afterwards.
3
+ *
4
+ * This file exists because of a real day lost. On 2026-08-28 two copies of one desktop app
5
+ * were running on one machine, sharing one identity, and every time either of them
6
+ * reconnected it pushed the other one off. The symptom was "my phone will not stay
7
+ * connected". The cause was two instances that each believed they were the only one.
8
+ *
9
+ * A checking tool that opens the same app twice — a reference build and a changed build,
10
+ * minutes apart — walks straight into that. Two instances of one app collide on FIVE things
11
+ * and every one of them is silent:
12
+ *
13
+ * 1. the single-instance lock the second copy quits, and quitting looks like a crash
14
+ * 2. the user data directory settings, databases and lock files written by both
15
+ * 3. the debugging port the second copy attaches to the FIRST copy's window
16
+ * 4. whatever identity it registers a relay slot, an account, a device id
17
+ * 5. lock files left behind a copy that died leaves them, the next copy sees them
18
+ *
19
+ * So every run gets its own of all five, and — the part that matters more — the teardown is
20
+ * PROVED rather than assumed. `release` does not return until the process is gone, until
21
+ * nothing on this machine is still holding the run's own folder, and until the ports it took
22
+ * are free again. If it cannot prove that, it says so in plain English instead of quietly
23
+ * letting the next run start on top of the last one.
24
+ *
25
+ * THE ONE RULE THAT IS NEVER BENT: only ever kill what this tool started. Somebody's real
26
+ * app is very probably open on this machine right now. Everything killed here is either a
27
+ * process we spawned ourselves, or a process whose command line names OUR OWN scratch
28
+ * folder — which no other process on earth can be pointing at, because we made the folder
29
+ * and its name has a random part.
30
+ */
31
+
32
+ import crypto from 'node:crypto';
33
+ import fsp from 'node:fs/promises';
34
+ import net from 'node:net';
35
+ import os from 'node:os';
36
+ import path from 'node:path';
37
+ import { execFile, spawn } from 'node:child_process';
38
+ import { promisify } from 'node:util';
39
+
40
+ const execFileAsync = promisify(execFile);
41
+
42
+ /**
43
+ * The one port a person's own paired browser lives on. Never taken, never probed, never
44
+ * killed. It is written down here rather than left to chance because "the tool stole the
45
+ * port my browser was on" is exactly the kind of damage nobody connects back to the tool.
46
+ */
47
+ export const PORTS_THAT_ARE_NOT_OURS = new Set([9333]);
48
+
49
+ /**
50
+ * Ports handed out in this process, so two isolations never pick the same one.
51
+ * @type {Set<number>}
52
+ */
53
+ const handedOut = new Set();
54
+
55
+ /**
56
+ * Every isolation still alive, so nothing survives a check that threw.
57
+ * @type {Map<string, {isolation: Isolation, closers: (() => Promise<void>|void)[], children: import('node:child_process').ChildProcess[]}>}
58
+ */
59
+ const alive = new Map();
60
+
61
+ /** @param {number} ms */
62
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // The shapes
66
+ // ---------------------------------------------------------------------------
67
+
68
+ /**
69
+ * Everything one run of one app is allowed to touch.
70
+ *
71
+ * @typedef {object} Isolation
72
+ * @property {string} id Short, unique to this run. Appears in every folder name.
73
+ * @property {string} label Plain English, for messages: 'your change', 'the build you shipped'.
74
+ * @property {string} dir The run's own folder. Everything below lives inside it.
75
+ * @property {string} userDataDir What the app will believe is its settings folder.
76
+ * @property {string} homeDir What the app will believe is the home folder.
77
+ * @property {string} tmpDir
78
+ * @property {string} cacheDir
79
+ * @property {string} crashDir
80
+ * @property {number} debugPort Where the window can be driven from.
81
+ * @property {number} inspectPort Where the main process can be read from.
82
+ * @property {string} identity A name for this run, for apps that register one
83
+ * somewhere — a relay slot, a device id, an account.
84
+ * @property {string[]} args Command line flags that put all of that into force.
85
+ * @property {Record<string, string>} env Environment that does the same for anything
86
+ * the flags do not cover.
87
+ * @property {(close: () => Promise<void>|void) => void} closeFirst
88
+ * Register something that must be hung up BEFORE the
89
+ * app is asked to quit. A debugger still attached
90
+ * holds the app open — measured, not guessed.
91
+ * @property {(child: import('node:child_process').ChildProcess) => void} own
92
+ * Register a process this run started.
93
+ * @property {string[]} notes Plain English, for the run's own report.
94
+ */
95
+
96
+ /**
97
+ * What actually happened when a run was torn down. Returned rather than logged, because
98
+ * "the last one is definitely gone" is a claim the next run depends on.
99
+ *
100
+ * @typedef {object} TeardownReport
101
+ * @property {boolean} proved True only when nothing is left: no process, no holder
102
+ * of the folder, both ports free again.
103
+ * @property {boolean} askedNicely The app was asked to quit and did.
104
+ * @property {boolean} hadToInsist It ignored being asked and was killed.
105
+ * @property {number[]} strays Other processes that were still pointing at this run's
106
+ * own folder, and were stopped.
107
+ * @property {number} ms How long the whole teardown took.
108
+ * @property {string} why One plain sentence a person or an agent can read.
109
+ * @property {string[]} leftBehind Anything that could not be cleaned up. Empty is the
110
+ * normal case and the only one that counts as proved.
111
+ */
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Ports
115
+ // ---------------------------------------------------------------------------
116
+
117
+ /**
118
+ * Is anything listening here?
119
+ *
120
+ * Asked by trying to LISTEN rather than by trying to connect. A port with a half-dead
121
+ * process still bound to it refuses a connection and still cannot be listened on, and it is
122
+ * the second answer that decides whether the next run can start.
123
+ *
124
+ * @param {number} port
125
+ * @returns {Promise<boolean>} true when the port is free
126
+ */
127
+ export function portFree(port) {
128
+ return new Promise((resolve) => {
129
+ const server = net.createServer();
130
+ const done = (/** @type {boolean} */ answer) => {
131
+ server.removeAllListeners();
132
+ try { server.close(); } catch { /* already closed */ }
133
+ resolve(answer);
134
+ };
135
+ server.once('error', () => done(false));
136
+ server.once('listening', () => done(true));
137
+ try { server.listen(port, '127.0.0.1'); } catch { done(false); }
138
+ });
139
+ }
140
+
141
+ /**
142
+ * A port nobody else is on, and nobody else in this process is about to be on.
143
+ *
144
+ * The operating system is asked for a free one rather than a number being guessed, and the
145
+ * answer is remembered so that reserving two isolations in a row cannot hand out the same
146
+ * number twice before either of them has actually bound it.
147
+ *
148
+ * @returns {Promise<number>}
149
+ */
150
+ export async function takePort() {
151
+ for (let attempt = 0; attempt < 40; attempt += 1) {
152
+ const port = await /** @type {Promise<number>} */ (new Promise((resolve, reject) => {
153
+ const server = net.createServer();
154
+ server.once('error', reject);
155
+ server.listen(0, '127.0.0.1', () => {
156
+ const address = server.address();
157
+ const chosen = address && typeof address === 'object' ? address.port : 0;
158
+ server.close(() => (chosen ? resolve(chosen) : reject(new Error('no port'))));
159
+ });
160
+ }));
161
+ if (PORTS_THAT_ARE_NOT_OURS.has(port) || handedOut.has(port)) continue;
162
+ handedOut.add(port);
163
+ return port;
164
+ }
165
+ throw new Error('Could not find a free port to talk to the app on after forty tries.');
166
+ }
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // Who is holding what
170
+ // ---------------------------------------------------------------------------
171
+
172
+ /**
173
+ * Every process on this machine whose command line contains `marker`.
174
+ *
175
+ * The marker is always this run's own folder path, which has a random part in it, so a
176
+ * process that names it was started by this run — by us directly, or by the app we started.
177
+ * Nothing else can be pointing at a folder we invented sixty seconds ago.
178
+ *
179
+ * @param {string} marker
180
+ * @returns {Promise<{pid: number, command: string}[]>}
181
+ */
182
+ export async function whoIsUsing(marker) {
183
+ if (!marker || marker.length < 8) return [];
184
+ try {
185
+ const { stdout } = await execFileAsync('/bin/ps', ['-axo', 'pid=,command='], {
186
+ timeout: 8000,
187
+ maxBuffer: 8 * 1024 * 1024,
188
+ });
189
+ /** @type {{pid: number, command: string}[]} */
190
+ const found = [];
191
+ for (const line of stdout.split('\n')) {
192
+ if (!line.includes(marker)) continue;
193
+ const match = line.trim().match(/^(\d+)\s+(.*)$/);
194
+ if (!match) continue;
195
+ const pid = Number(match[1]);
196
+ if (pid === process.pid) continue;
197
+ found.push({ pid, command: match[2] });
198
+ }
199
+ return found;
200
+ } catch {
201
+ // No `ps` (an unusual Linux container) is not a reason to fail a run. It is a reason to
202
+ // say the sweep could not be done, which the caller does.
203
+ return [];
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Every process descended from these, however deep.
209
+ *
210
+ * A desktop app is a family, not a process: helpers for the screen and for the network, one
211
+ * per window, plus whatever the app itself started — a shell, a language server, an agent.
212
+ * The grandchildren are the ones that bite. They inherit the run's throwaway home folder but
213
+ * they do NOT carry it on their command line, so a sweep that only matches the folder path
214
+ * misses them entirely — and one of them writing a file a moment after the folder was deleted
215
+ * puts the folder back. Measured exactly that way: a shell the app started recreated its own
216
+ * dot-folder inside a home directory that had already been cleaned up.
217
+ *
218
+ * The family has to be read BEFORE the parent is killed. Once it dies its children are
219
+ * adopted by the system and the trail is gone.
220
+ *
221
+ * @param {number[]} pids
222
+ * @returns {Promise<number[]>} the descendants, not including the ones passed in
223
+ */
224
+ export async function descendantsOf(pids) {
225
+ const roots = pids.filter((pid) => Number.isInteger(pid) && pid > 0);
226
+ if (roots.length === 0) return [];
227
+ /** @type {Map<number, number[]>} */
228
+ const childrenOf = new Map();
229
+ try {
230
+ const { stdout } = await execFileAsync('/bin/ps', ['-axo', 'pid=,ppid='], { timeout: 8000, maxBuffer: 8 * 1024 * 1024 });
231
+ for (const line of stdout.split('\n')) {
232
+ const match = line.trim().match(/^(\d+)\s+(\d+)$/);
233
+ if (!match) continue;
234
+ const pid = Number(match[1]);
235
+ const parent = Number(match[2]);
236
+ const list = childrenOf.get(parent) ?? [];
237
+ list.push(pid);
238
+ childrenOf.set(parent, list);
239
+ }
240
+ } catch {
241
+ return [];
242
+ }
243
+ /** @type {Set<number>} */
244
+ const found = new Set();
245
+ /** @type {number[]} */
246
+ const queue = [...roots];
247
+ while (queue.length > 0) {
248
+ const pid = /** @type {number} */ (queue.shift());
249
+ for (const child of childrenOf.get(pid) ?? []) {
250
+ if (found.has(child) || roots.includes(child) || child === process.pid) continue;
251
+ found.add(child);
252
+ queue.push(child);
253
+ }
254
+ }
255
+ return [...found];
256
+ }
257
+
258
+ /** @param {number} pid */
259
+ function stillThere(pid) {
260
+ try { process.kill(pid, 0); return true; } catch { return false; }
261
+ }
262
+
263
+ // ---------------------------------------------------------------------------
264
+ // Reserving one
265
+ // ---------------------------------------------------------------------------
266
+
267
+ /**
268
+ * Somewhere that is definitely not the person's real data.
269
+ *
270
+ * A bug that pointed `--user-data-dir` at a real settings folder would not throw and would
271
+ * not look wrong; it would quietly run a test build against somebody's actual sessions. So
272
+ * the check is made once, here, loudly, on a rule with no judgement in it: the folder has to
273
+ * be inside the scratch folder the engine handed us.
274
+ *
275
+ * @param {string} scratchDir
276
+ * @param {string} dir
277
+ */
278
+ function refuseIfNotScratch(scratchDir, dir) {
279
+ const inside = path.resolve(dir) + path.sep;
280
+ const root = path.resolve(scratchDir) + path.sep;
281
+ if (!inside.startsWith(root)) {
282
+ throw new Error(
283
+ `Stays Fixed will not point an app at "${dir}", because it is not inside the scratch folder "${scratchDir}". ` +
284
+ 'A run always gets its own throwaway settings folder — never a real one.',
285
+ );
286
+ }
287
+ for (const real of [
288
+ path.join(os.homedir(), 'Library', 'Application Support'),
289
+ path.join(os.homedir(), '.config'),
290
+ path.join(os.homedir(), 'AppData'),
291
+ ]) {
292
+ if (inside.startsWith(path.resolve(real) + path.sep)) {
293
+ throw new Error(`Stays Fixed will not point an app at "${dir}" — that is where real settings live.`);
294
+ }
295
+ }
296
+ }
297
+
298
+ /**
299
+ * The flags that make one desktop app run alone, and paint the same way twice.
300
+ *
301
+ * Two separate jobs in one list, and it is worth knowing which is which. The first four are
302
+ * ISOLATION: its own settings, its own cache, its own crash folder, its own debugging ports.
303
+ * The rest are DETERMINISM: the same pixel grid, no animation half-finished at the moment
304
+ * something is read, and no slowing-down when the window is behind another window — which
305
+ * otherwise turns "wait for the list to appear" into a random timeout.
306
+ *
307
+ * @param {Isolation} isolation
308
+ * @param {{scale?: number, brk?: boolean}} [opts]
309
+ * @returns {string[]}
310
+ */
311
+ export function isolationArgs(isolation, opts = {}) {
312
+ const scale = opts.scale ?? 1;
313
+ return [
314
+ `--user-data-dir=${isolation.userDataDir}`,
315
+ `--disk-cache-dir=${isolation.cacheDir}`,
316
+ `--crash-dumps-dir=${isolation.crashDir}`,
317
+ `--remote-debugging-port=${isolation.debugPort}`,
318
+ '--remote-allow-origins=*',
319
+ `${opts.brk === false ? '--inspect' : '--inspect-brk'}=${isolation.inspectPort}`,
320
+ `--force-device-scale-factor=${scale}`,
321
+ '--force-color-profile=srgb',
322
+ '--disable-lcd-text',
323
+ '--font-render-hinting=none',
324
+ '--force-prefers-reduced-motion',
325
+ '--disable-background-timer-throttling',
326
+ '--disable-renderer-backgrounding',
327
+ '--disable-backgrounding-occluded-windows',
328
+ ];
329
+ }
330
+
331
+ /**
332
+ * Set one run up: its own folders, its own ports, its own identity.
333
+ *
334
+ * Nothing is started here. Reserving and launching are separate on purpose — the engine
335
+ * reserves, checks it really is alone, and only then opens anything.
336
+ *
337
+ * @param {object} opts
338
+ * @param {string} opts.scratchDir Where the engine says this run may write.
339
+ * @param {string} [opts.label] Plain English name for the build being run.
340
+ * @param {string} [opts.appId] Something stable about the app, so its identity is
341
+ * stable too: a bundle id, a product name.
342
+ * @param {string} [opts.clock] ISO time the app should believe it is.
343
+ * @param {number} [opts.seed]
344
+ * @param {Record<string, string>} [opts.identityEnv]
345
+ * Environment variables this app uses to know who it
346
+ * is. Any `{identity}` in a value is filled in. This is
347
+ * the fifth collision — the relay slot — and it is the
348
+ * one the tool cannot detect for itself.
349
+ * @param {Record<string, string>} [opts.env] Anything else the project wants set.
350
+ * @returns {Promise<Isolation>}
351
+ */
352
+ export async function reserveIsolation(opts) {
353
+ const id = crypto.randomBytes(5).toString('hex');
354
+ const label = opts.label ?? 'this build';
355
+ const dir = path.join(opts.scratchDir, `run-${id}`);
356
+ refuseIfNotScratch(opts.scratchDir, dir);
357
+
358
+ const userDataDir = path.join(dir, 'settings');
359
+ const homeDir = path.join(dir, 'home');
360
+ const tmpDir = path.join(dir, 'tmp');
361
+ const cacheDir = path.join(dir, 'cache');
362
+ const crashDir = path.join(dir, 'crashes');
363
+ for (const folder of [userDataDir, homeDir, tmpDir, cacheDir, crashDir]) {
364
+ await fsp.mkdir(folder, { recursive: true });
365
+ }
366
+
367
+ const debugPort = await takePort();
368
+ const inspectPort = await takePort();
369
+
370
+ // Deliberately the SAME name every time this app is checked, rather than a new one per
371
+ // run. Runs are sequential and the previous one is proved gone before the next starts, so
372
+ // one name is never in two places at once — and a name that changed every run would show
373
+ // up as a difference in every single report.
374
+ const identity = `staysfixed-${crypto.createHash('sha256').update(String(opts.appId ?? label)).digest('hex').slice(0, 10)}`;
375
+
376
+ /** @type {Record<string, string>} */
377
+ const identityEnv = {};
378
+ for (const [key, value] of Object.entries(opts.identityEnv ?? {})) {
379
+ identityEnv[key] = String(value).split('{identity}').join(identity);
380
+ }
381
+
382
+ /** @type {(() => Promise<void>|void)[]} */
383
+ const closers = [];
384
+ /** @type {import('node:child_process').ChildProcess[]} */
385
+ const children = [];
386
+
387
+ /** @type {Isolation} */
388
+ const isolation = {
389
+ id,
390
+ label,
391
+ dir,
392
+ userDataDir,
393
+ homeDir,
394
+ tmpDir,
395
+ cacheDir,
396
+ crashDir,
397
+ debugPort,
398
+ inspectPort,
399
+ identity,
400
+ args: [],
401
+ env: {
402
+ PATH: process.env.PATH ?? '/usr/bin:/bin:/usr/sbin:/sbin',
403
+ HOME: homeDir,
404
+ TMPDIR: tmpDir,
405
+ TEMP: tmpDir,
406
+ TMP: tmpDir,
407
+ XDG_CONFIG_HOME: path.join(homeDir, '.config'),
408
+ XDG_CACHE_HOME: cacheDir,
409
+ XDG_DATA_HOME: path.join(homeDir, '.local', 'share'),
410
+ TZ: 'UTC',
411
+ LANG: 'en_US.UTF-8',
412
+ NO_COLOR: '1',
413
+ // An app that seeds itself from here gets the same seed both runs, which is the point.
414
+ STAYSFIXED: '1',
415
+ STAYSFIXED_SEED: String(opts.seed ?? 1),
416
+ STAYSFIXED_CLOCK: opts.clock ?? new Date().toISOString(),
417
+ STAYSFIXED_IDENTITY: identity,
418
+ // Nothing being checked should be phoning home about itself.
419
+ ELECTRON_NO_ATTACH_CONSOLE: '1',
420
+ ELECTRON_ENABLE_LOGGING: '1',
421
+ ...identityEnv,
422
+ ...opts.env,
423
+ },
424
+ closeFirst(close) { closers.push(close); },
425
+ own(child) { children.push(child); },
426
+ notes: [
427
+ `This run has its own settings folder, its own cache, its own two debugging ports and the name "${identity}".`,
428
+ 'Nothing here is shared with the real app, so the two cannot displace each other.',
429
+ Object.keys(identityEnv).length > 0
430
+ ? `The app is told who it is through ${Object.keys(identityEnv).join(', ')}, so it registers as itself and never as the real install.`
431
+ : 'This app was not told to register under a different name. If it signs in to something with a device id, name that setting under "electron.identityEnv" in the config so two runs cannot fight over one slot.',
432
+ ],
433
+ };
434
+ isolation.args = isolationArgs(isolation);
435
+
436
+ alive.set(id, { isolation, closers, children });
437
+ armTheSafetyNet();
438
+ return isolation;
439
+ }
440
+
441
+ /**
442
+ * Is this run really alone before anything opens?
443
+ *
444
+ * Cheap, and it is the check that turns "we made a new folder so it must be fine" into
445
+ * something known. It catches the case that actually happens: a previous run that was
446
+ * killed rudely and left a copy of the app still running on its ports.
447
+ *
448
+ * @param {Isolation} isolation
449
+ * @returns {Promise<{alone: boolean, why: string, holders: {pid: number, command: string}[]}>}
450
+ */
451
+ export async function verifyAlone(isolation) {
452
+ const holders = await whoIsUsing(isolation.dir);
453
+ const debugFree = await portFree(isolation.debugPort);
454
+ const inspectFree = await portFree(isolation.inspectPort);
455
+ if (holders.length === 0 && debugFree && inspectFree) {
456
+ return { alone: true, why: 'Nothing else is using this run\'s folder or either of its ports, so it starts alone.', holders };
457
+ }
458
+ /** @type {string[]} */
459
+ const problems = [];
460
+ if (holders.length > 0) problems.push(`${holders.length} process${holders.length === 1 ? ' is' : 'es are'} still using this run's own folder`);
461
+ if (!debugFree) problems.push(`something is already listening on port ${isolation.debugPort}`);
462
+ if (!inspectFree) problems.push(`something is already listening on port ${isolation.inspectPort}`);
463
+ return { alone: false, why: `This run is not alone yet: ${problems.join(', ')}.`, holders };
464
+ }
465
+
466
+ // ---------------------------------------------------------------------------
467
+ // Starting one
468
+ // ---------------------------------------------------------------------------
469
+
470
+ /**
471
+ * What a started app is, from the outside.
472
+ *
473
+ * @typedef {object} StartedApp
474
+ * @property {import('node:child_process').ChildProcess} child
475
+ * @property {number} pid
476
+ * @property {() => string} said Everything it has printed so far, both streams.
477
+ * @property {() => {code: number|null, signal: string|null}|null} finished
478
+ * Null while it is still running.
479
+ */
480
+
481
+ /**
482
+ * Start the app inside one isolation.
483
+ *
484
+ * @param {Isolation} isolation
485
+ * @param {object} opts
486
+ * @param {string} opts.binary The executable — inside the bundle on a Mac.
487
+ * @param {string[]} [opts.extraArgs] Anything the project wants added.
488
+ * @param {string} [opts.cwd]
489
+ * @param {AbortSignal} [opts.signal]
490
+ * @returns {StartedApp}
491
+ */
492
+ export function startIsolated(isolation, opts) {
493
+ const child = spawn(opts.binary, [...isolation.args, ...(opts.extraArgs ?? [])], {
494
+ cwd: opts.cwd,
495
+ env: isolation.env,
496
+ stdio: ['ignore', 'pipe', 'pipe'],
497
+ signal: opts.signal,
498
+ });
499
+ isolation.own(child);
500
+
501
+ /** @type {string[]} */
502
+ const lines = [];
503
+ let bytes = 0;
504
+ const keep = (/** @type {Buffer} */ chunk) => {
505
+ const text = String(chunk);
506
+ bytes += text.length;
507
+ if (bytes > 256 * 1024) return;
508
+ lines.push(text);
509
+ };
510
+ child.stdout?.on('data', keep);
511
+ child.stderr?.on('data', keep);
512
+
513
+ /** @type {{code: number|null, signal: string|null}|null} */
514
+ let ended = null;
515
+ child.once('exit', (code, signal) => { ended = { code, signal }; });
516
+ // A quit later on must never take the tool down with an unhandled error event.
517
+ child.on('error', () => {});
518
+
519
+ return {
520
+ child,
521
+ pid: child.pid ?? -1,
522
+ said: () => lines.join(''),
523
+ finished: () => ended,
524
+ };
525
+ }
526
+
527
+ // ---------------------------------------------------------------------------
528
+ // Proving it is gone
529
+ // ---------------------------------------------------------------------------
530
+
531
+ /**
532
+ * Stop everything this run started, and prove it stopped.
533
+ *
534
+ * The order is not arbitrary; each step was measured on a real app.
535
+ *
536
+ * 1. Hang up every debugging connection FIRST. An Electron app with a debugger still
537
+ * attached prints "Waiting for the debugger to disconnect..." and ignores being asked
538
+ * to quit. Measured on Terminal Deck 0.15.0: with the socket open it survived a polite
539
+ * request; with the socket closed it quit in well under a second.
540
+ * 2. Ask the app to quit, and give it a moment — a desktop app saves its state on the way
541
+ * out, and killing it mid-save is how a run corrupts its own scratch data.
542
+ * 3. Insist, if it ignored being asked.
543
+ * 4. Sweep. A desktop app is not one process: there is a helper for the screen, one per
544
+ * window, one for the network, and whatever the app itself started. They all carry this
545
+ * run's own folder on their command line, which is how they are told from everybody
546
+ * else's, and nothing without that marker is ever touched.
547
+ * 5. Check the ports are free again, because the next run needs them and a port that is
548
+ * still held is the clearest possible proof that something survived.
549
+ *
550
+ * @param {Isolation} isolation
551
+ * @param {{graceMs?: number, keepFolder?: boolean}} [opts]
552
+ * @returns {Promise<TeardownReport>}
553
+ */
554
+ export async function releaseIsolation(isolation, opts = {}) {
555
+ const started = Date.now();
556
+ const graceMs = opts.graceMs ?? 5000;
557
+ const held = alive.get(isolation.id);
558
+ const closers = held?.closers ?? [];
559
+ const children = held?.children ?? [];
560
+ alive.delete(isolation.id);
561
+ handedOut.delete(isolation.debugPort);
562
+ handedOut.delete(isolation.inspectPort);
563
+
564
+ /** @type {string[]} */
565
+ const leftBehind = [];
566
+
567
+ // 1 — hang up, before asking anything to quit.
568
+ for (const close of closers) {
569
+ try { await close(); } catch { /* a connection that will not close is one we are leaving anyway */ }
570
+ }
571
+
572
+ // 2 — read the whole family while the trail still exists, then ask the app to quit.
573
+ const family = await descendantsOf(children.map((c) => c.pid ?? 0));
574
+ let askedNicely = false;
575
+ for (const child of children) {
576
+ if (child.exitCode !== null || child.signalCode !== null) { askedNicely = true; continue; }
577
+ try { child.kill('SIGTERM'); } catch { /* already gone */ }
578
+ }
579
+ const until = Date.now() + graceMs;
580
+ while (Date.now() < until) {
581
+ if (children.every((c) => c.exitCode !== null || c.signalCode !== null || !c.pid || !stillThere(c.pid))) {
582
+ askedNicely = true;
583
+ break;
584
+ }
585
+ await wait(100);
586
+ }
587
+
588
+ // 3 — insist.
589
+ let hadToInsist = false;
590
+ for (const child of children) {
591
+ if (!child.pid || !stillThere(child.pid)) continue;
592
+ hadToInsist = true;
593
+ try { child.kill('SIGKILL'); } catch { /* gone between the two lines */ }
594
+ }
595
+ if (hadToInsist) await wait(600);
596
+
597
+ // 4 — sweep. First everything the app itself started, then everything still pointing at
598
+ // this run's own folder. The two catch different things and both are needed: a helper
599
+ // process names the folder, a grandchild only inherits it.
600
+ /** @type {number[]} */
601
+ const strays = [];
602
+ for (const signal of /** @type {const} */ (['SIGTERM', 'SIGKILL'])) {
603
+ const survivors = family.filter((pid) => stillThere(pid));
604
+ if (survivors.length === 0) break;
605
+ for (const pid of survivors) {
606
+ strays.push(pid);
607
+ try { process.kill(pid, signal); } catch { /* it ended on its own */ }
608
+ }
609
+ await wait(signal === 'SIGTERM' ? 500 : 300);
610
+ }
611
+ for (const round of [0, 1]) {
612
+ const holders = await whoIsUsing(isolation.dir);
613
+ if (holders.length === 0) break;
614
+ for (const holder of holders) {
615
+ strays.push(holder.pid);
616
+ try { process.kill(holder.pid, round === 0 ? 'SIGTERM' : 'SIGKILL'); } catch { /* it ended on its own */ }
617
+ }
618
+ await wait(round === 0 ? 700 : 400);
619
+ }
620
+ const stubborn = await whoIsUsing(isolation.dir);
621
+ for (const holder of stubborn) {
622
+ leftBehind.push(`process ${holder.pid} is still running and still pointing at this run's folder`);
623
+ }
624
+
625
+ // 5 — the ports have to come back.
626
+ /** @type {[number, string][]} */
627
+ const portsToCheck = [[isolation.debugPort, 'the window'], [isolation.inspectPort, 'the main process']];
628
+ for (const [port, what] of portsToCheck) {
629
+ let free = false;
630
+ for (let i = 0; i < 20 && !free; i += 1) {
631
+ free = await portFree(port);
632
+ if (!free) await wait(150);
633
+ }
634
+ if (!free) leftBehind.push(`port ${port}, which was how ${what} was read, is still held by something`);
635
+ }
636
+
637
+ if (!opts.keepFolder) {
638
+ // Twice, with a pause. The first pass can race a process that was in the middle of
639
+ // writing; if the folder comes back, something is still alive that should not be, and the
640
+ // second pass is what turns that from a silent leftover into something said out loud.
641
+ try {
642
+ await fsp.rm(isolation.dir, { recursive: true, force: true });
643
+ await wait(250);
644
+ await fsp.rm(isolation.dir, { recursive: true, force: true });
645
+ await fsp.stat(isolation.dir).then(
646
+ () => leftBehind.push("the run's folder keeps coming back, so something of its is still running"),
647
+ () => {},
648
+ );
649
+ } catch (e) {
650
+ leftBehind.push(`the run's folder could not be deleted: ${e instanceof Error ? e.message : String(e)}`);
651
+ }
652
+ }
653
+
654
+ const ms = Date.now() - started;
655
+ const proved = leftBehind.length === 0;
656
+ const uniqueStrays = [...new Set(strays)];
657
+ return {
658
+ proved,
659
+ askedNicely,
660
+ hadToInsist,
661
+ strays: uniqueStrays,
662
+ ms,
663
+ leftBehind,
664
+ why: proved
665
+ ? `${isolation.label} was closed and is gone: ${askedNicely && !hadToInsist ? 'it quit when asked' : 'it had to be stopped'}, ` +
666
+ `${uniqueStrays.length === 0 ? 'nothing else of its was left running' : `${uniqueStrays.length} helper process${uniqueStrays.length === 1 ? '' : 'es'} of its were stopped too`}, ` +
667
+ 'and both of its ports are free again. The next run starts alone.'
668
+ : `${isolation.label} may not be completely gone: ${leftBehind.join('; ')}. ` +
669
+ 'The next run must not start until this is clear, because two copies of one app fight over the same lock, the same settings and the same identity.',
670
+ };
671
+ }
672
+
673
+ /**
674
+ * Everything still open, closed. Called when a check ends — including when it throws.
675
+ *
676
+ * A scratch copy of somebody's app left running on their screen is the single rudest thing
677
+ * this tool could do, and an exception halfway through a check is exactly when it would
678
+ * happen.
679
+ *
680
+ * @returns {Promise<TeardownReport[]>}
681
+ */
682
+ export async function releaseEverything() {
683
+ /** @type {TeardownReport[]} */
684
+ const reports = [];
685
+ for (const held of [...alive.values()]) {
686
+ reports.push(await releaseIsolation(held.isolation));
687
+ }
688
+ return reports;
689
+ }
690
+
691
+ /**
692
+ * The last-resort version, for the moment the whole program is going away.
693
+ *
694
+ * Exit handlers cannot wait for anything, so this one is deliberately blunt and synchronous:
695
+ * it signals what it knows about and returns. The full, proved teardown is
696
+ * {@link releaseEverything}; this is only there so a crash does not leave a window open.
697
+ */
698
+ function killOnTheWayOut() {
699
+ for (const held of alive.values()) {
700
+ for (const child of held.children) {
701
+ if (!child.pid) continue;
702
+ try { child.kill('SIGKILL'); } catch { /* nothing left to do about it */ }
703
+ }
704
+ }
705
+ alive.clear();
706
+ }
707
+
708
+ let netArmed = false;
709
+ /** Put the safety net up once, however many isolations are reserved. */
710
+ function armTheSafetyNet() {
711
+ if (netArmed) return;
712
+ netArmed = true;
713
+ process.once('exit', killOnTheWayOut);
714
+ for (const signal of /** @type {const} */ (['SIGINT', 'SIGTERM', 'SIGHUP'])) {
715
+ process.once(signal, () => { killOnTheWayOut(); process.exit(130); });
716
+ }
717
+ }
718
+
719
+ /**
720
+ * How many runs this process still has open. For a test, and for a report that wants to say
721
+ * out loud that it left nothing behind.
722
+ */
723
+ export function stillOpen() {
724
+ return alive.size;
725
+ }
726
+
727
+ /**
728
+ * The isolation, said in plain English, for the run's own report.
729
+ * @param {Isolation} isolation
730
+ * @returns {string}
731
+ */
732
+ export function describeIsolation(isolation) {
733
+ return (
734
+ `"${isolation.label}" runs on its own: settings in a throwaway folder, the window read on port ` +
735
+ `${isolation.debugPort}, the main process read on port ${isolation.inspectPort}, and the name ` +
736
+ `"${isolation.identity}" so it never takes the real app's place anywhere it signs in. ` +
737
+ 'Only one copy is ever open at a time, and the previous one is proved gone before the next starts.'
738
+ );
739
+ }