@juxtapose/core 0.5.0 → 0.7.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/dist/browser/acquire.d.ts +5 -1
- package/dist/browser/acquire.js +14 -3
- package/dist/browser/acquire.js.map +1 -1
- package/dist/browser/browser.d.ts +1 -1
- package/dist/browser/daemon/client.d.ts +145 -0
- package/dist/browser/daemon/client.js +764 -0
- package/dist/browser/daemon/client.js.map +1 -0
- package/dist/browser/daemon/state.d.ts +99 -0
- package/dist/browser/daemon/state.js +297 -0
- package/dist/browser/daemon/state.js.map +1 -0
- package/dist/browser/daemon/supervisor-main.d.ts +1 -0
- package/dist/browser/daemon/supervisor-main.js +54 -0
- package/dist/browser/daemon/supervisor-main.js.map +1 -0
- package/dist/browser/daemon/supervisor.d.ts +37 -0
- package/dist/browser/daemon/supervisor.js +777 -0
- package/dist/browser/daemon/supervisor.js.map +1 -0
- package/dist/{capture/launch.js → browser/flags.js} +1 -1
- package/dist/browser/flags.js.map +1 -0
- package/dist/browser/index.d.ts +7 -2
- package/dist/browser/index.js +6 -2
- package/dist/browser/index.js.map +1 -1
- package/dist/browser/launch.js +4 -15
- package/dist/browser/launch.js.map +1 -1
- package/dist/browser/page.d.ts +1 -1
- package/dist/browser/private-dir.d.ts +7 -0
- package/dist/browser/private-dir.js +24 -0
- package/dist/browser/private-dir.js.map +1 -0
- package/dist/browser/resolve.d.ts +6 -0
- package/dist/browser/resolve.js +11 -0
- package/dist/browser/resolve.js.map +1 -1
- package/dist/browser/transport.d.ts +12 -0
- package/dist/browser/transport.js +26 -1
- package/dist/browser/transport.js.map +1 -1
- package/dist/browser/types.d.ts +4 -0
- package/dist/browser/types.js +2 -0
- package/dist/browser/types.js.map +1 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +9 -4
- package/dist/types.js +0 -2
- package/dist/types.js.map +1 -1
- package/package.json +2 -1
- package/dist/capture/launch.js.map +0 -1
- /package/dist/{capture/launch.d.ts → browser/flags.d.ts} +0 -0
|
@@ -0,0 +1,764 @@
|
|
|
1
|
+
// packages/core/src/browser/daemon/client.ts
|
|
2
|
+
//
|
|
3
|
+
// Try the warm browser; never let it cost correctness or a hang. Every
|
|
4
|
+
// failure here (no daemon, stale file, refused hello, a spawn that dies, the
|
|
5
|
+
// race lost and the winner slow, a socket we do not trust) ends in the same
|
|
6
|
+
// private launch PR 1 ships, with one JUX_DEBUG line saying why. The budget
|
|
7
|
+
// is 2 s to reach a daemon this call spawned or found; a client that finds
|
|
8
|
+
// another launch in progress (a live lock holder) waits for it 3 s longer
|
|
9
|
+
// rather than starting a second Chrome, and no longer, so a Ctrl-Z'd jux
|
|
10
|
+
// holding the lock stalls nobody.
|
|
11
|
+
//
|
|
12
|
+
// Ruling 24: the only socket ever connected to is the one computed from the
|
|
13
|
+
// cache dir and key, and only once it is a 0600 socket of ours in a verified
|
|
14
|
+
// private directory. The path in the state file is never trusted.
|
|
15
|
+
//
|
|
16
|
+
// Once connected, the Browser is the daemon's: if the daemon dies mid-run,
|
|
17
|
+
// the connection closes, isConnected() goes false and the pool reports
|
|
18
|
+
// BrowserDiedError exactly as for a private Chrome that died (PR 1).
|
|
19
|
+
import { execFile, spawn } from 'node:child_process';
|
|
20
|
+
import { createHash } from 'node:crypto';
|
|
21
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
22
|
+
import { lstat, readdir, readFile, rm, stat } from 'node:fs/promises';
|
|
23
|
+
import net from 'node:net';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
import { chromeArgs } from '../flags.js';
|
|
27
|
+
import { Browser } from '../browser.js';
|
|
28
|
+
import { CdpConnection } from '../cdp.js';
|
|
29
|
+
import { CHROME_VERSION } from '../pins.js';
|
|
30
|
+
import { SocketTransport } from '../transport.js';
|
|
31
|
+
import { acquireLock, daemonKey, daemonPaths, ensureDaemonDirs, handOffLock, lockHeld, pidAlive, readState, recheckDaemonDirs, releaseLockHeldBy, } from './state.js';
|
|
32
|
+
const DEFAULT_IDLE_MINUTES = 10;
|
|
33
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 2000;
|
|
34
|
+
/**
|
|
35
|
+
* A live daemon answers hello in milliseconds; a state file whose pid was
|
|
36
|
+
* reused by an unrelated process must not eat the launch budget (preflight
|
|
37
|
+
* defect 13), so the first probe gets 500 ms, not the whole 2 s.
|
|
38
|
+
*/
|
|
39
|
+
const PROBE_TIMEOUT_MS = 500;
|
|
40
|
+
/**
|
|
41
|
+
* `status`, `start` and `stop` have no capture waiting on them, so they give
|
|
42
|
+
* a live daemon the whole capture budget to answer: at 500 ms a supervisor
|
|
43
|
+
* busy parsing tens of MB of screenshot frames read as "not running", and
|
|
44
|
+
* `start` then reported that it could not start (PR #102 re-review N4).
|
|
45
|
+
*/
|
|
46
|
+
const STATUS_PROBE_TIMEOUT_MS = DEFAULT_CONNECT_TIMEOUT_MS;
|
|
47
|
+
/** How much longer than the budget a live lock holder is waited on. */
|
|
48
|
+
const HOLDER_GRACE_MS = 3000;
|
|
49
|
+
/**
|
|
50
|
+
* The supervisor touches its state file every second. Ten silent seconds
|
|
51
|
+
* means stopped or wedged, not busy: a daemon serving a 700-story run still
|
|
52
|
+
* runs its timers.
|
|
53
|
+
*/
|
|
54
|
+
const HEARTBEAT_STALE_MS = 10_000;
|
|
55
|
+
const POLL_MS = 50;
|
|
56
|
+
/** A start that exited 0 but left no state this long after is a foreign socket, not a slow winner. */
|
|
57
|
+
const FOREIGN_SETTLE_MS = 500;
|
|
58
|
+
/**
|
|
59
|
+
* `jux browser start` asked for a daemon and has nothing else to do, so it
|
|
60
|
+
* waits for a cold Chrome (first launch after a download, Gatekeeper's scan)
|
|
61
|
+
* rather than reporting failure at the 2 s capture budget.
|
|
62
|
+
*/
|
|
63
|
+
const START_TIMEOUT_MS = 30_000;
|
|
64
|
+
/** setTimeout clamps anything over 2^31-1 ms (~35791 min) to 1 ms. */
|
|
65
|
+
const MAX_IDLE_MINUTES = 35_000;
|
|
66
|
+
const DEFAULT_ENTRY = path.join(path.dirname(fileURLToPath(import.meta.url)), 'supervisor-main.js');
|
|
67
|
+
/**
|
|
68
|
+
* `CI` is the convention; these are the systems that do not set it (GitHub
|
|
69
|
+
* Actions does, but `CI` can be unset in a step's env; Jenkins sets BUILD_ID).
|
|
70
|
+
*/
|
|
71
|
+
export const CI_ENV_VARS = [
|
|
72
|
+
'CI',
|
|
73
|
+
'GITHUB_ACTIONS',
|
|
74
|
+
'JENKINS_URL',
|
|
75
|
+
'BUILD_ID',
|
|
76
|
+
'TEAMCITY_VERSION',
|
|
77
|
+
'BUILDKITE',
|
|
78
|
+
'GITLAB_CI',
|
|
79
|
+
'TF_BUILD',
|
|
80
|
+
'CODEBUILD_BUILD_ID',
|
|
81
|
+
];
|
|
82
|
+
/** Empty is unset (shells export `X=` when clearing it); `0`/`false` say "no" explicitly. */
|
|
83
|
+
const truthy = (v) => !!v && v !== '0' && v.toLowerCase() !== 'false';
|
|
84
|
+
/** Off with `--no-daemon`, JUX_NO_DAEMON (any truthy value), or any CI marker. */
|
|
85
|
+
export function daemonEnabled(env, flagNoDaemon = false) {
|
|
86
|
+
if (flagNoDaemon)
|
|
87
|
+
return false;
|
|
88
|
+
if (truthy(env.JUX_NO_DAEMON))
|
|
89
|
+
return false;
|
|
90
|
+
return !CI_ENV_VARS.some((v) => truthy(env[v]));
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* JUX_BROWSER_IDLE_MINUTES within [1, 35000], else the default (undefined).
|
|
94
|
+
* The upper bound is setTimeout's: 100000 minutes overflowed to a 1 ms idle
|
|
95
|
+
* timer and the daemon quit as soon as its last client left. Infinity used to
|
|
96
|
+
* pass too, became null in the spawn JSON, and every supervisor exited 1.
|
|
97
|
+
*/
|
|
98
|
+
export function daemonIdleMinutes(env) {
|
|
99
|
+
const raw = env.JUX_BROWSER_IDLE_MINUTES;
|
|
100
|
+
if (!raw)
|
|
101
|
+
return undefined;
|
|
102
|
+
const n = Number(raw);
|
|
103
|
+
return Number.isFinite(n) && n >= 1 && n <= MAX_IDLE_MINUTES ? n : undefined;
|
|
104
|
+
}
|
|
105
|
+
/** Stands in for the per-launch profile directory in the keyed command line. */
|
|
106
|
+
const PROFILE_PLACEHOLDER = '<profile>';
|
|
107
|
+
/**
|
|
108
|
+
* Size and mtime of the binary. An auto-updating Chrome behind --chrome or
|
|
109
|
+
* JUX_CHROME is replaced in place under the same path and pin; without this a
|
|
110
|
+
* daemon started before the update kept rendering with the old build while
|
|
111
|
+
* `--no-daemon` and CI used the new one. For our own pinned download it
|
|
112
|
+
* changes only on a reinstall, which is a new daemon either way.
|
|
113
|
+
*/
|
|
114
|
+
function binaryIdentity(file) {
|
|
115
|
+
try {
|
|
116
|
+
const st = statSync(file);
|
|
117
|
+
return `${st.size}:${st.mtimeMs}`;
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return '';
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const RELATIVE_IMPORT = /(?:\bfrom|\bimport)\s*\(?\s*['"](\.{1,2}\/[^'"]+)['"]/g;
|
|
124
|
+
/**
|
|
125
|
+
* A hash of `entry` and every file it reaches through relative imports. The
|
|
126
|
+
* supervisor that serves a client is whichever build spawned it; after a
|
|
127
|
+
* rebuild or an upgrade the supervisor's flags, allowlist or protocol can
|
|
128
|
+
* differ from this client's, and reusing it would render with the old code.
|
|
129
|
+
* A missing file hashes as missing (vitest runs the .ts sources, where the
|
|
130
|
+
* .js entry does not exist).
|
|
131
|
+
*/
|
|
132
|
+
export function buildIdentity(entry) {
|
|
133
|
+
return buildClosure(entry).hash;
|
|
134
|
+
}
|
|
135
|
+
function buildClosure(entry) {
|
|
136
|
+
const hash = createHash('sha256');
|
|
137
|
+
const seen = new Set();
|
|
138
|
+
const queue = [path.resolve(entry)];
|
|
139
|
+
while (queue.length > 0) {
|
|
140
|
+
const file = queue.shift();
|
|
141
|
+
if (seen.has(file))
|
|
142
|
+
continue;
|
|
143
|
+
seen.add(file);
|
|
144
|
+
let src;
|
|
145
|
+
try {
|
|
146
|
+
src = readFileSync(file, 'utf8');
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
hash.update(`${file}\0missing\0`);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
hash.update(`${path.basename(file)}\0${src}\0`);
|
|
153
|
+
for (const m of src.matchAll(RELATIVE_IMPORT))
|
|
154
|
+
queue.push(path.resolve(path.dirname(file), m[1]));
|
|
155
|
+
}
|
|
156
|
+
return { hash: hash.digest('hex').slice(0, 16), files: [...seen] };
|
|
157
|
+
}
|
|
158
|
+
const PACKAGE_JSON = fileURLToPath(new URL('../../../package.json', import.meta.url));
|
|
159
|
+
function packageVersion() {
|
|
160
|
+
try {
|
|
161
|
+
const pkg = JSON.parse(readFileSync(PACKAGE_JSON, 'utf8'));
|
|
162
|
+
return typeof pkg.version === 'string' ? pkg.version : '';
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return '';
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/** Size and mtime of each file, '-' for a missing one: what a rebuild or upgrade changes. */
|
|
169
|
+
function filesStamp(files) {
|
|
170
|
+
return files
|
|
171
|
+
.map((f) => {
|
|
172
|
+
try {
|
|
173
|
+
const st = statSync(f);
|
|
174
|
+
return `${f}:${st.size}:${st.mtimeMs}`;
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return `${f}:-`;
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
.join('\n');
|
|
181
|
+
}
|
|
182
|
+
const identityCache = new Map();
|
|
183
|
+
/**
|
|
184
|
+
* `<@juxtapose/core version>+<build hash of the supervisor entry>`. Cached
|
|
185
|
+
* per process, but rechecked by stat on every call (about ten files): a
|
|
186
|
+
* Storybook addon or review server that stays up across a `pnpm build` kept
|
|
187
|
+
* the old key while spawning the new supervisor-main from disk, so it never
|
|
188
|
+
* shared a daemon with the CLI and ran a mixed build (PR #102 re-review N5).
|
|
189
|
+
*/
|
|
190
|
+
export function juxIdentity(entry = DEFAULT_ENTRY) {
|
|
191
|
+
const hit = identityCache.get(entry);
|
|
192
|
+
if (hit && filesStamp(hit.files) === hit.stamp)
|
|
193
|
+
return hit.id;
|
|
194
|
+
const closure = buildClosure(entry);
|
|
195
|
+
const files = [PACKAGE_JSON, ...closure.files];
|
|
196
|
+
// Stamped before hashing would miss a write landing between the two; after
|
|
197
|
+
// is safe: a write after the stamp changes it, and the next call rehashes.
|
|
198
|
+
const id = `${packageVersion()}+${closure.hash}`;
|
|
199
|
+
identityCache.set(entry, { files, stamp: filesStamp(files), id });
|
|
200
|
+
return id;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* The daemon key for these options. It covers everything a fresh launch
|
|
204
|
+
* would render with: the binary (path, size, mtime), the pin, the full Chrome
|
|
205
|
+
* command line jux would launch, the render env and this jux build. Keys that
|
|
206
|
+
* differ name different sockets, so a daemon from an older jux or other
|
|
207
|
+
* flags is never reused; it idles out.
|
|
208
|
+
*/
|
|
209
|
+
export function daemonKeyFor(opts, jux = juxIdentity()) {
|
|
210
|
+
const binary = path.resolve(opts.executablePath);
|
|
211
|
+
return daemonKey({
|
|
212
|
+
binary,
|
|
213
|
+
binaryId: binaryIdentity(binary),
|
|
214
|
+
version: CHROME_VERSION,
|
|
215
|
+
args: chromeArgs({ userDataDir: PROFILE_PLACEHOLDER, args: opts.args }),
|
|
216
|
+
env: opts.env ?? process.env,
|
|
217
|
+
jux,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
const keyFor = (opts) => daemonKeyFor(opts);
|
|
221
|
+
/**
|
|
222
|
+
* `stateDir` and `executablePath` made absolute before anything is keyed or
|
|
223
|
+
* spawned: the supervisor runs with its cwd in the state dir, so a relative
|
|
224
|
+
* `stateDir` made it bind `<stateDir>/<stateDir>/browser/<key>.sock` while
|
|
225
|
+
* the client looked in `<stateDir>/browser`. Every run fell back to a private
|
|
226
|
+
* launch, waited out the lock grace, and left an orphan supervisor (PR #102
|
|
227
|
+
* re-review N2).
|
|
228
|
+
*/
|
|
229
|
+
function absolute(opts) {
|
|
230
|
+
return {
|
|
231
|
+
...opts,
|
|
232
|
+
stateDir: path.resolve(opts.stateDir),
|
|
233
|
+
executablePath: path.resolve(opts.executablePath),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Ruling 24: null when `paths.socket` is a 0600 socket owned by us in the
|
|
238
|
+
* verified private directories, else why not. Run right before connecting.
|
|
239
|
+
*/
|
|
240
|
+
async function untrustedSocket(paths) {
|
|
241
|
+
try {
|
|
242
|
+
await recheckDaemonDirs(paths, await ensureDaemonDirs(paths));
|
|
243
|
+
}
|
|
244
|
+
catch (e) {
|
|
245
|
+
return e instanceof Error ? e.message : String(e);
|
|
246
|
+
}
|
|
247
|
+
const st = await lstat(paths.socket).catch(() => null);
|
|
248
|
+
if (!st)
|
|
249
|
+
return null; // nothing there yet: connecting finds nobody
|
|
250
|
+
const uid = process.getuid?.();
|
|
251
|
+
if (!st.isSocket() ||
|
|
252
|
+
(st.mode & 0o777) !== 0o600 ||
|
|
253
|
+
(uid !== undefined && st.uid !== uid))
|
|
254
|
+
return `${paths.socket} is not a private socket of ours`;
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
/** One connect + hello within `timeoutMs`, to the computed socket only. */
|
|
258
|
+
function tryConnect(paths, timeoutMs) {
|
|
259
|
+
return new Promise((resolve) => {
|
|
260
|
+
let settled = false;
|
|
261
|
+
let conn = null;
|
|
262
|
+
const sock = net.connect(paths.socket);
|
|
263
|
+
const finish = (p) => {
|
|
264
|
+
if (settled)
|
|
265
|
+
return;
|
|
266
|
+
settled = true;
|
|
267
|
+
clearTimeout(timer);
|
|
268
|
+
if (p.kind !== 'ok') {
|
|
269
|
+
if (conn)
|
|
270
|
+
conn.close();
|
|
271
|
+
else
|
|
272
|
+
sock.destroy();
|
|
273
|
+
}
|
|
274
|
+
resolve(p);
|
|
275
|
+
};
|
|
276
|
+
const timer = setTimeout(() => finish({ kind: 'timeout' }), timeoutMs);
|
|
277
|
+
sock.once('error', () => {
|
|
278
|
+
if (!conn)
|
|
279
|
+
finish({ kind: 'dead' });
|
|
280
|
+
});
|
|
281
|
+
sock.once('connect', () => {
|
|
282
|
+
const t = new SocketTransport(sock);
|
|
283
|
+
const c = (conn = new CdpConnection(t));
|
|
284
|
+
c.once('jux.ready').then((p) => finish({ kind: 'ok', connection: c, product: p.product }), () => finish({ kind: 'refused', reason: 'socket closed during hello' }));
|
|
285
|
+
c.once('jux.refused').then((p) => finish({ kind: 'refused', reason: p.reason ?? 'hello refused' }), () => { });
|
|
286
|
+
t.send(JSON.stringify({ jux: 'hello', version: CHROME_VERSION }));
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
/** Verify, then connect: a socket we do not trust is never connected to. */
|
|
291
|
+
async function probe(paths, timeoutMs) {
|
|
292
|
+
const why = await untrustedSocket(paths);
|
|
293
|
+
return why ? { kind: 'refused', reason: why } : tryConnect(paths, timeoutMs);
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Start a detached supervisor-main with the caller's env: the key was computed
|
|
297
|
+
* from that env, so Chrome must render with it (TZ, LANG, fonts), not with
|
|
298
|
+
* whatever env this process happens to have. Detached in its own process
|
|
299
|
+
* group with stdio ignored: it outlives this jux, and a Ctrl-C to this
|
|
300
|
+
* terminal must not reach it.
|
|
301
|
+
*
|
|
302
|
+
* NODE_OPTIONS is not a render input and is dropped: VS Code's debug
|
|
303
|
+
* terminal (`--require …/bootloader.js`), `--inspect` or `--cpu-prof` would
|
|
304
|
+
* otherwise attach to a process that lives for hours, and `--cpu-prof` would
|
|
305
|
+
* write a profile into the project when it finally exits. The cwd is the
|
|
306
|
+
* state dir for the same reason: not the project that happened to start it.
|
|
307
|
+
*/
|
|
308
|
+
export function spawnSupervisor(args, env, entry = DEFAULT_ENTRY) {
|
|
309
|
+
const { NODE_OPTIONS: _dropped, ...childEnv } = env;
|
|
310
|
+
const child = spawn(process.execPath, [entry, JSON.stringify(args)], {
|
|
311
|
+
detached: true,
|
|
312
|
+
stdio: 'ignore',
|
|
313
|
+
windowsHide: true,
|
|
314
|
+
cwd: args.stateDir,
|
|
315
|
+
env: childEnv,
|
|
316
|
+
});
|
|
317
|
+
child.unref();
|
|
318
|
+
return child;
|
|
319
|
+
}
|
|
320
|
+
function watchChild(child, watch) {
|
|
321
|
+
// An async spawn failure is an 'error' event; unhandled, it would crash jux.
|
|
322
|
+
child.on('error', (e) => (watch.failed ??= `spawn failed: ${e.message}`));
|
|
323
|
+
child.on('exit', (code, signal) => {
|
|
324
|
+
if (code === 0)
|
|
325
|
+
watch.exitedCleanAt = Date.now();
|
|
326
|
+
else
|
|
327
|
+
watch.failed ??= `daemon exited (${signal ?? `code ${code}`})`;
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
function wrap(p) {
|
|
331
|
+
const { connection } = p;
|
|
332
|
+
return {
|
|
333
|
+
// close() drops this client's socket only; the supervisor then disposes
|
|
334
|
+
// the contexts this client still owns. The daemon and its Chrome live on.
|
|
335
|
+
browser: Browser.fromConnection(connection, {
|
|
336
|
+
version: p.product,
|
|
337
|
+
close: async () => connection.close(),
|
|
338
|
+
}),
|
|
339
|
+
warm: true,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
343
|
+
function usable(s, key, opts) {
|
|
344
|
+
return (s.key === key && s.binary === opts.executablePath && s.version === CHROME_VERSION);
|
|
345
|
+
}
|
|
346
|
+
/** Linux: /proc/<pid>/cmdline, NUL-separated. */
|
|
347
|
+
async function procCommand(pid) {
|
|
348
|
+
try {
|
|
349
|
+
return (await readFile(`/proc/${pid}/cmdline`, 'utf8')).replace(/\0/g, ' ').trim();
|
|
350
|
+
}
|
|
351
|
+
catch (e) {
|
|
352
|
+
// No such pid is an answer only when /proc is really there.
|
|
353
|
+
if (e.code === 'ENOENT')
|
|
354
|
+
return (await stat('/proc/self').catch(() => null)) ? '' : null;
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
function psCommand(pid) {
|
|
359
|
+
return new Promise((resolve) => execFile('ps', ['-o', 'command=', '-p', String(pid)], (err, out) =>
|
|
360
|
+
// ps missing (ENOENT) cannot say; a non-zero exit is "no such process".
|
|
361
|
+
resolve(err ? (typeof err.code === 'string' ? null : '') : out)));
|
|
362
|
+
}
|
|
363
|
+
export const defaultReadCommand = async (pid) => {
|
|
364
|
+
if (process.platform === 'linux') {
|
|
365
|
+
const c = await procCommand(pid);
|
|
366
|
+
if (c !== null)
|
|
367
|
+
return c;
|
|
368
|
+
}
|
|
369
|
+
return psCommand(pid);
|
|
370
|
+
};
|
|
371
|
+
/** Is `pid` our supervisor-main for `key`? 'unknown' when its command line cannot be read. */
|
|
372
|
+
async function ownership(pid, key, read = defaultReadCommand) {
|
|
373
|
+
const cmd = await read(pid);
|
|
374
|
+
if (cmd === null)
|
|
375
|
+
return 'unknown';
|
|
376
|
+
return cmd.includes('supervisor-main') && cmd.includes(key) ? 'ours' : 'foreign';
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* True when the daemon behind a timed-out probe is wedged, not busy: its
|
|
380
|
+
* heartbeat is over 10 s old and the pid really is our supervisor-main for
|
|
381
|
+
* this key (not a reused pid, which we must never signal).
|
|
382
|
+
*/
|
|
383
|
+
async function wedged(s, paths, key, read) {
|
|
384
|
+
const st = await stat(paths.state).catch(() => null);
|
|
385
|
+
if (!st || Date.now() - st.mtimeMs <= HEARTBEAT_STALE_MS)
|
|
386
|
+
return false;
|
|
387
|
+
// Unverifiable is not replaceable: we never signal what we cannot identify.
|
|
388
|
+
return (await ownership(s.pid, key, read)) === 'ours';
|
|
389
|
+
}
|
|
390
|
+
async function killWedged(pid) {
|
|
391
|
+
// SIGKILL: a stopped process does not act on SIGTERM. Its Chrome loses the
|
|
392
|
+
// pipe and exits with it.
|
|
393
|
+
try {
|
|
394
|
+
process.kill(pid, 'SIGKILL');
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
// already gone
|
|
398
|
+
}
|
|
399
|
+
for (let i = 0; i < 20 && pidAlive(pid); i++)
|
|
400
|
+
await sleep(POLL_MS);
|
|
401
|
+
}
|
|
402
|
+
/** True when something accepts a connection on `socket` (a full backlog counts). */
|
|
403
|
+
function socketLive(socket) {
|
|
404
|
+
return new Promise((resolve) => {
|
|
405
|
+
const s = net.connect(socket);
|
|
406
|
+
const done = (live) => {
|
|
407
|
+
clearTimeout(timer);
|
|
408
|
+
s.destroy();
|
|
409
|
+
resolve(live);
|
|
410
|
+
};
|
|
411
|
+
const timer = setTimeout(() => done(true), PROBE_TIMEOUT_MS);
|
|
412
|
+
s.once('connect', () => done(true));
|
|
413
|
+
s.once('error', () => done(false));
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Remove the files of the daemon `s` describes, and nothing newer. The state
|
|
418
|
+
* goes only if it still names the same pid and start time: between our read
|
|
419
|
+
* and this call another client may have spawned a replacement that wrote its
|
|
420
|
+
* own (the review traced `jux browser stop` deleting a new daemon's files
|
|
421
|
+
* while the old one's Chrome took 5 s to close, orphaning the new one). The
|
|
422
|
+
* socket goes only if it is the path that state named and nothing listens on
|
|
423
|
+
* it, compared by inode so a socket bound meanwhile is left alone.
|
|
424
|
+
*/
|
|
425
|
+
export async function clearStaleState(paths, s) {
|
|
426
|
+
const now = await readState(paths);
|
|
427
|
+
if (!now || now.pid !== s.pid || now.startedAt !== s.startedAt)
|
|
428
|
+
return;
|
|
429
|
+
await rm(paths.state, { force: true });
|
|
430
|
+
if (s.socket !== paths.socket)
|
|
431
|
+
return;
|
|
432
|
+
const before = await lstat(paths.socket).catch(() => null);
|
|
433
|
+
if (!before?.isSocket() || (await socketLive(paths.socket)))
|
|
434
|
+
return;
|
|
435
|
+
const after = await lstat(paths.socket).catch(() => null);
|
|
436
|
+
if (after && after.ino === before.ino && after.dev === before.dev)
|
|
437
|
+
await rm(paths.socket, { force: true });
|
|
438
|
+
}
|
|
439
|
+
async function connectOrSpawn(given, probeMs = PROBE_TIMEOUT_MS) {
|
|
440
|
+
const opts = absolute(given);
|
|
441
|
+
const env = opts.env ?? process.env;
|
|
442
|
+
const key = keyFor(opts);
|
|
443
|
+
const paths = daemonPaths(opts.stateDir, key);
|
|
444
|
+
const start = Date.now();
|
|
445
|
+
const budget = opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
|
|
446
|
+
const existing = await readState(paths);
|
|
447
|
+
if (existing) {
|
|
448
|
+
if (!pidAlive(existing.pid) || existing.socket !== paths.socket) {
|
|
449
|
+
// SIGKILLed daemon, a reboot, or a state naming a socket we would never
|
|
450
|
+
// connect to (Ruling 24): nothing to reuse.
|
|
451
|
+
await clearStaleState(paths, existing);
|
|
452
|
+
}
|
|
453
|
+
else if (!usable(existing, key, opts)) {
|
|
454
|
+
// Never reuse a daemon with another fingerprint (Ruling 7), and never
|
|
455
|
+
// remove a live process's files.
|
|
456
|
+
return { why: `state file names another daemon (pid ${existing.pid})` };
|
|
457
|
+
}
|
|
458
|
+
else {
|
|
459
|
+
const p = await probe(paths, probeMs);
|
|
460
|
+
if (p.kind === 'ok')
|
|
461
|
+
return wrap(p);
|
|
462
|
+
if (p.kind === 'refused')
|
|
463
|
+
return { why: `daemon refused: ${p.reason}` };
|
|
464
|
+
if (p.kind === 'timeout') {
|
|
465
|
+
if (!(await wedged(existing, paths, key, opts.readCommand)))
|
|
466
|
+
return {
|
|
467
|
+
why: `daemon pid ${existing.pid} did not answer in ${probeMs} ms`,
|
|
468
|
+
};
|
|
469
|
+
await killWedged(existing.pid);
|
|
470
|
+
}
|
|
471
|
+
// Dead (the pid was reused by an unrelated process) or killed as wedged.
|
|
472
|
+
await clearStaleState(paths, existing);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
let watch = null;
|
|
476
|
+
let release = null;
|
|
477
|
+
/** The supervisor pid our lock was handed to, if any. */
|
|
478
|
+
let handedTo = null;
|
|
479
|
+
try {
|
|
480
|
+
for (;;) {
|
|
481
|
+
const s = await readState(paths);
|
|
482
|
+
if (s && s.socket === paths.socket && usable(s, key, opts) && pidAlive(s.pid)) {
|
|
483
|
+
const remaining = start + budget - Date.now();
|
|
484
|
+
const p = await probe(paths, Math.min(probeMs, Math.max(100, remaining)));
|
|
485
|
+
if (p.kind === 'ok')
|
|
486
|
+
return wrap(p);
|
|
487
|
+
if (p.kind === 'refused')
|
|
488
|
+
return { why: `daemon refused: ${p.reason}` };
|
|
489
|
+
}
|
|
490
|
+
if (!watch) {
|
|
491
|
+
release = await acquireLock(paths);
|
|
492
|
+
if (release) {
|
|
493
|
+
watch = { failed: null, exitedCleanAt: null };
|
|
494
|
+
const args = {
|
|
495
|
+
executablePath: opts.executablePath,
|
|
496
|
+
args: opts.args ?? [],
|
|
497
|
+
stateDir: opts.stateDir,
|
|
498
|
+
key,
|
|
499
|
+
idleMinutes: opts.idleMinutes ?? DEFAULT_IDLE_MINUTES,
|
|
500
|
+
};
|
|
501
|
+
let child;
|
|
502
|
+
try {
|
|
503
|
+
child = opts.spawnDaemon
|
|
504
|
+
? opts.spawnDaemon(args, env)
|
|
505
|
+
: spawnSupervisor(args, env);
|
|
506
|
+
}
|
|
507
|
+
catch (e) {
|
|
508
|
+
return { why: `spawn failed: ${e instanceof Error ? e.message : String(e)}` };
|
|
509
|
+
}
|
|
510
|
+
if (child) {
|
|
511
|
+
watchChild(child, watch);
|
|
512
|
+
// The lock now lives as long as the supervisor's start, not as
|
|
513
|
+
// long as this client waits (see handOffLock).
|
|
514
|
+
if (child.pid !== undefined && (await handOffLock(paths, child.pid))) {
|
|
515
|
+
handedTo = child.pid;
|
|
516
|
+
release = null;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
if (watch?.failed)
|
|
522
|
+
return { why: watch.failed };
|
|
523
|
+
if (watch?.exitedCleanAt &&
|
|
524
|
+
Date.now() - watch.exitedCleanAt >= FOREIGN_SETTLE_MS &&
|
|
525
|
+
!(await readState(paths)) &&
|
|
526
|
+
(await lstat(paths.socket).catch(() => null)))
|
|
527
|
+
return {
|
|
528
|
+
why: `something else is listening on ${paths.socket}`,
|
|
529
|
+
notice: `jux: something else is listening on ${paths.socket}; using a private browser. Remove it or set JUX_STATE_DIR.`,
|
|
530
|
+
};
|
|
531
|
+
const elapsed = Date.now() - start;
|
|
532
|
+
if (elapsed >= budget) {
|
|
533
|
+
// Past the budget, only another client's launch in progress is worth
|
|
534
|
+
// waiting for, and only briefly; our own spawn had its 2 s.
|
|
535
|
+
if (watch || elapsed >= budget + HOLDER_GRACE_MS || !(await lockHeld(paths)))
|
|
536
|
+
return { why: `no daemon answered within ${elapsed} ms` };
|
|
537
|
+
}
|
|
538
|
+
await sleep(POLL_MS);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
finally {
|
|
542
|
+
if (release)
|
|
543
|
+
await release();
|
|
544
|
+
else if (handedTo !== null &&
|
|
545
|
+
(watch?.failed ||
|
|
546
|
+
watch?.exitedCleanAt ||
|
|
547
|
+
// Up before the hand-off landed: it found no lock of its own to drop.
|
|
548
|
+
(await readState(paths))?.pid === handedTo))
|
|
549
|
+
await releaseLockHeldBy(paths, handedTo);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
async function privateLaunch(opts) {
|
|
553
|
+
return {
|
|
554
|
+
browser: await Browser.launch({
|
|
555
|
+
executablePath: opts.executablePath,
|
|
556
|
+
args: opts.args,
|
|
557
|
+
}),
|
|
558
|
+
warm: false,
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* The warm daemon's browser, or a private one if the daemon cannot be had.
|
|
563
|
+
* Never rejects because of the daemon; a private launch failure rejects as in
|
|
564
|
+
* PR 1.
|
|
565
|
+
*/
|
|
566
|
+
export async function connectWarmBrowser(opts) {
|
|
567
|
+
let outcome;
|
|
568
|
+
try {
|
|
569
|
+
outcome = await connectOrSpawn(opts);
|
|
570
|
+
}
|
|
571
|
+
catch (e) {
|
|
572
|
+
outcome = { why: e instanceof Error ? e.message : String(e) };
|
|
573
|
+
}
|
|
574
|
+
const debug = (opts.env ?? process.env).JUX_DEBUG;
|
|
575
|
+
if ('browser' in outcome) {
|
|
576
|
+
// Positive evidence for check:determinism --daemon: a run whose clients
|
|
577
|
+
// all fell back privately prints none of these.
|
|
578
|
+
if (debug)
|
|
579
|
+
process.stderr.write('jux: using the warm browser\n');
|
|
580
|
+
return outcome;
|
|
581
|
+
}
|
|
582
|
+
if (outcome.notice)
|
|
583
|
+
process.stderr.write(`${outcome.notice}\n`);
|
|
584
|
+
else if (debug)
|
|
585
|
+
process.stderr.write(`jux: warm browser unavailable (${outcome.why.replace(/\s+/g, ' ')}); launching a private browser\n`);
|
|
586
|
+
return privateLaunch(opts);
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Start (or find) the daemon without a private fallback: `jux browser start`
|
|
590
|
+
* must not launch and close a whole Chrome just to report that the daemon
|
|
591
|
+
* could not start.
|
|
592
|
+
*/
|
|
593
|
+
export async function startDaemon(opts) {
|
|
594
|
+
let outcome;
|
|
595
|
+
try {
|
|
596
|
+
// A live daemon busy with another run gets the status budget to answer,
|
|
597
|
+
// not the capture path's 500 ms: `start` has nothing else to do.
|
|
598
|
+
outcome = await connectOrSpawn({ connectTimeoutMs: START_TIMEOUT_MS, ...opts }, STATUS_PROBE_TIMEOUT_MS);
|
|
599
|
+
}
|
|
600
|
+
catch (e) {
|
|
601
|
+
return { error: e instanceof Error ? e.message : String(e) };
|
|
602
|
+
}
|
|
603
|
+
if (!('browser' in outcome))
|
|
604
|
+
return { error: outcome.why };
|
|
605
|
+
await outcome.browser.close();
|
|
606
|
+
const s = await daemonStatus(opts);
|
|
607
|
+
return s ? { state: s } : { error: 'the warm browser exited right after starting' };
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* `s` if the daemon it describes is alive, else null (and a dead one's files
|
|
611
|
+
* cleared). Alive means it answered a hello on our socket, not that its pid
|
|
612
|
+
* is alive: after a reboot the pid can belong to anything, and `start` used
|
|
613
|
+
* to report "already running" for a daemon that did not exist. A hello that
|
|
614
|
+
* times out still counts when the pid is verifiably our supervisor-main for
|
|
615
|
+
* this key: a busy daemon is running. With `refusedIsLive`, so does a daemon
|
|
616
|
+
* that refuses our hello: one from a jux with another Chrome pin is still a
|
|
617
|
+
* live process to list.
|
|
618
|
+
*/
|
|
619
|
+
async function liveState(paths, s, read, refusedIsLive = false) {
|
|
620
|
+
if (!pidAlive(s.pid) || s.socket !== paths.socket) {
|
|
621
|
+
await clearStaleState(paths, s);
|
|
622
|
+
return null;
|
|
623
|
+
}
|
|
624
|
+
const p = await probe(paths, STATUS_PROBE_TIMEOUT_MS);
|
|
625
|
+
if (p.kind === 'ok') {
|
|
626
|
+
p.connection.close();
|
|
627
|
+
return s;
|
|
628
|
+
}
|
|
629
|
+
// Nothing listens: the pid was reused. A timeout or refusal is a live
|
|
630
|
+
// listener; its files are not ours to remove.
|
|
631
|
+
if (p.kind === 'dead') {
|
|
632
|
+
await clearStaleState(paths, s);
|
|
633
|
+
return null;
|
|
634
|
+
}
|
|
635
|
+
if (p.kind === 'refused' && !refusedIsLive)
|
|
636
|
+
return null;
|
|
637
|
+
return (await ownership(s.pid, s.key, read)) === 'ours' ? s : null;
|
|
638
|
+
}
|
|
639
|
+
/** The running daemon for this fingerprint, or null. A dead daemon's files are cleared. */
|
|
640
|
+
export async function daemonStatus(given) {
|
|
641
|
+
const opts = absolute(given);
|
|
642
|
+
const key = keyFor(opts);
|
|
643
|
+
const paths = daemonPaths(opts.stateDir, key);
|
|
644
|
+
const s = await readState(paths);
|
|
645
|
+
if (!s || s.key !== key) {
|
|
646
|
+
if (s && (!pidAlive(s.pid) || s.socket !== paths.socket))
|
|
647
|
+
await clearStaleState(paths, s);
|
|
648
|
+
return null;
|
|
649
|
+
}
|
|
650
|
+
return liveState(paths, s, opts.readCommand);
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Every state file under `<stateDir>/browser`, whatever key wrote it: the key
|
|
654
|
+
* covers the jux build and the binary's size and mtime, so each upgrade,
|
|
655
|
+
* rebuild or Chrome update leaves the previous daemon under a key the current
|
|
656
|
+
* jux no longer computes (PR #102 re-review N3). A file whose content names
|
|
657
|
+
* another key is skipped, never touched.
|
|
658
|
+
*/
|
|
659
|
+
async function stateFiles(stateDir) {
|
|
660
|
+
const dir = path.join(path.resolve(stateDir), 'browser');
|
|
661
|
+
const names = await readdir(dir).catch(() => []);
|
|
662
|
+
const out = [];
|
|
663
|
+
for (const name of names.sort()) {
|
|
664
|
+
if (!name.endsWith('.json') || name.startsWith('.'))
|
|
665
|
+
continue;
|
|
666
|
+
const key = name.slice(0, -'.json'.length);
|
|
667
|
+
let paths;
|
|
668
|
+
try {
|
|
669
|
+
paths = daemonPaths(path.resolve(stateDir), key);
|
|
670
|
+
}
|
|
671
|
+
catch {
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
const state = await readState(paths).catch(() => null);
|
|
675
|
+
if (state && state.key === key)
|
|
676
|
+
out.push({ paths, state });
|
|
677
|
+
}
|
|
678
|
+
return out;
|
|
679
|
+
}
|
|
680
|
+
/** Every live daemon in `stateDir`, whichever key; dead ones' files are cleared on the way. */
|
|
681
|
+
export async function listDaemons(opts) {
|
|
682
|
+
const found = await Promise.all((await stateFiles(opts.stateDir)).map(({ paths, state }) => liveState(paths, state, opts.readCommand, true)));
|
|
683
|
+
return found.filter((s) => s !== null);
|
|
684
|
+
}
|
|
685
|
+
/** SIGTERM the supervisor `s` describes (it closes Chrome and clears its state). */
|
|
686
|
+
async function stopOne(paths, s, read) {
|
|
687
|
+
if (!pidAlive(s.pid)) {
|
|
688
|
+
await clearStaleState(paths, s);
|
|
689
|
+
return { outcome: 'none' };
|
|
690
|
+
}
|
|
691
|
+
// An in-process supervisor (tests) shares our pid: signalling it would kill us.
|
|
692
|
+
if (s.pid === process.pid)
|
|
693
|
+
return { outcome: 'none' };
|
|
694
|
+
// A reused pid is somebody else's process: signal only our supervisor-main.
|
|
695
|
+
const who = await ownership(s.pid, s.key, read);
|
|
696
|
+
if (who === 'unknown')
|
|
697
|
+
return {
|
|
698
|
+
outcome: 'unverified',
|
|
699
|
+
pid: s.pid,
|
|
700
|
+
reason: 'no /proc or ps to read its command line',
|
|
701
|
+
};
|
|
702
|
+
if (who === 'foreign') {
|
|
703
|
+
// A false "foreign" (a wrapper entry whose command line lacks
|
|
704
|
+
// supervisor-main) must not delete a live daemon's files: it would keep
|
|
705
|
+
// running, unreachable, with its Chrome. Only a socket nobody answers on
|
|
706
|
+
// proves the pid was reused.
|
|
707
|
+
const p = s.socket === paths.socket
|
|
708
|
+
? await probe(paths, STATUS_PROBE_TIMEOUT_MS)
|
|
709
|
+
: { kind: 'dead' };
|
|
710
|
+
if (p.kind === 'ok')
|
|
711
|
+
p.connection.close();
|
|
712
|
+
if (p.kind !== 'dead')
|
|
713
|
+
return {
|
|
714
|
+
outcome: 'unverified',
|
|
715
|
+
pid: s.pid,
|
|
716
|
+
reason: 'its command line is not the warm browser, yet its socket is live',
|
|
717
|
+
};
|
|
718
|
+
await clearStaleState(paths, s);
|
|
719
|
+
return { outcome: 'none' };
|
|
720
|
+
}
|
|
721
|
+
try {
|
|
722
|
+
process.kill(s.pid, 'SIGTERM');
|
|
723
|
+
}
|
|
724
|
+
catch {
|
|
725
|
+
// already gone
|
|
726
|
+
}
|
|
727
|
+
for (let i = 0; i < 40 && pidAlive(s.pid); i++)
|
|
728
|
+
await sleep(50);
|
|
729
|
+
// A stopped (SIGSTOP) supervisor keeps its SIGTERM pending and is still
|
|
730
|
+
// there after the wait; reporting it stopped told scripts a lie.
|
|
731
|
+
if (pidAlive(s.pid))
|
|
732
|
+
return {
|
|
733
|
+
outcome: 'running',
|
|
734
|
+
pid: s.pid,
|
|
735
|
+
reason: 'it was signalled but had not exited after 2 s',
|
|
736
|
+
};
|
|
737
|
+
// Gone: only its own files are removed, never a successor's.
|
|
738
|
+
await clearStaleState(paths, s);
|
|
739
|
+
return { outcome: 'stopped' };
|
|
740
|
+
}
|
|
741
|
+
/** Stop the daemon for this fingerprint only. */
|
|
742
|
+
export async function stopDaemon(given) {
|
|
743
|
+
const opts = absolute(given);
|
|
744
|
+
const paths = daemonPaths(opts.stateDir, keyFor(opts));
|
|
745
|
+
const s = await readState(paths);
|
|
746
|
+
if (!s)
|
|
747
|
+
return { outcome: 'none' };
|
|
748
|
+
return stopOne(paths, s, opts.readCommand);
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* Stop every daemon in `stateDir`, whichever key it runs under, each through
|
|
752
|
+
* the same verification as `stopDaemon`. Entries whose outcome is `none`
|
|
753
|
+
* (stale files, reused pids) are cleared and left out.
|
|
754
|
+
*/
|
|
755
|
+
export async function stopAllDaemons(opts) {
|
|
756
|
+
const out = [];
|
|
757
|
+
for (const { paths, state } of await stateFiles(opts.stateDir)) {
|
|
758
|
+
const r = await stopOne(paths, state, opts.readCommand);
|
|
759
|
+
if (r.outcome !== 'none')
|
|
760
|
+
out.push({ key: state.key, pid: state.pid, ...r });
|
|
761
|
+
}
|
|
762
|
+
return out;
|
|
763
|
+
}
|
|
764
|
+
//# sourceMappingURL=client.js.map
|