@monoes/monobrowse 1.0.24 → 1.0.26
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/src/__tests__/browser-launch.test.d.ts +3 -2
- package/dist/src/__tests__/browser-launch.test.d.ts.map +1 -1
- package/dist/src/__tests__/browser-launch.test.js +208 -13
- package/dist/src/__tests__/browser-launch.test.js.map +1 -1
- package/dist/src/__tests__/launch-close-settle.test.d.ts +6 -0
- package/dist/src/__tests__/launch-close-settle.test.d.ts.map +1 -1
- package/dist/src/__tests__/launch-close-settle.test.js +29 -8
- package/dist/src/__tests__/launch-close-settle.test.js.map +1 -1
- package/dist/src/browser/browser.d.ts.map +1 -1
- package/dist/src/browser/browser.js +120 -44
- package/dist/src/browser/browser.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/__tests__/browser-launch.test.ts +220 -13
- package/src/__tests__/launch-close-settle.test.ts +32 -10
- package/src/browser/browser.ts +126 -47
package/package.json
CHANGED
|
@@ -5,16 +5,18 @@
|
|
|
5
5
|
* launchBrowser would ever exec a browser binary, so these run fast and
|
|
6
6
|
* don't depend on Chrome being installed in CI.
|
|
7
7
|
*
|
|
8
|
-
* Fixed port range (
|
|
9
|
-
* services
|
|
8
|
+
* Fixed port range (23460-23489) chosen to avoid colliding with real
|
|
9
|
+
* services and with reap-idle-browser.test.ts's 23490-23499, which runs in a
|
|
10
|
+
* parallel worker; each test binds/tears down its own listeners.
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
|
-
import {
|
|
13
|
+
import { execFileSync } from 'node:child_process';
|
|
14
|
+
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
13
15
|
import { createServer as createHttpServer, type Server as HttpServer } from 'node:http';
|
|
14
16
|
import { createServer as createTcpServer, type Socket, type Server as TcpServer } from 'node:net';
|
|
15
17
|
import { tmpdir } from 'node:os';
|
|
16
18
|
import { join } from 'node:path';
|
|
17
|
-
import { afterEach, describe, expect, it } from 'vitest';
|
|
19
|
+
import { afterAll, afterEach, describe, expect, it } from 'vitest';
|
|
18
20
|
import { getLaunchedPid, getLaunchedUserDataDir, launchBrowser } from '../browser/browser.js';
|
|
19
21
|
|
|
20
22
|
const BASE = 23470;
|
|
@@ -32,6 +34,72 @@ afterEach(async () => {
|
|
|
32
34
|
servers = [];
|
|
33
35
|
}, 5000);
|
|
34
36
|
|
|
37
|
+
// Every fake-chrome.cjs below is spawned by launchBrowser (detached, so a
|
|
38
|
+
// test cannot reach it through a ChildProcess). Its temp dir is on its
|
|
39
|
+
// command line, so each dir is recorded here and afterAll kills whatever is
|
|
40
|
+
// still running out of one — a fixture must never outlive this file (one
|
|
41
|
+
// that did kept 127.0.0.1:23480 bound and failed later runs with
|
|
42
|
+
// EADDRINUSE).
|
|
43
|
+
const fixtureDirs: string[] = [];
|
|
44
|
+
|
|
45
|
+
function fixtureDir(prefix: string): string {
|
|
46
|
+
const dir = mkdtempSync(join(tmpdir(), prefix));
|
|
47
|
+
fixtureDirs.push(dir);
|
|
48
|
+
return dir;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Pids of processes whose command line mentions `dir`. */
|
|
52
|
+
function processesIn(dir: string): string[] {
|
|
53
|
+
try {
|
|
54
|
+
return execFileSync('pgrep', ['-f', dir], { encoding: 'utf8' })
|
|
55
|
+
.trim()
|
|
56
|
+
.split('\n')
|
|
57
|
+
.filter(Boolean);
|
|
58
|
+
} catch {
|
|
59
|
+
return []; // pgrep exits 1 when nothing matches
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
afterAll(async () => {
|
|
64
|
+
const leaked: string[] = [];
|
|
65
|
+
for (const dir of fixtureDirs.splice(0)) {
|
|
66
|
+
// A fixture a test just SIGKILLed can take a moment to disappear.
|
|
67
|
+
let pids = processesIn(dir);
|
|
68
|
+
for (let i = 0; i < 20 && pids.length > 0; i++) {
|
|
69
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
70
|
+
pids = processesIn(dir);
|
|
71
|
+
}
|
|
72
|
+
leaked.push(...pids);
|
|
73
|
+
for (const pid of pids) {
|
|
74
|
+
try {
|
|
75
|
+
process.kill(Number(pid), 'SIGKILL');
|
|
76
|
+
} catch {
|
|
77
|
+
/* already gone */
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
rmSync(dir, { recursive: true, force: true });
|
|
81
|
+
}
|
|
82
|
+
// Swept above either way; failing here names a test whose own cleanup
|
|
83
|
+
// missed a fixture.
|
|
84
|
+
expect(leaked, 'fake-chrome fixtures outlived their test').toEqual([]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Prepended to every fake-chrome script: exit once the process that launched
|
|
88
|
+
// it is gone (launchBrowser spawns detached, so a vitest worker that dies or
|
|
89
|
+
// is killed mid-test would otherwise leave the fixture — and its port —
|
|
90
|
+
// behind, reparented to init), and never live longer than a minute anyway.
|
|
91
|
+
const FIXTURE_WATCHDOG = `const parent = process.ppid;
|
|
92
|
+
setInterval(() => {
|
|
93
|
+
try {
|
|
94
|
+
if (process.ppid !== parent) process.exit(0);
|
|
95
|
+
process.kill(parent, 0);
|
|
96
|
+
} catch {
|
|
97
|
+
process.exit(0);
|
|
98
|
+
}
|
|
99
|
+
}, 1000).unref();
|
|
100
|
+
setTimeout(() => process.exit(0), 60000).unref();
|
|
101
|
+
`;
|
|
102
|
+
|
|
35
103
|
/** Bind a bare TCP listener — accepts connections but speaks no HTTP/CDP. */
|
|
36
104
|
function occupyNonChrome(port: number): Promise<void> {
|
|
37
105
|
return new Promise((resolve, reject) => {
|
|
@@ -119,7 +187,7 @@ describe('launchBrowser — port scan/attach decisions', () => {
|
|
|
119
187
|
// --remote-debugging-port=0, it binds a kernel-assigned port and only then
|
|
120
188
|
// writes that port to <user-data-dir>/DevToolsActivePort.
|
|
121
189
|
const FAKE_CHROME = `#!/usr/bin/env node
|
|
122
|
-
const { createServer } = require('node:http');
|
|
190
|
+
${FIXTURE_WATCHDOG}const { createServer } = require('node:http');
|
|
123
191
|
const { writeFileSync } = require('node:fs');
|
|
124
192
|
const { join } = require('node:path');
|
|
125
193
|
const dir = process.argv.find((a) => a.startsWith('--user-data-dir=')).slice('--user-data-dir='.length);
|
|
@@ -133,11 +201,32 @@ setTimeout(() => server.listen(0, '127.0.0.1', () => {
|
|
|
133
201
|
}), 600);
|
|
134
202
|
`;
|
|
135
203
|
|
|
204
|
+
// Like FAKE_CHROME, but its first /json/version answer takes longer than
|
|
205
|
+
// launchBrowser's 1 s identity timeout, while /json/list answers at once.
|
|
206
|
+
const FAKE_CHROME_SLOW_IDENTITY = `#!/usr/bin/env node
|
|
207
|
+
${FIXTURE_WATCHDOG}const { createServer } = require('node:http');
|
|
208
|
+
const { writeFileSync } = require('node:fs');
|
|
209
|
+
const { join } = require('node:path');
|
|
210
|
+
const dir = process.argv.find((a) => a.startsWith('--user-data-dir=')).slice('--user-data-dir='.length);
|
|
211
|
+
let versionCalls = 0;
|
|
212
|
+
const server = createServer((req, res) => {
|
|
213
|
+
const send = () => {
|
|
214
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
215
|
+
res.end(req.url === '/json/version' ? JSON.stringify({ Browser: 'Chrome/999.0.0.0' }) : '[]');
|
|
216
|
+
};
|
|
217
|
+
if (req.url === '/json/version' && versionCalls++ === 0) setTimeout(send, 1500);
|
|
218
|
+
else send();
|
|
219
|
+
});
|
|
220
|
+
server.listen(0, '127.0.0.1', () => {
|
|
221
|
+
writeFileSync(join(dir, 'DevToolsActivePort'), server.address().port + '\\n/devtools/browser/fake');
|
|
222
|
+
});
|
|
223
|
+
`;
|
|
224
|
+
|
|
136
225
|
describe.skipIf(process.platform === 'win32')(
|
|
137
226
|
'launchBrowser — port 0 (Chrome picks the port)',
|
|
138
227
|
() => {
|
|
139
228
|
it('returns the port its own Chrome reported, never another CDP endpoint', async () => {
|
|
140
|
-
const dir =
|
|
229
|
+
const dir = fixtureDir('monobrowse-port0-');
|
|
141
230
|
const exe = join(dir, 'fake-chrome.cjs');
|
|
142
231
|
writeFileSync(exe, FAKE_CHROME);
|
|
143
232
|
chmodSync(exe, 0o755);
|
|
@@ -168,6 +257,48 @@ describe.skipIf(process.platform === 'win32')(
|
|
|
168
257
|
}
|
|
169
258
|
});
|
|
170
259
|
|
|
260
|
+
it('does not reject its own Chrome when /json/version answers slowly (loaded machine)', async () => {
|
|
261
|
+
// On a busy CI runner Chrome's DevTools server can take longer than the
|
|
262
|
+
// identity check's timeout to answer /json/version right after it
|
|
263
|
+
// starts listening, while /json/list (no timeout) got through. That
|
|
264
|
+
// timeout used to be read as "not Chrome" and failed the launch with
|
|
265
|
+
// "occupied by a CDP-speaking process that does not identify as
|
|
266
|
+
// Chrome/Chromium" — for the very Chrome it had just started.
|
|
267
|
+
const dir = fixtureDir('monobrowse-slowid-');
|
|
268
|
+
const exe = join(dir, 'fake-chrome.cjs');
|
|
269
|
+
writeFileSync(exe, FAKE_CHROME_SLOW_IDENTITY);
|
|
270
|
+
chmodSync(exe, 0o755);
|
|
271
|
+
const profile = join(dir, 'profile');
|
|
272
|
+
mkdirSync(profile);
|
|
273
|
+
|
|
274
|
+
let port: number | undefined;
|
|
275
|
+
try {
|
|
276
|
+
port = await launchBrowser({ port: 0, userDataDir: profile, executablePath: exe });
|
|
277
|
+
expect(port).toBeGreaterThan(0);
|
|
278
|
+
expect(getLaunchedPid(port)).toBeTypeOf('number');
|
|
279
|
+
} finally {
|
|
280
|
+
const pid = port === undefined ? undefined : getLaunchedPid(port);
|
|
281
|
+
if (pid) process.kill(pid, 'SIGKILL');
|
|
282
|
+
}
|
|
283
|
+
}, 15000);
|
|
284
|
+
|
|
285
|
+
it('still refuses a CDP endpoint that answers as a non-Chrome browser', async () => {
|
|
286
|
+
const dir = fixtureDir('monobrowse-notchrome-');
|
|
287
|
+
const exe = join(dir, 'fake-chrome.cjs');
|
|
288
|
+
writeFileSync(exe, FAKE_CHROME.replace('Chrome/999.0.0.0', 'Firefox/1.0'));
|
|
289
|
+
chmodSync(exe, 0o755);
|
|
290
|
+
const profile = join(dir, 'profile');
|
|
291
|
+
mkdirSync(profile);
|
|
292
|
+
await expect(
|
|
293
|
+
launchBrowser({ port: 0, userDataDir: profile, executablePath: exe }),
|
|
294
|
+
).rejects.toThrow(/does not identify as Chrome/);
|
|
295
|
+
// Refused, so never tracked — launchBrowser must stop it itself.
|
|
296
|
+
for (let i = 0; i < 20 && processesIn(dir).length > 0; i++) {
|
|
297
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
298
|
+
}
|
|
299
|
+
expect(processesIn(dir)).toEqual([]);
|
|
300
|
+
}, 15000);
|
|
301
|
+
|
|
171
302
|
it('refuses port 0 without a dedicated userDataDir', async () => {
|
|
172
303
|
await expect(launchBrowser({ port: 0 })).rejects.toThrow(/requires a dedicated userDataDir/);
|
|
173
304
|
});
|
|
@@ -180,25 +311,33 @@ describe.skipIf(process.platform === 'win32')(
|
|
|
180
311
|
// port too — see cli/commands.ts's `port` option default). If the bind
|
|
181
312
|
// fails (another process already has it), it exits the way real Chrome does
|
|
182
313
|
// when a concurrent launch wins the same "free-looking" port: before its
|
|
183
|
-
// CDP endpoint ever opens.
|
|
314
|
+
// CDP endpoint ever opens. Like real Chrome on a fixed port, it writes no
|
|
315
|
+
// DevToolsActivePort; it announces its browser websocket on stderr, with an
|
|
316
|
+
// id unique to this process that /json/version reports too.
|
|
184
317
|
const FAKE_CHROME_FIXED_PORT = `#!/usr/bin/env node
|
|
185
|
-
const { createServer } = require('node:http');
|
|
318
|
+
${FIXTURE_WATCHDOG}const { createServer } = require('node:http');
|
|
319
|
+
const { randomUUID } = require('node:crypto');
|
|
186
320
|
const { writeFileSync, mkdirSync } = require('node:fs');
|
|
187
321
|
const { join } = require('node:path');
|
|
188
322
|
const port = Number(
|
|
189
323
|
process.argv.find((a) => a.startsWith('--remote-debugging-port=')).slice('--remote-debugging-port='.length),
|
|
190
324
|
);
|
|
191
325
|
const dir = process.argv.find((a) => a.startsWith('--user-data-dir=')).slice('--user-data-dir='.length);
|
|
326
|
+
const ws = 'ws://127.0.0.1:' + port + '/devtools/browser/' + randomUUID();
|
|
192
327
|
const server = createServer((req, res) => {
|
|
193
328
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
194
|
-
res.end(
|
|
329
|
+
res.end(
|
|
330
|
+
req.url === '/json/version'
|
|
331
|
+
? JSON.stringify({ Browser: 'Chrome/999.0.0.0', webSocketDebuggerUrl: ws })
|
|
332
|
+
: '[]',
|
|
333
|
+
);
|
|
195
334
|
});
|
|
196
335
|
server.on('error', () => {
|
|
197
336
|
process.exit(21);
|
|
198
337
|
});
|
|
199
338
|
server.listen(port, '127.0.0.1', () => {
|
|
200
339
|
mkdirSync(dir, { recursive: true });
|
|
201
|
-
|
|
340
|
+
process.stderr.write('\\nDevTools listening on ' + ws + '\\n');
|
|
202
341
|
});
|
|
203
342
|
`;
|
|
204
343
|
|
|
@@ -206,7 +345,7 @@ describe.skipIf(process.platform === 'win32')(
|
|
|
206
345
|
'launchBrowser — concurrent launches with no explicit --port (TOCTOU race)',
|
|
207
346
|
() => {
|
|
208
347
|
it('two concurrent launches racing for the same default port both succeed, on different ports and profiles', async () => {
|
|
209
|
-
const dir =
|
|
348
|
+
const dir = fixtureDir('monobrowse-concurrent-');
|
|
210
349
|
const exe = join(dir, 'fake-chrome.cjs');
|
|
211
350
|
writeFileSync(exe, FAKE_CHROME_FIXED_PORT);
|
|
212
351
|
chmodSync(exe, 0o755);
|
|
@@ -216,12 +355,17 @@ describe.skipIf(process.platform === 'win32')(
|
|
|
216
355
|
// whether or not the user passed it (cli/commands.ts:288).
|
|
217
356
|
const port = BASE + 10;
|
|
218
357
|
|
|
358
|
+
// allSettled, not all: when one launch rejects, the other's fixture is
|
|
359
|
+
// still running and must be killed too — Promise.all dropped its port
|
|
360
|
+
// and leaked it.
|
|
361
|
+
let results: PromiseSettledResult<number>[] = [];
|
|
219
362
|
let ports: number[] = [];
|
|
220
363
|
try {
|
|
221
|
-
|
|
364
|
+
results = await Promise.allSettled([
|
|
222
365
|
launchBrowser({ port, executablePath: exe, launchTimeoutMs: 5000 }),
|
|
223
366
|
launchBrowser({ port, executablePath: exe, launchTimeoutMs: 5000 }),
|
|
224
367
|
]);
|
|
368
|
+
ports = results.flatMap((r) => (r.status === 'fulfilled' ? [r.value] : []));
|
|
225
369
|
} finally {
|
|
226
370
|
for (const p of ports) {
|
|
227
371
|
const pid = getLaunchedPid(p);
|
|
@@ -233,9 +377,9 @@ describe.skipIf(process.platform === 'win32')(
|
|
|
233
377
|
}
|
|
234
378
|
}
|
|
235
379
|
}
|
|
236
|
-
rmSync(dir, { recursive: true, force: true });
|
|
237
380
|
}
|
|
238
381
|
|
|
382
|
+
for (const r of results) if (r.status === 'rejected') throw r.reason;
|
|
239
383
|
expect(ports).toHaveLength(2);
|
|
240
384
|
// The old probe-then-launch race let both calls observe the same
|
|
241
385
|
// candidate as free and spawn Chrome on it — one bind wins, the
|
|
@@ -250,5 +394,68 @@ describe.skipIf(process.platform === 'win32')(
|
|
|
250
394
|
expect(dirs[0]).toBeTruthy();
|
|
251
395
|
expect(dirs[1]).toBeTruthy();
|
|
252
396
|
}, 15000);
|
|
397
|
+
|
|
398
|
+
it('a launch whose Chrome is still booting does not adopt a concurrent launch that took the port', async () => {
|
|
399
|
+
// The loaded-machine shape of the race above, made deterministic: both
|
|
400
|
+
// launches probe the port as free, the "fast" Chrome binds it first,
|
|
401
|
+
// and the "slow" one is still starting when its poll finds a Chrome
|
|
402
|
+
// answering there. That Chrome is not the slow launch's own, so it
|
|
403
|
+
// must not be returned — nor may the slow launch record its own pid
|
|
404
|
+
// (about to exit on the lost bind) under the fast launch's port.
|
|
405
|
+
const dir = fixtureDir('monobrowse-adopt-');
|
|
406
|
+
const fast = join(dir, 'fast-chrome.cjs');
|
|
407
|
+
const slow = join(dir, 'slow-chrome.cjs');
|
|
408
|
+
writeFileSync(fast, fixedPortChrome(500));
|
|
409
|
+
writeFileSync(slow, fixedPortChrome(2500));
|
|
410
|
+
chmodSync(fast, 0o755);
|
|
411
|
+
chmodSync(slow, 0o755);
|
|
412
|
+
// Below BASE: this file's fixed ports, clear of the kernel's
|
|
413
|
+
// ephemeral range, where another test's listen(0) server can land on
|
|
414
|
+
// the candidate the slow launch scans on to.
|
|
415
|
+
const port = BASE - 10;
|
|
416
|
+
|
|
417
|
+
let results: PromiseSettledResult<number>[] = [];
|
|
418
|
+
try {
|
|
419
|
+
results = await Promise.allSettled([
|
|
420
|
+
launchBrowser({ port, executablePath: fast, launchTimeoutMs: 10_000 }),
|
|
421
|
+
launchBrowser({ port, executablePath: slow, launchTimeoutMs: 10_000 }),
|
|
422
|
+
]);
|
|
423
|
+
} finally {
|
|
424
|
+
for (const r of results) {
|
|
425
|
+
const pid = r.status === 'fulfilled' ? getLaunchedPid(r.value) : undefined;
|
|
426
|
+
if (pid) {
|
|
427
|
+
try {
|
|
428
|
+
process.kill(pid, 'SIGKILL');
|
|
429
|
+
} catch {
|
|
430
|
+
/* already gone */
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const [fastResult, slowResult] = results;
|
|
437
|
+
expect(fastResult).toEqual({ status: 'fulfilled', value: port });
|
|
438
|
+
if (slowResult.status === 'rejected') throw slowResult.reason;
|
|
439
|
+
expect(slowResult.value).not.toBe(port);
|
|
440
|
+
// The pid tracked for the port is the Chrome that actually serves it.
|
|
441
|
+
const fastDir = getLaunchedUserDataDir(port)!;
|
|
442
|
+
expect(fastDir).toBeTruthy();
|
|
443
|
+
expect(getLaunchedPid(port)).toBe(Number(readFileSync(join(fastDir, 'pid'), 'utf8')));
|
|
444
|
+
}, 20_000);
|
|
253
445
|
},
|
|
254
446
|
);
|
|
447
|
+
|
|
448
|
+
/** FAKE_CHROME_FIXED_PORT that waits `delayMs` before binding, and records
|
|
449
|
+
* its own pid in its profile dir. */
|
|
450
|
+
function fixedPortChrome(delayMs: number): string {
|
|
451
|
+
return FAKE_CHROME_FIXED_PORT.replace(
|
|
452
|
+
'mkdirSync(dir, { recursive: true });',
|
|
453
|
+
`mkdirSync(dir, { recursive: true });
|
|
454
|
+
writeFileSync(join(dir, 'pid'), String(process.pid));`,
|
|
455
|
+
)
|
|
456
|
+
.replace(
|
|
457
|
+
"server.listen(port, '127.0.0.1', () => {",
|
|
458
|
+
"setTimeout(() => server.listen(port, '127.0.0.1', () => {",
|
|
459
|
+
)
|
|
460
|
+
.replace(/\}\);\n$/, `}), ${delayMs});\n`);
|
|
461
|
+
}
|
|
@@ -27,11 +27,18 @@
|
|
|
27
27
|
* manual repro. vi.useFakeTimers() (used throughout close-browser.test.ts)
|
|
28
28
|
* cannot catch this class of bug, because fake timers do not model real
|
|
29
29
|
* event-loop-drain semantics — hence a dedicated, real-timer test here.
|
|
30
|
+
*
|
|
31
|
+
* Ports come from the OS (listen on 0), never a fixed number (#351): these
|
|
32
|
+
* tests used 23495-23497, inside reap-idle-browser.test.ts's 23490-23499
|
|
33
|
+
* range, whose fake Chrome endpoints run in a parallel worker. A spawn that
|
|
34
|
+
* failed on 23495 while that file had 23495 bound looked like a lost launch
|
|
35
|
+
* race to launchBrowser, which moved on and reported port 23496 instead.
|
|
30
36
|
*/
|
|
31
37
|
|
|
32
38
|
import { spawn } from 'node:child_process';
|
|
33
39
|
import { chmodSync, writeFileSync } from 'node:fs';
|
|
34
40
|
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
41
|
+
import { createServer } from 'node:net';
|
|
35
42
|
import { tmpdir } from 'node:os';
|
|
36
43
|
import { join } from 'node:path';
|
|
37
44
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
@@ -88,6 +95,19 @@ afterEach(async () => {
|
|
|
88
95
|
await rm(tempDir, { recursive: true, force: true });
|
|
89
96
|
});
|
|
90
97
|
|
|
98
|
+
/** A port nothing is listening on, picked by the kernel from its ephemeral
|
|
99
|
+
* range — which the fixed-port fixtures of other test files stay below. */
|
|
100
|
+
function freePort(): Promise<number> {
|
|
101
|
+
return new Promise((resolve, reject) => {
|
|
102
|
+
const server = createServer();
|
|
103
|
+
server.once('error', reject);
|
|
104
|
+
server.listen(0, '127.0.0.1', () => {
|
|
105
|
+
const { port } = server.address() as { port: number };
|
|
106
|
+
server.close(() => resolve(port));
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
91
111
|
async function writePersistedPort(port: number, pid: number, savedAt: number): Promise<void> {
|
|
92
112
|
const dir = join(tempDir, '.monomind', 'monobrowse');
|
|
93
113
|
await mkdir(dir, { recursive: true });
|
|
@@ -118,21 +138,22 @@ describe('#314: launchBrowser settles instead of hanging when Chrome fails to st
|
|
|
118
138
|
// "file not found" that launchBrowser would reject before ever exec'ing.
|
|
119
139
|
writeFileSync(fake, 'not an executable\n');
|
|
120
140
|
chmodSync(fake, 0o644);
|
|
141
|
+
const port = await freePort();
|
|
121
142
|
|
|
122
143
|
const start = Date.now();
|
|
123
144
|
await expect(
|
|
124
|
-
launchBrowser({ executablePath: fake, port
|
|
125
|
-
).rejects.toThrow(
|
|
145
|
+
launchBrowser({ executablePath: fake, port, launchTimeoutMs: 10_000 }),
|
|
146
|
+
).rejects.toThrow(`Chrome failed to start on port ${port}: `);
|
|
126
147
|
// Without a child 'error' listener, this used to depend entirely on
|
|
127
148
|
// whether something outside this function happened to intercept the
|
|
128
149
|
// resulting uncaught exception (a bare node:test process has nothing
|
|
129
150
|
// that does — see the module doc comment); where it happened not to
|
|
130
151
|
// crash the process outright, the promise still only ever settled by
|
|
131
152
|
// burning the full launchTimeoutMs. The fix rejects as soon as the
|
|
132
|
-
// spawn error fires, so this must land
|
|
133
|
-
// ride
|
|
134
|
-
expect(Date.now() - start).toBeLessThan(
|
|
135
|
-
},
|
|
153
|
+
// spawn error fires, so this must land well inside the 10s timeout
|
|
154
|
+
// rather than ride it out — with room for a loaded machine.
|
|
155
|
+
expect(Date.now() - start).toBeLessThan(5000);
|
|
156
|
+
}, 20_000);
|
|
136
157
|
|
|
137
158
|
it('rejects with a clear message when Chrome exits immediately instead of opening its CDP port', async () => {
|
|
138
159
|
vi.resetModules();
|
|
@@ -140,18 +161,19 @@ describe('#314: launchBrowser settles instead of hanging when Chrome fails to st
|
|
|
140
161
|
const fake = join(tempDir, 'chrome.sh');
|
|
141
162
|
writeFileSync(fake, '#!/bin/sh\nexit 1\n');
|
|
142
163
|
chmodSync(fake, 0o755);
|
|
164
|
+
const port = await freePort();
|
|
143
165
|
|
|
144
166
|
await expect(
|
|
145
|
-
launchBrowser({ executablePath: fake, port
|
|
146
|
-
).rejects.toThrow(
|
|
147
|
-
},
|
|
167
|
+
launchBrowser({ executablePath: fake, port, launchTimeoutMs: 10_000 }),
|
|
168
|
+
).rejects.toThrow(`Chrome exited before the CDP endpoint opened on port ${port} `);
|
|
169
|
+
}, 20_000);
|
|
148
170
|
});
|
|
149
171
|
|
|
150
172
|
describe('#314: closeBrowser settles under the real event loop, not fake timers', () => {
|
|
151
173
|
it('resolves promptly when nothing else is pinning the event loop', async () => {
|
|
152
174
|
vi.resetModules();
|
|
153
175
|
const { closeBrowser } = await import('../browser/browser.js');
|
|
154
|
-
const port =
|
|
176
|
+
const port = await freePort();
|
|
155
177
|
|
|
156
178
|
// A real, short-lived child — not Chrome, just something with a genuine,
|
|
157
179
|
// observable exit so process.kill(pid, 0) behaves for real and there is
|
package/src/browser/browser.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execSync, spawn } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
2
3
|
import { existsSync, readFileSync, rmSync } from 'node:fs';
|
|
3
4
|
import { connect } from 'node:net';
|
|
4
5
|
import { tmpdir } from 'node:os';
|
|
@@ -92,18 +93,27 @@ export async function isPortOpen(port: number): Promise<boolean> {
|
|
|
92
93
|
* attaching to an unrelated real browser that happens to be listening there.
|
|
93
94
|
*/
|
|
94
95
|
async function isChromeIdentity(port: number): Promise<boolean> {
|
|
96
|
+
return (await chromeIdentity(port)) === 'chrome';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** isChromeIdentity, telling "answered as something else" apart from "did
|
|
100
|
+
* not answer in time" ('unknown': a timeout, a refused or reset connection,
|
|
101
|
+
* a non-OK status or an unparseable body). */
|
|
102
|
+
async function chromeIdentity(port: number): Promise<'chrome' | 'other' | 'unknown'> {
|
|
95
103
|
try {
|
|
96
104
|
const res = await fetch(`http://127.0.0.1:${port}/json/version`, {
|
|
97
105
|
signal: AbortSignal.timeout(1000),
|
|
98
106
|
});
|
|
99
|
-
if (!res.ok) return
|
|
107
|
+
if (!res.ok) return 'unknown';
|
|
100
108
|
const info = (await res.json()) as { Browser?: string };
|
|
101
109
|
// Edge's Browser field is "Edg/<version>" with no "chrome"/"chromium"
|
|
102
110
|
// substring, even though it's Chromium-based and CDP-compatible —
|
|
103
111
|
// accept it alongside Chrome/Chromium rather than rejecting a valid target.
|
|
104
|
-
return typeof info.Browser === 'string' && /chrom(e|ium)|edg/i.test(info.Browser)
|
|
112
|
+
return typeof info.Browser === 'string' && /chrom(e|ium)|edg/i.test(info.Browser)
|
|
113
|
+
? 'chrome'
|
|
114
|
+
: 'other';
|
|
105
115
|
} catch {
|
|
106
|
-
return
|
|
116
|
+
return 'unknown';
|
|
107
117
|
}
|
|
108
118
|
}
|
|
109
119
|
|
|
@@ -249,19 +259,27 @@ function readDevToolsActivePort(file: string): number | null {
|
|
|
249
259
|
* for a free port at bind time — atomic, nothing to collide on — and writes
|
|
250
260
|
* the port it got to DevToolsActivePort in its own (caller-dedicated) profile
|
|
251
261
|
* directory, which is proof the endpoint is the process we spawned.
|
|
262
|
+
*
|
|
263
|
+
* A fixed port needs its own proof, since Chrome writes DevToolsActivePort
|
|
264
|
+
* only for port 0. Once listening, Chrome prints `DevTools listening on
|
|
265
|
+
* ws://…/devtools/browser/<id>` to stderr, and `<id>` is unique to that
|
|
266
|
+
* browser process, the same one `/json/version` reports. An endpoint is
|
|
267
|
+
* adopted only when it reports the id our own child printed. Without that
|
|
268
|
+
* check, a launch whose Chrome was still booting found a concurrent
|
|
269
|
+
* launch's Chrome on the shared port and returned it, recording its own
|
|
270
|
+
* doomed pid there. It also covers Chrome's [::1] fallback described above.
|
|
252
271
|
*/
|
|
253
272
|
async function launchOnFreePort(config: BrowserConfig, port: number): Promise<number> {
|
|
254
273
|
const chromePath = findChrome(config.executablePath);
|
|
255
|
-
//
|
|
256
|
-
// same candidate port as free (the race this function
|
|
257
|
-
// see the retry-on-collision caller above)
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
// for as long as the loser stays alive. Different processes always have
|
|
262
|
-
// different pids, so this can never collide even when the port does.
|
|
274
|
+
// Unique per launch, not just per port and pid: two concurrent launches
|
|
275
|
+
// that both probe the same candidate port as free (the race this function
|
|
276
|
+
// exists to survive — see the retry-on-collision caller above) would
|
|
277
|
+
// otherwise share one profile directory (lock files, preferences, the
|
|
278
|
+
// DevToolsActivePort file itself) — including two launches from the same
|
|
279
|
+
// process, where a pid suffix alone is identical.
|
|
263
280
|
const userDataDir =
|
|
264
|
-
config.userDataDir ??
|
|
281
|
+
config.userDataDir ??
|
|
282
|
+
join(tmpdir(), `monomind-browser-${port}-${process.pid}-${randomUUID().slice(0, 8)}`);
|
|
265
283
|
const activePortFile = join(userDataDir, 'DevToolsActivePort');
|
|
266
284
|
// A reused profile dir may hold a previous run's file naming a port some
|
|
267
285
|
// other process now owns.
|
|
@@ -308,8 +326,26 @@ async function launchOnFreePort(config: BrowserConfig, port: number): Promise<nu
|
|
|
308
326
|
const args = [...defaultArgs, ...callerArgs];
|
|
309
327
|
const child = spawn(chromePath, args, {
|
|
310
328
|
detached: true,
|
|
311
|
-
|
|
329
|
+
// stderr carries the fixed-port ownership proof (see above). The pipe is
|
|
330
|
+
// closed as soon as this launch settles, so a Chrome that outlives us
|
|
331
|
+
// never blocks writing to it.
|
|
332
|
+
stdio: port === 0 ? 'ignore' : ['ignore', 'ignore', 'pipe'],
|
|
312
333
|
});
|
|
334
|
+
let announcedBrowserId: string | null = null;
|
|
335
|
+
if (child.stderr) {
|
|
336
|
+
let stderrTail = '';
|
|
337
|
+
child.stderr.setEncoding('utf8');
|
|
338
|
+
child.stderr.on('data', (chunk: string) => {
|
|
339
|
+
if (announcedBrowserId) return;
|
|
340
|
+
stderrTail = (stderrTail + chunk).slice(-4096);
|
|
341
|
+
announcedBrowserId = browserIdOf(
|
|
342
|
+
/DevTools listening on (ws:\/\/\S+)\r?\n/.exec(stderrTail)?.[1],
|
|
343
|
+
);
|
|
344
|
+
});
|
|
345
|
+
child.stderr.on('error', () => {
|
|
346
|
+
/* closed under us — nothing to read */
|
|
347
|
+
});
|
|
348
|
+
}
|
|
313
349
|
|
|
314
350
|
// Without this, a spawn failure (e.g. EACCES/ENOENT — chromePath exists per
|
|
315
351
|
// findChrome()'s existsSync check but isn't actually executable, or is
|
|
@@ -353,51 +389,94 @@ async function launchOnFreePort(config: BrowserConfig, port: number): Promise<nu
|
|
|
353
389
|
// process's pid overwriting the real winner's, corrupting the very map
|
|
354
390
|
// closeBrowser()'s kill fallback depends on.
|
|
355
391
|
|
|
392
|
+
// A refused or timed-out launch is not tracked under any port, so no
|
|
393
|
+
// closeBrowser() could ever reach it — stop it rather than leave it
|
|
394
|
+
// running.
|
|
395
|
+
const killUntracked = () => {
|
|
396
|
+
try {
|
|
397
|
+
if (child.pid) process.kill(child.pid, 'SIGKILL');
|
|
398
|
+
} catch {
|
|
399
|
+
/* already gone */
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
// Our own Chrome, not just some Chrome, is serving `boundPort`. For port 0
|
|
404
|
+
// the port came from our own profile's DevToolsActivePort, so it is.
|
|
405
|
+
const isOwnEndpoint = async (boundPort: number): Promise<boolean> => {
|
|
406
|
+
if (port === 0) return true;
|
|
407
|
+
if (!announcedBrowserId) return false;
|
|
408
|
+
const wsUrl = await fetchBrowserWebSocketUrl(boundPort).catch(() => null);
|
|
409
|
+
return browserIdOf(wsUrl ?? undefined) === announcedBrowserId;
|
|
410
|
+
};
|
|
411
|
+
|
|
356
412
|
const launchTimeout = config.launchTimeoutMs ?? LAUNCH_TIMEOUT;
|
|
357
413
|
const deadline = Date.now() + launchTimeout;
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
if (await
|
|
365
|
-
|
|
366
|
-
|
|
414
|
+
try {
|
|
415
|
+
while (Date.now() < deadline) {
|
|
416
|
+
if (earlyFailure) throw earlyFailure;
|
|
417
|
+
await sleep(POLL_INTERVAL);
|
|
418
|
+
if (earlyFailure) throw earlyFailure;
|
|
419
|
+
const boundPort = port === 0 ? readDevToolsActivePort(activePortFile) : port;
|
|
420
|
+
if (boundPort !== null && (await isPortOpen(boundPort))) {
|
|
421
|
+
const identity = await chromeIdentity(boundPort);
|
|
422
|
+
if (identity === 'chrome') {
|
|
423
|
+
if (await isOwnEndpoint(boundPort)) {
|
|
424
|
+
track(boundPort);
|
|
425
|
+
return boundPort;
|
|
426
|
+
}
|
|
427
|
+
// Some Chrome answers on the port, but not the one we started: a
|
|
428
|
+
// concurrent launch's, or ours before its stderr line arrived. Our
|
|
429
|
+
// child exiting (it lost the bind) or the deadline decides.
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
// A Chrome that has only just opened its endpoint, on a loaded
|
|
433
|
+
// machine, can answer /json/list (no timeout) yet miss the identity
|
|
434
|
+
// check's 1 s timeout on /json/version. That says nothing about who is
|
|
435
|
+
// listening — keep polling rather than refuse our own Chrome.
|
|
436
|
+
if (identity === 'unknown') continue;
|
|
437
|
+
killUntracked();
|
|
438
|
+
throw new Error(
|
|
439
|
+
`Port ${boundPort} is occupied by a CDP-speaking process that does not identify as Chrome/Chromium. ` +
|
|
440
|
+
`Refusing to attach — pass a different port or free port ${boundPort}.`,
|
|
441
|
+
);
|
|
367
442
|
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (earlyFailure) throw earlyFailure;
|
|
446
|
+
killUntracked();
|
|
447
|
+
|
|
448
|
+
if (port === 0) {
|
|
368
449
|
throw new Error(
|
|
369
|
-
`
|
|
370
|
-
`Refusing to attach — pass a different port or free port ${boundPort}.`,
|
|
450
|
+
`Chrome did not report a CDP port in ${activePortFile} within ${launchTimeout}ms`,
|
|
371
451
|
);
|
|
372
452
|
}
|
|
373
|
-
}
|
|
374
453
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
//
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
454
|
+
// Timed out waiting for our Chrome to come up on the port. Distinguish
|
|
455
|
+
// "another Chrome holds it", "nothing is listening" (real launch failure)
|
|
456
|
+
// and "something non-CDP is squatting the port" (confusing generic
|
|
457
|
+
// timeout otherwise).
|
|
458
|
+
if (await isChromeIdentity(port)) {
|
|
459
|
+
throw new Error(
|
|
460
|
+
`Port ${port} is held by a Chrome/Chromium this launch did not start (a concurrent launch took it).`,
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
if (await isTcpPortOpen(port)) {
|
|
464
|
+
throw new Error(
|
|
465
|
+
`Port ${port} is occupied by a non-Chrome process (TCP connection succeeds but no CDP response within ${launchTimeout}ms). ` +
|
|
466
|
+
`Free the port or pass a different one.`,
|
|
467
|
+
);
|
|
384
468
|
}
|
|
385
|
-
throw new Error(
|
|
386
|
-
`Chrome did not report a CDP port in ${activePortFile} within ${launchTimeout}ms`,
|
|
387
|
-
);
|
|
388
|
-
}
|
|
389
469
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
if (await isTcpPortOpen(port)) {
|
|
394
|
-
throw new Error(
|
|
395
|
-
`Port ${port} is occupied by a non-Chrome process (TCP connection succeeds but no CDP response within ${launchTimeout}ms). ` +
|
|
396
|
-
`Free the port or pass a different one.`,
|
|
397
|
-
);
|
|
470
|
+
throw new Error(`Chrome failed to start on port ${port} within ${launchTimeout}ms`);
|
|
471
|
+
} finally {
|
|
472
|
+
child.stderr?.destroy();
|
|
398
473
|
}
|
|
474
|
+
}
|
|
399
475
|
|
|
400
|
-
|
|
476
|
+
/** The per-process id in a browser-level CDP websocket URL
|
|
477
|
+
* (`ws://host:port/devtools/browser/<id>`), or null. */
|
|
478
|
+
function browserIdOf(wsUrl: string | undefined): string | null {
|
|
479
|
+
return wsUrl?.match(/\/devtools\/browser\/([^/?#\s]+)/)?.[1] ?? null;
|
|
401
480
|
}
|
|
402
481
|
|
|
403
482
|
export async function enableSessionDomains(client: CdpClient, sessionId: string): Promise<void> {
|