@monoes/monobrowse 1.0.15 → 1.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/src/__tests__/launch-close-settle.test.d.ts +32 -0
  2. package/dist/src/__tests__/launch-close-settle.test.d.ts.map +1 -0
  3. package/dist/src/__tests__/launch-close-settle.test.js +155 -0
  4. package/dist/src/__tests__/launch-close-settle.test.js.map +1 -0
  5. package/dist/src/__tests__/report-exit-code.test.d.ts +46 -0
  6. package/dist/src/__tests__/report-exit-code.test.d.ts.map +1 -0
  7. package/dist/src/__tests__/report-exit-code.test.js +188 -0
  8. package/dist/src/__tests__/report-exit-code.test.js.map +1 -0
  9. package/dist/src/browser/actions.d.ts.map +1 -1
  10. package/dist/src/browser/actions.js +19 -9
  11. package/dist/src/browser/actions.js.map +1 -1
  12. package/dist/src/browser/bridge.d.ts.map +1 -1
  13. package/dist/src/browser/bridge.js +4 -1
  14. package/dist/src/browser/bridge.js.map +1 -1
  15. package/dist/src/browser/browser.d.ts.map +1 -1
  16. package/dist/src/browser/browser.js +62 -7
  17. package/dist/src/browser/browser.js.map +1 -1
  18. package/dist/src/browser/cdp.d.ts.map +1 -1
  19. package/dist/src/browser/cdp.js +8 -1
  20. package/dist/src/browser/cdp.js.map +1 -1
  21. package/dist/src/cli/commands-report.d.ts.map +1 -1
  22. package/dist/src/cli/commands-report.js +46 -20
  23. package/dist/src/cli/commands-report.js.map +1 -1
  24. package/dist/src/report/util.d.ts +4 -0
  25. package/dist/src/report/util.d.ts.map +1 -1
  26. package/dist/src/report/util.js +9 -3
  27. package/dist/src/report/util.js.map +1 -1
  28. package/dist/tsconfig.tsbuildinfo +1 -1
  29. package/package.json +1 -1
  30. package/src/__tests__/launch-close-settle.test.ts +175 -0
  31. package/src/__tests__/report-exit-code.test.ts +207 -0
  32. package/src/browser/actions.ts +21 -12
  33. package/src/browser/bridge.ts +4 -1
  34. package/src/browser/browser.ts +63 -7
  35. package/src/browser/cdp.ts +8 -1
  36. package/src/cli/commands-report.ts +46 -19
  37. package/src/report/util.ts +9 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monoes/monobrowse",
3
- "version": "1.0.15",
3
+ "version": "1.0.17",
4
4
  "description": "Native browser automation via Chrome DevTools Protocol — the engine powering monomind browse",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,175 @@
1
+ /**
2
+ * #314: launchMonobrowseBrowser() returned a promise that never settled on a
3
+ * CI runner — node:test reported `cancelledByParent` / "Promise resolution is
4
+ * still pending but the event loop has already resolved". Two distinct bugs
5
+ * in browser.ts, both fixed here, both reproduced below without a real
6
+ * Chrome:
7
+ *
8
+ * 1. launchOnFreePort() spawned Chrome with no 'error' listener on the
9
+ * child. A spawn failure (EACCES/ENOENT — the executable existed when
10
+ * findChrome()'s existsSync() checked it, then failed to actually exec)
11
+ * fires Node's 'error' event asynchronously; with nothing listening,
12
+ * Node throws it as an uncaught exception and the whole process goes
13
+ * down, stranding launchBrowser()'s promise forever pending rather than
14
+ * rejecting it. A process that execs but dies immediately (missing
15
+ * shared libraries, a sandboxed container refusing it) hit the same
16
+ * "poll forever, then possibly crash" shape via 'exit' instead.
17
+ *
18
+ * 2. closeBrowser()'s waitForProcessExit() polled with a deliberately
19
+ * unref'd setTimeout. unref'd means the timer does not count toward
20
+ * "does the event loop have anything left to do" — so once nothing else
21
+ * is pinning the loop (as soon as the CDP websocket closes, which
22
+ * happens right around here as Chrome shuts down), Node considers the
23
+ * loop drained and exits WITHOUT ever firing that timer's callback,
24
+ * leaving closeBrowser()'s promise (and its caller's) pending forever.
25
+ * This is real and 100% reproducible locally with a genuine launched
26
+ * Chrome and nothing else running — see the PR description for the
27
+ * manual repro. vi.useFakeTimers() (used throughout close-browser.test.ts)
28
+ * cannot catch this class of bug, because fake timers do not model real
29
+ * event-loop-drain semantics — hence a dedicated, real-timer test here.
30
+ */
31
+
32
+ import { spawn } from 'node:child_process';
33
+ import { chmodSync, writeFileSync } from 'node:fs';
34
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
35
+ import { tmpdir } from 'node:os';
36
+ import { join } from 'node:path';
37
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
38
+
39
+ // Minimal fake CDP websocket — enough for closeBrowser()'s Browser.close
40
+ // round trip, without needing a real Chrome or CDP endpoint. Deliberately
41
+ // NOT paired with vi.useFakeTimers(): the bug under test only manifests
42
+ // against the real event loop.
43
+ let lastSocket: FakeWs | null = null;
44
+
45
+ class FakeWs {
46
+ handlers = new Map<string, Array<(...a: unknown[]) => void>>();
47
+ constructor(public url: string) {
48
+ lastSocket = this;
49
+ }
50
+ on(event: string, fn: (...a: unknown[]) => void): void {
51
+ if (!this.handlers.has(event)) this.handlers.set(event, []);
52
+ this.handlers.get(event)!.push(fn);
53
+ }
54
+ emit(event: string, ...args: unknown[]): void {
55
+ for (const fn of [...(this.handlers.get(event) ?? [])]) fn(...args);
56
+ }
57
+ send(data: string): void {
58
+ const { id } = JSON.parse(data) as { id: number };
59
+ queueMicrotask(() => this.deliver({ id, result: {} }));
60
+ }
61
+ close(): void {}
62
+ deliver(msg: unknown): void {
63
+ this.emit('message', Buffer.from(JSON.stringify(msg)));
64
+ }
65
+ }
66
+
67
+ vi.mock('ws', () => ({ WebSocket: FakeWs }));
68
+
69
+ let tempDir: string;
70
+ const children: Array<ReturnType<typeof spawn>> = [];
71
+
72
+ beforeEach(async () => {
73
+ tempDir = await mkdtemp(join(tmpdir(), 'monobrowse-launch-close-'));
74
+ vi.spyOn(process, 'cwd').mockReturnValue(tempDir);
75
+ });
76
+
77
+ afterEach(async () => {
78
+ vi.restoreAllMocks();
79
+ for (const child of children.splice(0)) {
80
+ if (child.pid && !child.killed) {
81
+ try {
82
+ process.kill(child.pid, 'SIGKILL');
83
+ } catch {
84
+ /* already gone */
85
+ }
86
+ }
87
+ }
88
+ await rm(tempDir, { recursive: true, force: true });
89
+ });
90
+
91
+ async function writePersistedPort(port: number, pid: number, savedAt: number): Promise<void> {
92
+ const dir = join(tempDir, '.monomind', 'monobrowse');
93
+ await mkdir(dir, { recursive: true });
94
+ await writeFile(
95
+ join(dir, 'active-port.json'),
96
+ JSON.stringify({ port, pid, launched: true, savedAt }),
97
+ 'utf-8',
98
+ );
99
+ }
100
+
101
+ async function connectedClient() {
102
+ vi.resetModules();
103
+ const { CdpClient } = await import('../browser/cdp.js');
104
+ const client = new CdpClient();
105
+ const p = client.connect('ws://127.0.0.1:9333/devtools/page/ABC');
106
+ lastSocket!.emit('open');
107
+ await p;
108
+ return client;
109
+ }
110
+
111
+ describe('#314: launchBrowser settles instead of hanging when Chrome fails to start', () => {
112
+ it('rejects promptly (does not hang or crash the process) when the executable cannot be spawned', async () => {
113
+ vi.resetModules();
114
+ const { launchBrowser } = await import('../browser/browser.js');
115
+ const fake = join(tempDir, 'chrome');
116
+ // Exists (passes findChrome()'s existsSync check) but is not a valid
117
+ // executable — a real EACCES/ENOEXEC from spawn(), not a synchronous
118
+ // "file not found" that launchBrowser would reject before ever exec'ing.
119
+ writeFileSync(fake, 'not an executable\n');
120
+ chmodSync(fake, 0o644);
121
+
122
+ const start = Date.now();
123
+ await expect(
124
+ launchBrowser({ executablePath: fake, port: 23495, launchTimeoutMs: 3000 }),
125
+ ).rejects.toThrow(/Chrome failed to start on port 23495/);
126
+ // Without a child 'error' listener, this used to depend entirely on
127
+ // whether something outside this function happened to intercept the
128
+ // resulting uncaught exception (a bare node:test process has nothing
129
+ // that does — see the module doc comment); where it happened not to
130
+ // crash the process outright, the promise still only ever settled by
131
+ // 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);
136
+
137
+ it('rejects with a clear message when Chrome exits immediately instead of opening its CDP port', async () => {
138
+ vi.resetModules();
139
+ const { launchBrowser } = await import('../browser/browser.js');
140
+ const fake = join(tempDir, 'chrome.sh');
141
+ writeFileSync(fake, '#!/bin/sh\nexit 1\n');
142
+ chmodSync(fake, 0o755);
143
+
144
+ 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);
148
+ });
149
+
150
+ describe('#314: closeBrowser settles under the real event loop, not fake timers', () => {
151
+ it('resolves promptly when nothing else is pinning the event loop', async () => {
152
+ vi.resetModules();
153
+ const { closeBrowser } = await import('../browser/browser.js');
154
+ const port = 23497;
155
+
156
+ // A real, short-lived child — not Chrome, just something with a genuine,
157
+ // observable exit so process.kill(pid, 0) behaves for real and there is
158
+ // no other handle (socket, ref'd timer) left to accidentally keep the
159
+ // event loop alive on this bug's behalf.
160
+ const child = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 150)'], {
161
+ stdio: 'ignore',
162
+ });
163
+ children.push(child);
164
+ await writePersistedPort(port, child.pid!, Date.now());
165
+
166
+ const client = await connectedClient();
167
+
168
+ const outcome = await Promise.race([
169
+ closeBrowser(client, port).then(() => 'settled' as const),
170
+ new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), 4000)),
171
+ ]);
172
+
173
+ expect(outcome).toBe('settled');
174
+ }, 8000);
175
+ });
@@ -0,0 +1,207 @@
1
+ /**
2
+ * `monobrowse report` exited 0, and printed nothing at all, on a page that
3
+ * FAILED its budgets — while the report JSON it had already written on disk
4
+ * said `"verdict": "fail"`. Anything gating on exit status (CI, an agent, a
5
+ * shell `&&` chain) saw green on a broken page, which defeats the entire
6
+ * point of the budgets feature.
7
+ *
8
+ * Two things combined to cause it:
9
+ *
10
+ * 1. closeBrowser() could hang. Its timers were unref'd, so once Chrome's
11
+ * CDP socket went away nothing was left holding the event loop open and
12
+ * Node exited before those timers ever fired. (browser.ts; the poll-loop
13
+ * half of this is #314's launch-close-settle.test.ts.)
14
+ *
15
+ * 2. commands-report.ts awaited that teardown in a `finally` positioned
16
+ * BETWEEN producing the result and printing it. So a hung teardown took
17
+ * the verdict down with it: the print never ran, `exitCode: 1` was never
18
+ * returned, and Node drained the loop and exited 0.
19
+ *
20
+ * (1) alone is not enough to make this safe — any future teardown hang would
21
+ * silently reintroduce it. (2) is the ordering fix: print the verdict and fix
22
+ * process.exitCode BEFORE teardown, so a teardown that never settles can no
23
+ * longer turn a failing page green.
24
+ *
25
+ * WHY THIS IS A SUBPROCESS TEST. The bug only exists in the semantics of a
26
+ * real process exit — "the event loop drained while a promise was still
27
+ * pending, so Node exited with the code it had". An in-process test cannot
28
+ * observe that: vitest's own runner pins the event loop, so the hang simply
29
+ * becomes a pending promise and the exit code is never consulted. (#314's
30
+ * test says the same thing about fake timers.) So this spawns a real `node`
31
+ * and asserts the code the OS sees.
32
+ *
33
+ * WHY IT NEEDS NO CHROME. The suite is deliberately browser-free (see the
34
+ * monobrowse note in .github/workflows/tests.yml) and this keeps it that way.
35
+ * The child imports the REAL built commands-report.js, but next to stub
36
+ * `session.js` / `report/index.js` modules in a temp sandbox — commands-report
37
+ * has only those two static imports, and output.js has none, so copying three
38
+ * files reproduces its module graph exactly with no loader hooks and no
39
+ * Node-version-specific API. The stub's closeBrowser() is the hang, modelled
40
+ * as a promise that never settles — which is precisely what an unref'd timer
41
+ * on a dead socket degrades into.
42
+ *
43
+ * Requires `npm run build` (CI's monobrowse job builds before `npm test`).
44
+ */
45
+
46
+ import { spawn } from 'node:child_process';
47
+ import { existsSync } from 'node:fs';
48
+ import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
49
+ import { tmpdir } from 'node:os';
50
+ import { dirname, join } from 'node:path';
51
+ import { fileURLToPath } from 'node:url';
52
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
53
+
54
+ const here = dirname(fileURLToPath(import.meta.url));
55
+ const distCli = join(here, '..', '..', 'dist', 'src', 'cli');
56
+ const hasBuild = existsSync(join(distCli, 'commands-report.js'));
57
+
58
+ let sandbox: string;
59
+
60
+ beforeEach(async () => {
61
+ sandbox = await mkdtemp(join(tmpdir(), 'monobrowse-report-exit-'));
62
+ });
63
+
64
+ afterEach(async () => {
65
+ await rm(sandbox, { recursive: true, force: true });
66
+ });
67
+
68
+ /** Stub `cli/session.js`. `closeBrowser` is the failure under test. */
69
+ function sessionStub(mode: 'hang' | 'clean'): string {
70
+ const close =
71
+ mode === 'hang'
72
+ ? // Never settles — a teardown that is wedged forever. An unref'd timer
73
+ // racing a dead socket degrades into exactly this.
74
+ 'closeBrowser: () => new Promise(() => {}),'
75
+ : 'closeBrowser: async () => {},';
76
+ return `
77
+ export const session = {
78
+ client: null, sessionId: '', targetId: '', port: 9222,
79
+ refs: new Map(), parentSessionId: '',
80
+ };
81
+ export async function ensureConnected(port) {
82
+ session.client = {}; session.sessionId = 'stub-session';
83
+ return { client: session.client, sessionId: session.sessionId };
84
+ }
85
+ export function print(line) { console.log(line); }
86
+ export async function getBrowser() {
87
+ return {
88
+ // Non-undefined so the teardown branch actually runs (it is skipped for
89
+ // a browser this process did not launch).
90
+ getLaunchedPid: () => 4242,
91
+ stopRequestCapture: () => {},
92
+ teardownConsoleCapture: () => {},
93
+ ${close}
94
+ clearActivePort: async () => {},
95
+ clearRefCache: async () => {},
96
+ };
97
+ }
98
+ export function ensureSignalCleanupHandlers() {}
99
+ `;
100
+ }
101
+
102
+ /** Stub `report/index.js` — a run that FAILED its budgets. */
103
+ const reportStub = `
104
+ const report = {
105
+ verdict: 'fail',
106
+ failures: [{ budget: 'maxConsoleErrors', expected: '<= 0', actual: 3, detail: 'boom' }],
107
+ notes: [],
108
+ trend: undefined,
109
+ diff: undefined,
110
+ };
111
+ export async function runReport() {
112
+ return {
113
+ report,
114
+ htmlPath: '/stub/r.html',
115
+ jsonPath: '/stub/r.json',
116
+ summary: '✗ FAIL — 1 budget failure(s)',
117
+ historyDir: undefined,
118
+ flake: undefined,
119
+ };
120
+ }
121
+ export const runReportRepeated = runReport;
122
+ export async function readHistory() { return { dir: '/stub', runs: [] }; }
123
+ export function toJsonReport(r) { return r; }
124
+ `;
125
+
126
+ /** Harness: mirrors cli.ts's real shape — `main().then(() => process.exit(...))`,
127
+ * NOT a top-level await. That distinction is the whole bug: with a top-level
128
+ * await Node notices the unsettled promise and exits 13, but cli.ts's promise
129
+ * chain simply never runs its `.then`, so the loop drains and the process
130
+ * exits with whatever process.exitCode holds — 0, unless someone set it. */
131
+ const harness = `
132
+ import { reportCommand } from './cli/commands-report.js';
133
+
134
+ async function main() {
135
+ const result = await reportCommand.action({
136
+ args: ['http://127.0.0.1:1/broken.html'],
137
+ flags: { out: '/stub/r.html' },
138
+ cwd: process.cwd(),
139
+ interactive: false,
140
+ });
141
+ // Only reached when teardown settles. cli.ts does exactly this.
142
+ if (result && !result.success) process.exitCode = result.exitCode ?? 1;
143
+ }
144
+
145
+ main()
146
+ .then(() => { process.exit(process.exitCode ?? 0); })
147
+ .catch((err) => { console.error(err?.message ?? String(err)); process.exit(1); });
148
+ `;
149
+
150
+ async function runChild(mode: 'hang' | 'clean'): Promise<{
151
+ code: number | null;
152
+ stdout: string;
153
+ stderr: string;
154
+ }> {
155
+ await mkdir(join(sandbox, 'cli'), { recursive: true });
156
+ await mkdir(join(sandbox, 'report'), { recursive: true });
157
+ await cp(join(distCli, 'commands-report.js'), join(sandbox, 'cli', 'commands-report.js'));
158
+ await cp(join(distCli, 'output.js'), join(sandbox, 'cli', 'output.js'));
159
+ await writeFile(join(sandbox, 'cli', 'session.js'), sessionStub(mode), 'utf-8');
160
+ await writeFile(join(sandbox, 'report', 'index.js'), reportStub, 'utf-8');
161
+ await writeFile(join(sandbox, 'run.mjs'), harness, 'utf-8');
162
+
163
+ return await new Promise((resolve) => {
164
+ const child = spawn(process.execPath, [join(sandbox, 'run.mjs')], {
165
+ stdio: ['ignore', 'pipe', 'pipe'],
166
+ cwd: sandbox,
167
+ });
168
+ let stdout = '';
169
+ let stderr = '';
170
+ child.stdout.on('data', (d) => (stdout += String(d)));
171
+ child.stderr.on('data', (d) => (stderr += String(d)));
172
+ // A hung teardown must still let the process EXIT (by draining), so if
173
+ // the child is genuinely still alive here the test should fail loudly
174
+ // rather than hang the suite.
175
+ const kill = setTimeout(() => child.kill('SIGKILL'), 15_000);
176
+ child.on('close', (code) => {
177
+ clearTimeout(kill);
178
+ resolve({ code, stdout, stderr });
179
+ });
180
+ });
181
+ }
182
+
183
+ describe.skipIf(!hasBuild)('report exit code survives a hung browser teardown', () => {
184
+ it('exits non-zero and prints the verdict even when closeBrowser never settles', async () => {
185
+ const { code, stdout, stderr } = await runChild('hang');
186
+
187
+ // THE BUG: this was 0 — a failing page reported as success, with the
188
+ // process exiting cleanly because the loop drained mid-teardown.
189
+ expect(code).toBe(1);
190
+ // ...and the verdict was never printed at all.
191
+ expect(stderr + stdout).toContain('FAIL');
192
+ expect(stdout).toContain('maxConsoleErrors');
193
+ }, 30_000);
194
+
195
+ it('still exits non-zero on the normal path where teardown completes', async () => {
196
+ const { code, stdout, stderr } = await runChild('clean');
197
+
198
+ expect(code).toBe(1);
199
+ expect(stderr + stdout).toContain('FAIL');
200
+ }, 30_000);
201
+ });
202
+
203
+ describe.skipIf(hasBuild)('report exit code subprocess test', () => {
204
+ it('is skipped because dist/ is not built (run `npm run build`)', () => {
205
+ expect(hasBuild).toBe(false);
206
+ });
207
+ });
@@ -680,18 +680,27 @@ export async function evaluateJs(
680
680
  sessionId,
681
681
  );
682
682
 
683
- const result = await (timeoutMs > 0
684
- ? Promise.race([
685
- evalPromise,
686
- new Promise<never>((_, reject) => {
687
- const t = setTimeout(
688
- () => reject(new Error(`JS evaluation timed out after ${timeoutMs}ms`)),
689
- timeoutMs,
690
- );
691
- t.unref?.();
692
- }),
693
- ])
694
- : evalPromise);
683
+ // Not unref'd (same rule as CdpClient.send): this timer settles the awaited
684
+ // race, so it has to hold the event loop open while the evaluation is in
685
+ // flight. Cleared in the finally so a fast evaluation does not keep the
686
+ // process alive for the rest of timeoutMs.
687
+ let evalTimer: ReturnType<typeof setTimeout> | undefined;
688
+ let result: Awaited<typeof evalPromise>;
689
+ try {
690
+ result = await (timeoutMs > 0
691
+ ? Promise.race([
692
+ evalPromise,
693
+ new Promise<never>((_, reject) => {
694
+ evalTimer = setTimeout(
695
+ () => reject(new Error(`JS evaluation timed out after ${timeoutMs}ms`)),
696
+ timeoutMs,
697
+ );
698
+ }),
699
+ ])
700
+ : evalPromise);
701
+ } finally {
702
+ clearTimeout(evalTimer);
703
+ }
695
704
 
696
705
  if (result.exceptionDetails) {
697
706
  throw new Error(
@@ -237,13 +237,16 @@ export class BridgeTransport implements CdpTransport {
237
237
  const envelope: BridgeEnvelope = { id, type, params };
238
238
  if (this.tabId) envelope.tabId = this.tabId;
239
239
  return new Promise<BridgeReply>((resolve, reject) => {
240
+ // Not unref'd (same rule as CdpClient.send): this timer settles a
241
+ // promise the caller awaits, so it must count toward keeping the event
242
+ // loop alive — otherwise a dead bridge socket lets Node exit 0 rather
243
+ // than surfacing the timeout. Cleared on every settle path below.
240
244
  let timer: ReturnType<typeof setTimeout> | undefined;
241
245
  if (timeoutMs > 0) {
242
246
  timer = setTimeout(() => {
243
247
  this.pending.delete(id);
244
248
  reject(new Error(`bridge ${type} timed out after ${timeoutMs}ms`));
245
249
  }, timeoutMs);
246
- timer.unref?.();
247
250
  }
248
251
  this.pending.set(id, (reply) => {
249
252
  if (timer) clearTimeout(timer);
@@ -237,6 +237,32 @@ async function launchOnFreePort(config: BrowserConfig, port: number): Promise<nu
237
237
  detached: true,
238
238
  stdio: 'ignore',
239
239
  });
240
+
241
+ // Without this, a spawn failure (e.g. EACCES/ENOENT — chromePath exists per
242
+ // findChrome()'s existsSync check but isn't actually executable, or is
243
+ // removed between the check and exec) fires Node's 'error' event on the
244
+ // next tick. With no listener, Node throws it as an uncaught exception and
245
+ // the whole process goes down — which strands this function's promise
246
+ // forever pending rather than rejecting it (issue #314: node:test reports
247
+ // that as "cancelledByParent" / "still pending" because nothing here ever
248
+ // got the chance to settle it). Recording the failure and having the poll
249
+ // loop below notice it turns that crash into a normal rejection. 'exit'
250
+ // covers the twin case: Chrome execs fine but the process itself dies
251
+ // immediately (missing shared libraries, a container's sandbox refusing
252
+ // it, etc.) — that fires 'exit', not 'error', and without this the poll
253
+ // loop would just burn its whole timeout probing a port nothing will ever
254
+ // open on.
255
+ let earlyFailure: Error | null = null;
256
+ child.on('error', (err) => {
257
+ earlyFailure ??= new Error(`Chrome failed to start on port ${port}: ${err.message}`);
258
+ });
259
+ child.on('exit', (code, signal) => {
260
+ earlyFailure ??= new Error(
261
+ `Chrome exited before the CDP endpoint opened on port ${port} ` +
262
+ `(code=${code ?? 'null'}, signal=${signal ?? 'null'})`,
263
+ );
264
+ });
265
+
240
266
  child.unref();
241
267
  if (child.pid) {
242
268
  launchedPids.set(port, child.pid);
@@ -246,7 +272,9 @@ async function launchOnFreePort(config: BrowserConfig, port: number): Promise<nu
246
272
  const launchTimeout = config.launchTimeoutMs ?? LAUNCH_TIMEOUT;
247
273
  const deadline = Date.now() + launchTimeout;
248
274
  while (Date.now() < deadline) {
275
+ if (earlyFailure) throw earlyFailure;
249
276
  await sleep(POLL_INTERVAL);
277
+ if (earlyFailure) throw earlyFailure;
250
278
  if (await isPortOpen(port)) {
251
279
  if (await isChromeIdentity(port)) return port;
252
280
  throw new Error(
@@ -256,6 +284,8 @@ async function launchOnFreePort(config: BrowserConfig, port: number): Promise<nu
256
284
  }
257
285
  }
258
286
 
287
+ if (earlyFailure) throw earlyFailure;
288
+
259
289
  // Timed out waiting for our Chrome to come up on the port. Distinguish
260
290
  // "nothing is listening" (real launch failure) from "something non-CDP is
261
291
  // squatting the port" (confusing generic timeout otherwise) via a raw TCP probe.
@@ -333,20 +363,30 @@ export async function connectToTarget(
333
363
  */
334
364
  export async function closeBrowser(client: CdpClient, port: number): Promise<void> {
335
365
  let gracefullyClosed = false;
366
+ let closeTimer: ReturnType<typeof setTimeout> | undefined;
336
367
  try {
337
368
  await Promise.race([
338
369
  client.send('Browser.close', {}),
370
+ // Deliberately NOT unref'd, for the same reason waitForProcessExit()'s
371
+ // poll below is not. This timer is the only thing that settles the race
372
+ // when Chrome's socket dies without ever acknowledging the close, and
373
+ // closeBrowser() is actively awaited by callers. An unref'd timer does
374
+ // not hold the event loop open, so Node would consider the loop drained
375
+ // and exit — status 0, before the caller's verdict was ever printed.
376
+ // Cleared in the finally so a close that IS acknowledged does not pin
377
+ // the loop for the rest of BROWSER_CLOSE_TIMEOUT_MS.
339
378
  new Promise<never>((_, reject) => {
340
- const t = setTimeout(
379
+ closeTimer = setTimeout(
341
380
  () => reject(new Error('Browser.close timed out')),
342
381
  BROWSER_CLOSE_TIMEOUT_MS,
343
382
  );
344
- t.unref?.();
345
383
  }),
346
384
  ]);
347
385
  gracefullyClosed = true;
348
386
  } catch {
349
387
  // fall through to PID-kill fallback below
388
+ } finally {
389
+ clearTimeout(closeTimer);
350
390
  }
351
391
 
352
392
  let pid = launchedPids.get(port);
@@ -403,7 +443,19 @@ export async function closeBrowser(client: CdpClient, port: number): Promise<voi
403
443
 
404
444
  /** Poll until `pid` is gone or PROCESS_EXIT_TIMEOUT_MS elapses. Returns
405
445
  * whether it exited. `process.kill(pid, 0)` throws once the process is no
406
- * longer there, on Windows as well as POSIX. */
446
+ * longer there, on Windows as well as POSIX.
447
+ *
448
+ * This timer is deliberately NOT unref'd. closeBrowser() awaits this poll
449
+ * as part of its own return value — a caller that awaits closeBrowser() is
450
+ * actively blocked on it, not doing other work in the background. Once
451
+ * Chrome's CDP websocket closes (which happens right around here, as part
452
+ * of it shutting down), nothing else may be left holding the event loop
453
+ * open; an unref'd timer at that point never fires because Node considers
454
+ * the loop drained and exits without running it, leaving this promise (and
455
+ * closeBrowser()'s) pending forever — reproduced locally by closing a real
456
+ * launched browser with nothing else scheduled. Bounded by
457
+ * PROCESS_EXIT_TIMEOUT_MS (5s) either way, so keeping it ref'd only ever
458
+ * costs a caller a few seconds, never a hang. */
407
459
  async function waitForProcessExit(pid: number): Promise<boolean> {
408
460
  const deadline = Date.now() + PROCESS_EXIT_TIMEOUT_MS;
409
461
  for (;;) {
@@ -414,8 +466,7 @@ async function waitForProcessExit(pid: number): Promise<boolean> {
414
466
  }
415
467
  if (Date.now() >= deadline) return false;
416
468
  await new Promise((resolve) => {
417
- const t = setTimeout(resolve, PROCESS_EXIT_POLL_MS);
418
- t.unref?.();
469
+ setTimeout(resolve, PROCESS_EXIT_POLL_MS);
419
470
  });
420
471
  }
421
472
  }
@@ -460,22 +511,27 @@ export async function reapIdleLaunchedBrowser(): Promise<number | null> {
460
511
  if ((await fetchTargets(session.port)).length > 0) return null;
461
512
 
462
513
  const client = new CdpClient();
514
+ let reapTimer: ReturnType<typeof setTimeout> | undefined;
463
515
  try {
464
516
  // CdpClient.connect() has no timeout of its own — a socket that never
465
517
  // opens (and never errors) would hang the launch this reap is running
466
518
  // inside of, which is strictly worse than leaving the port squatted.
519
+ // Not unref'd (see closeBrowser): a socket that never opens and never
520
+ // errors leaves this timer as the only thing that can settle the race,
521
+ // and the reap is awaited inside launch/attach. Cleared in the finally
522
+ // below so a connect that succeeds does not pin the event loop.
467
523
  await Promise.race([
468
524
  client.connect(wsUrl),
469
525
  new Promise<never>((_, reject) => {
470
- const t = setTimeout(
526
+ reapTimer = setTimeout(
471
527
  () => reject(new Error('Reap: CDP connect timed out')),
472
528
  REAP_CONNECT_TIMEOUT_MS,
473
529
  );
474
- t.unref?.();
475
530
  }),
476
531
  ]);
477
532
  await closeBrowser(client, session.port);
478
533
  } finally {
534
+ clearTimeout(reapTimer);
479
535
  try {
480
536
  client.close();
481
537
  } catch {
@@ -125,13 +125,20 @@ export class CdpClient {
125
125
  const cmd: CdpCommand = { id, method, params };
126
126
  if (sessionId) cmd.sessionId = sessionId;
127
127
 
128
+ // This timer is deliberately NOT unref'd. It is the backstop for the
129
+ // exact case described at the top of this file — a socket that dies
130
+ // without ever firing 'close'/'error', so flushPending() never runs.
131
+ // send()'s promise is awaited by essentially every caller; an unref'd
132
+ // timer cannot hold the event loop open, so Node would drain and exit
133
+ // (status 0, no output) instead of rejecting with a timeout the caller
134
+ // can report. Every settle path below clears it, so a command that gets
135
+ // its response never keeps the loop alive.
128
136
  let timer: ReturnType<typeof setTimeout> | undefined;
129
137
  if (timeoutMs > 0) {
130
138
  timer = setTimeout(() => {
131
139
  this.pendingCommands.delete(id);
132
140
  reject(new Error(`CDP command "${method}" timed out after ${timeoutMs}ms`));
133
141
  }, timeoutMs);
134
- timer.unref?.();
135
142
  }
136
143
 
137
144
  this.pendingCommands.set(id, {