@monoes/monobrowse 1.0.25 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monoes/monobrowse",
3
- "version": "1.0.25",
3
+ "version": "1.0.26",
4
4
  "description": "Native browser automation via Chrome DevTools Protocol — the engine powering monomind browse",
5
5
  "type": "module",
6
6
  "bin": {
@@ -5,12 +5,13 @@
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 (23470-23479) chosen to avoid colliding with real
9
- * services; each test binds/tears down its own listeners.
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
13
  import { execFileSync } from 'node:child_process';
13
- import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
14
+ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
14
15
  import { createServer as createHttpServer, type Server as HttpServer } from 'node:http';
15
16
  import { createServer as createTcpServer, type Socket, type Server as TcpServer } from 'node:net';
16
17
  import { tmpdir } from 'node:os';
@@ -310,25 +311,33 @@ describe.skipIf(process.platform === 'win32')(
310
311
  // port too — see cli/commands.ts's `port` option default). If the bind
311
312
  // fails (another process already has it), it exits the way real Chrome does
312
313
  // when a concurrent launch wins the same "free-looking" port: before its
313
- // 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.
314
317
  const FAKE_CHROME_FIXED_PORT = `#!/usr/bin/env node
315
318
  ${FIXTURE_WATCHDOG}const { createServer } = require('node:http');
319
+ const { randomUUID } = require('node:crypto');
316
320
  const { writeFileSync, mkdirSync } = require('node:fs');
317
321
  const { join } = require('node:path');
318
322
  const port = Number(
319
323
  process.argv.find((a) => a.startsWith('--remote-debugging-port=')).slice('--remote-debugging-port='.length),
320
324
  );
321
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();
322
327
  const server = createServer((req, res) => {
323
328
  res.writeHead(200, { 'Content-Type': 'application/json' });
324
- res.end(req.url === '/json/version' ? JSON.stringify({ Browser: 'Chrome/999.0.0.0' }) : '[]');
329
+ res.end(
330
+ req.url === '/json/version'
331
+ ? JSON.stringify({ Browser: 'Chrome/999.0.0.0', webSocketDebuggerUrl: ws })
332
+ : '[]',
333
+ );
325
334
  });
326
335
  server.on('error', () => {
327
336
  process.exit(21);
328
337
  });
329
338
  server.listen(port, '127.0.0.1', () => {
330
339
  mkdirSync(dir, { recursive: true });
331
- writeFileSync(join(dir, 'DevToolsActivePort'), port + '\\n/devtools/browser/fake');
340
+ process.stderr.write('\\nDevTools listening on ' + ws + '\\n');
332
341
  });
333
342
  `;
334
343
 
@@ -385,5 +394,68 @@ describe.skipIf(process.platform === 'win32')(
385
394
  expect(dirs[0]).toBeTruthy();
386
395
  expect(dirs[1]).toBeTruthy();
387
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);
388
445
  },
389
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: 23495, launchTimeoutMs: 3000 }),
125
- ).rejects.toThrow(/Chrome failed to start on port 23495/);
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 in well under a second, not
133
- // ride the 3000ms timeout out.
134
- expect(Date.now() - start).toBeLessThan(1000);
135
- }, 8000);
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: 23496, launchTimeoutMs: 3000 }),
146
- ).rejects.toThrow(/Chrome exited before the CDP endpoint opened on port 23496/);
147
- }, 8000);
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 = 23497;
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
@@ -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';
@@ -258,19 +259,27 @@ function readDevToolsActivePort(file: string): number | null {
258
259
  * for a free port at bind time — atomic, nothing to collide on — and writes
259
260
  * the port it got to DevToolsActivePort in its own (caller-dedicated) profile
260
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.
261
271
  */
262
272
  async function launchOnFreePort(config: BrowserConfig, port: number): Promise<number> {
263
273
  const chromePath = findChrome(config.executablePath);
264
- // Suffixed with our own pid: two concurrent launches that both probe the
265
- // same candidate port as free (the race this function exists to survive —
266
- // see the retry-on-collision caller above) briefly run Chrome with
267
- // *identical* --user-data-dir values if it were derived from the port
268
- // alone, sharing a profile directory (lock files, preferences, the
269
- // DevToolsActivePort file itself) between two unrelated Chrome processes
270
- // for as long as the loser stays alive. Different processes always have
271
- // 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.
272
280
  const userDataDir =
273
- config.userDataDir ?? join(tmpdir(), `monomind-browser-${port}-${process.pid}`);
281
+ config.userDataDir ??
282
+ join(tmpdir(), `monomind-browser-${port}-${process.pid}-${randomUUID().slice(0, 8)}`);
274
283
  const activePortFile = join(userDataDir, 'DevToolsActivePort');
275
284
  // A reused profile dir may hold a previous run's file naming a port some
276
285
  // other process now owns.
@@ -317,8 +326,26 @@ async function launchOnFreePort(config: BrowserConfig, port: number): Promise<nu
317
326
  const args = [...defaultArgs, ...callerArgs];
318
327
  const child = spawn(chromePath, args, {
319
328
  detached: true,
320
- stdio: 'ignore',
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'],
321
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
+ }
322
349
 
323
350
  // Without this, a spawn failure (e.g. EACCES/ENOENT — chromePath exists per
324
351
  // findChrome()'s existsSync check but isn't actually executable, or is
@@ -362,8 +389,8 @@ async function launchOnFreePort(config: BrowserConfig, port: number): Promise<nu
362
389
  // process's pid overwriting the real winner's, corrupting the very map
363
390
  // closeBrowser()'s kill fallback depends on.
364
391
 
365
- // With port 0 a refused or timed-out launch is not tracked under any port,
366
- // so no closeBrowser() could ever reach it — stop it rather than leave it
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
367
394
  // running.
368
395
  const killUntracked = () => {
369
396
  try {
@@ -373,52 +400,83 @@ async function launchOnFreePort(config: BrowserConfig, port: number): Promise<nu
373
400
  }
374
401
  };
375
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
+
376
412
  const launchTimeout = config.launchTimeoutMs ?? LAUNCH_TIMEOUT;
377
413
  const deadline = Date.now() + launchTimeout;
378
- while (Date.now() < deadline) {
379
- if (earlyFailure) throw earlyFailure;
380
- await sleep(POLL_INTERVAL);
381
- if (earlyFailure) throw earlyFailure;
382
- const boundPort = port === 0 ? readDevToolsActivePort(activePortFile) : port;
383
- if (boundPort !== null && (await isPortOpen(boundPort))) {
384
- const identity = await chromeIdentity(boundPort);
385
- if (identity === 'chrome') {
386
- track(boundPort);
387
- return boundPort;
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
+ );
388
442
  }
389
- // A Chrome that has only just opened its endpoint, on a loaded
390
- // machine, can answer /json/list (no timeout) yet miss the identity
391
- // check's 1 s timeout on /json/version. That says nothing about who is
392
- // listening — keep polling rather than refuse our own Chrome.
393
- if (identity === 'unknown') continue;
394
- if (port === 0) killUntracked();
443
+ }
444
+
445
+ if (earlyFailure) throw earlyFailure;
446
+ killUntracked();
447
+
448
+ if (port === 0) {
395
449
  throw new Error(
396
- `Port ${boundPort} is occupied by a CDP-speaking process that does not identify as Chrome/Chromium. ` +
397
- `Refusing to attach — pass a different port or free port ${boundPort}.`,
450
+ `Chrome did not report a CDP port in ${activePortFile} within ${launchTimeout}ms`,
398
451
  );
399
452
  }
400
- }
401
453
 
402
- if (earlyFailure) throw earlyFailure;
403
-
404
- if (port === 0) {
405
- killUntracked();
406
- throw new Error(
407
- `Chrome did not report a CDP port in ${activePortFile} within ${launchTimeout}ms`,
408
- );
409
- }
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
+ );
468
+ }
410
469
 
411
- // Timed out waiting for our Chrome to come up on the port. Distinguish
412
- // "nothing is listening" (real launch failure) from "something non-CDP is
413
- // squatting the port" (confusing generic timeout otherwise) via a raw TCP probe.
414
- if (await isTcpPortOpen(port)) {
415
- throw new Error(
416
- `Port ${port} is occupied by a non-Chrome process (TCP connection succeeds but no CDP response within ${launchTimeout}ms). ` +
417
- `Free the port or pass a different one.`,
418
- );
470
+ throw new Error(`Chrome failed to start on port ${port} within ${launchTimeout}ms`);
471
+ } finally {
472
+ child.stderr?.destroy();
419
473
  }
474
+ }
420
475
 
421
- throw new Error(`Chrome failed to start on port ${port} within ${launchTimeout}ms`);
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;
422
480
  }
423
481
 
424
482
  export async function enableSessionDomains(client: CdpClient, sessionId: string): Promise<void> {