@monoes/monobrowse 1.0.14 → 1.0.16

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.14",
3
+ "version": "1.0.16",
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
+ });
@@ -218,6 +218,18 @@ async function launchOnFreePort(config: BrowserConfig, port: number): Promise<nu
218
218
  defaultArgs.push('--headless=new');
219
219
  }
220
220
 
221
+ // Chrome's setuid sandbox cannot initialise on most CI runners and
222
+ // containers, so the process dies during startup and the CDP endpoint never
223
+ // opens. The launch below then burns its whole timeout before reporting a
224
+ // generic failure, which reads as a hang rather than "Chrome could not
225
+ // start". Disabling the sandbox is the standard remedy and is scoped to CI
226
+ // so a real user's browser keeps it. --disable-dev-shm-usage goes with it:
227
+ // a container's default /dev/shm is 64MB, which Chrome exhausts and then
228
+ // crashes the same way.
229
+ if (process.env.CI) {
230
+ defaultArgs.push('--no-sandbox', '--disable-dev-shm-usage');
231
+ }
232
+
221
233
  // Cap caller-supplied args to prevent memory exhaustion via huge argument arrays
222
234
  const callerArgs = (config.args ?? []).slice(0, 50);
223
235
  const args = [...defaultArgs, ...callerArgs];
@@ -225,6 +237,32 @@ async function launchOnFreePort(config: BrowserConfig, port: number): Promise<nu
225
237
  detached: true,
226
238
  stdio: 'ignore',
227
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
+
228
266
  child.unref();
229
267
  if (child.pid) {
230
268
  launchedPids.set(port, child.pid);
@@ -234,7 +272,9 @@ async function launchOnFreePort(config: BrowserConfig, port: number): Promise<nu
234
272
  const launchTimeout = config.launchTimeoutMs ?? LAUNCH_TIMEOUT;
235
273
  const deadline = Date.now() + launchTimeout;
236
274
  while (Date.now() < deadline) {
275
+ if (earlyFailure) throw earlyFailure;
237
276
  await sleep(POLL_INTERVAL);
277
+ if (earlyFailure) throw earlyFailure;
238
278
  if (await isPortOpen(port)) {
239
279
  if (await isChromeIdentity(port)) return port;
240
280
  throw new Error(
@@ -244,6 +284,8 @@ async function launchOnFreePort(config: BrowserConfig, port: number): Promise<nu
244
284
  }
245
285
  }
246
286
 
287
+ if (earlyFailure) throw earlyFailure;
288
+
247
289
  // Timed out waiting for our Chrome to come up on the port. Distinguish
248
290
  // "nothing is listening" (real launch failure) from "something non-CDP is
249
291
  // squatting the port" (confusing generic timeout otherwise) via a raw TCP probe.
@@ -391,7 +433,19 @@ export async function closeBrowser(client: CdpClient, port: number): Promise<voi
391
433
 
392
434
  /** Poll until `pid` is gone or PROCESS_EXIT_TIMEOUT_MS elapses. Returns
393
435
  * whether it exited. `process.kill(pid, 0)` throws once the process is no
394
- * longer there, on Windows as well as POSIX. */
436
+ * longer there, on Windows as well as POSIX.
437
+ *
438
+ * This timer is deliberately NOT unref'd. closeBrowser() awaits this poll
439
+ * as part of its own return value — a caller that awaits closeBrowser() is
440
+ * actively blocked on it, not doing other work in the background. Once
441
+ * Chrome's CDP websocket closes (which happens right around here, as part
442
+ * of it shutting down), nothing else may be left holding the event loop
443
+ * open; an unref'd timer at that point never fires because Node considers
444
+ * the loop drained and exits without running it, leaving this promise (and
445
+ * closeBrowser()'s) pending forever — reproduced locally by closing a real
446
+ * launched browser with nothing else scheduled. Bounded by
447
+ * PROCESS_EXIT_TIMEOUT_MS (5s) either way, so keeping it ref'd only ever
448
+ * costs a caller a few seconds, never a hang. */
395
449
  async function waitForProcessExit(pid: number): Promise<boolean> {
396
450
  const deadline = Date.now() + PROCESS_EXIT_TIMEOUT_MS;
397
451
  for (;;) {
@@ -402,8 +456,7 @@ async function waitForProcessExit(pid: number): Promise<boolean> {
402
456
  }
403
457
  if (Date.now() >= deadline) return false;
404
458
  await new Promise((resolve) => {
405
- const t = setTimeout(resolve, PROCESS_EXIT_POLL_MS);
406
- t.unref?.();
459
+ setTimeout(resolve, PROCESS_EXIT_POLL_MS);
407
460
  });
408
461
  }
409
462
  }