staysfixed 0.3.0 → 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.
- package/README.md +534 -402
- package/package.json +8 -3
- package/src/cli/index.js +14 -0
- package/src/v2/adapters/android-driver.js +1705 -0
- package/src/v2/adapters/android.js +1117 -0
- package/src/v2/adapters/contract.js +565 -0
- package/src/v2/adapters/electron.js +1594 -0
- package/src/v2/adapters/http.js +733 -0
- package/src/v2/adapters/ios-driver.js +1551 -0
- package/src/v2/adapters/ios.js +989 -0
- package/src/v2/adapters/isolate.js +739 -0
- package/src/v2/adapters/process.js +920 -0
- package/src/v2/adapters/source.js +1241 -0
- package/src/v2/adapters/web-driver.js +1532 -0
- package/src/v2/adapters/web.js +1009 -0
- package/src/v2/adapters/windows.js +1329 -0
- package/src/v2/browsers.js +1203 -0
- package/src/v2/cause.js +364 -0
- package/src/v2/check.js +1331 -0
- package/src/v2/ci.js +1209 -0
- package/src/v2/cli.js +657 -0
- package/src/v2/cluster.js +372 -0
- package/src/v2/coverage.js +1116 -0
- package/src/v2/detect.js +1199 -0
- package/src/v2/doctor.js +1690 -0
- package/src/v2/escalate.js +679 -0
- package/src/v2/init.js +1394 -0
- package/src/v2/intent.js +659 -0
- package/src/v2/journeys/from-routes.js +498 -0
- package/src/v2/journeys/from-suite.js +988 -0
- package/src/v2/journeys/index.js +651 -0
- package/src/v2/journeys/record.js +516 -0
- package/src/v2/mcp/server.js +374 -0
- package/src/v2/mcp/tools.js +1571 -0
- package/src/v2/normalise.js +783 -0
- package/src/v2/observation.js +877 -0
- package/src/v2/rank.js +672 -0
- package/src/v2/reference.js +1051 -0
- package/src/v2/remote.js +911 -0
- package/src/v2/run.js +964 -0
- package/src/v2/sealed.js +564 -0
- package/src/v2/selfcheck.js +564 -0
- package/src/v2/ship.js +684 -0
- package/src/v2/store.js +703 -0
- package/src/v2/types.js +503 -0
- package/src/v2/waiver.js +511 -0
- package/src/watch/panel.js +73 -44
|
@@ -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
|
+
}
|