@monoes/monobrowse 1.0.28 → 2.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monoes/monobrowse",
3
- "version": "1.0.28",
3
+ "version": "2.24.0",
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,128 @@
1
+ /**
2
+ * #491: two launches that call listen() on the same free port at the same
3
+ * instant can BOTH lose it on Linux (each socket is marked LISTEN before the
4
+ * port conflict check, so each sees the other and gets EADDRINUSE). Both
5
+ * Chromes exit before their CDP endpoint opens and the port is free again
6
+ * afterwards, so the lost race looks exactly like a Chrome that cannot start.
7
+ *
8
+ * The double loss is a kernel timing window (about 1 in 600 races locally),
9
+ * so these tests reproduce its outcome deterministically instead: a fake
10
+ * Chrome that exits early without binding on its first start.
11
+ *
12
+ * Fixed ports 23440-23449, clear of browser-launch.test.ts (23460-23489) and
13
+ * reap-idle-browser.test.ts (23490-23499), which run in parallel workers.
14
+ */
15
+
16
+ import { execFileSync } from 'node:child_process';
17
+ import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
18
+ import { tmpdir } from 'node:os';
19
+ import { join } from 'node:path';
20
+ import { afterEach, describe, expect, it } from 'vitest';
21
+ import { getLaunchedPid, launchBrowser } from '../browser/browser.js';
22
+
23
+ const BASE = 23440;
24
+ const dirs: string[] = [];
25
+
26
+ afterEach(() => {
27
+ for (const dir of dirs.splice(0)) {
28
+ try {
29
+ execFileSync('pkill', ['-KILL', '-f', dir]);
30
+ } catch {
31
+ /* nothing left running */
32
+ }
33
+ rmSync(dir, { recursive: true, force: true });
34
+ }
35
+ });
36
+
37
+ /**
38
+ * A fixed-port fake Chrome. It exits with code 21 before binding anything
39
+ * on each of its first `lostStarts` starts (counted in a file next to it),
40
+ * and on every start for `lostPort`, which is what a Chrome that lost the
41
+ * double-EADDRINUSE race looks like. Otherwise it binds the port and
42
+ * announces itself like real Chrome.
43
+ */
44
+ function fakeChrome(lostStarts: number, lostPort = 0): string {
45
+ const dir = mkdtempSync(join(tmpdir(), 'monobrowse-491-'));
46
+ dirs.push(dir);
47
+ const exe = join(dir, 'fake-chrome.cjs');
48
+ writeFileSync(
49
+ exe,
50
+ `#!/usr/bin/env node
51
+ const { createServer } = require('node:http');
52
+ const { existsSync, readFileSync, writeFileSync } = require('node:fs');
53
+ const port = Number(
54
+ process.argv.find((a) => a.startsWith('--remote-debugging-port=')).slice('--remote-debugging-port='.length),
55
+ );
56
+ const counter = ${JSON.stringify(join(dir, 'starts'))};
57
+ const starts = existsSync(counter) ? Number(readFileSync(counter, 'utf8')) : 0;
58
+ writeFileSync(counter, String(starts + 1));
59
+ if (starts < ${lostStarts} || port === ${lostPort}) process.exit(21);
60
+ setTimeout(() => process.exit(0), 30000).unref();
61
+ const ws = 'ws://127.0.0.1:' + port + '/devtools/browser/fake-' + process.pid;
62
+ const server = createServer((req, res) => {
63
+ res.writeHead(200, { 'Content-Type': 'application/json' });
64
+ res.end(
65
+ req.url === '/json/version'
66
+ ? JSON.stringify({ Browser: 'Chrome/999.0.0.0', webSocketDebuggerUrl: ws })
67
+ : '[]',
68
+ );
69
+ });
70
+ server.listen(port, '127.0.0.1', () => {
71
+ process.stderr.write('\\nDevTools listening on ' + ws + '\\n');
72
+ });
73
+ `,
74
+ );
75
+ chmodSync(exe, 0o755);
76
+ return exe;
77
+ }
78
+
79
+ function startsOf(exe: string): number {
80
+ const counter = join(exe, '..', 'starts');
81
+ return existsSync(counter) ? Number(readFileSync(counter, 'utf8')) : 0;
82
+ }
83
+
84
+ describe.skipIf(process.platform === 'win32')(
85
+ 'launchBrowser — a Chrome that lost the port race with nothing left listening (#491)',
86
+ () => {
87
+ it('moves on to the next candidate instead of failing the launch', async () => {
88
+ const exe = fakeChrome(1);
89
+ const port = await launchBrowser({ port: BASE, executablePath: exe, launchTimeoutMs: 5000 });
90
+ try {
91
+ expect(port).toBe(BASE + 1);
92
+ expect(startsOf(exe)).toBe(2);
93
+ } finally {
94
+ process.kill(getLaunchedPid(port)!, 'SIGKILL');
95
+ }
96
+ }, 15000);
97
+
98
+ it('two concurrent launches that both lost the same candidate both succeed, on different ports', async () => {
99
+ // The CI failure's shape: both racers' Chromes exited early on the
100
+ // first candidate and neither found anything listening there.
101
+ const exe = fakeChrome(0, BASE + 2);
102
+ let results: PromiseSettledResult<number>[] = [];
103
+ try {
104
+ results = await Promise.allSettled([
105
+ launchBrowser({ port: BASE + 2, executablePath: exe, launchTimeoutMs: 5000 }),
106
+ launchBrowser({ port: BASE + 2, executablePath: exe, launchTimeoutMs: 5000 }),
107
+ ]);
108
+ } finally {
109
+ for (const r of results) {
110
+ const pid = r.status === 'fulfilled' ? getLaunchedPid(r.value) : undefined;
111
+ if (pid) process.kill(pid, 'SIGKILL');
112
+ }
113
+ }
114
+ for (const r of results) if (r.status === 'rejected') throw r.reason;
115
+ const ports = results.map((r) => (r as PromiseFulfilledResult<number>).value);
116
+ expect(ports[0]).not.toBe(ports[1]);
117
+ expect(ports).not.toContain(BASE + 2);
118
+ }, 15000);
119
+
120
+ it('still surfaces a Chrome that cannot start, after one more attempt', async () => {
121
+ const exe = fakeChrome(Number.POSITIVE_INFINITY);
122
+ await expect(
123
+ launchBrowser({ port: BASE + 5, executablePath: exe, launchTimeoutMs: 5000 }),
124
+ ).rejects.toThrow(/exited before the CDP endpoint opened on port 23445 \(code=21/);
125
+ expect(startsOf(exe)).toBe(2);
126
+ }, 15000);
127
+ },
128
+ );
@@ -0,0 +1,74 @@
1
+ /**
2
+ * #428: an installed browser always wins; monomind's downloaded Chrome (the
3
+ * registered fallback, or MONOBROWSE_CHROME_PATH for a child process) is used
4
+ * only when there is none.
5
+ */
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
7
+
8
+ const present = new Set<string>();
9
+ vi.mock('node:fs', async (orig) => ({
10
+ ...(await orig<typeof import('node:fs')>()),
11
+ existsSync: (p: string) => present.has(p),
12
+ }));
13
+ vi.mock('node:child_process', async (orig) => ({
14
+ ...(await orig<typeof import('node:child_process')>()),
15
+ execSync: () => {
16
+ throw new Error('which: nothing found');
17
+ },
18
+ }));
19
+
20
+ const { findChrome, resolveChrome, setChromeFallback } = await import(
21
+ '../browser/browser-discovery.js'
22
+ );
23
+ const { CHROME_EXECUTABLES } = await import('../browser/types.js');
24
+
25
+ const SYSTEM = CHROME_EXECUTABLES.find((p) => p.startsWith('/usr/bin/')) as string;
26
+
27
+ beforeEach(() => {
28
+ present.clear();
29
+ delete process.env.MONOBROWSE_CHROME_PATH;
30
+ });
31
+ afterEach(() => {
32
+ setChromeFallback(undefined);
33
+ delete process.env.MONOBROWSE_CHROME_PATH;
34
+ });
35
+
36
+ describe('resolveChrome', () => {
37
+ it('throws the usual error when there is no browser and no fallback', async () => {
38
+ await expect(resolveChrome()).rejects.toThrow('No supported browser found');
39
+ });
40
+
41
+ it('asks the fallback only when no browser is installed', async () => {
42
+ const fallback = vi.fn(async () => '/deps/chrome');
43
+ setChromeFallback(fallback);
44
+ await expect(resolveChrome()).resolves.toBe('/deps/chrome');
45
+ expect(fallback).toHaveBeenCalledOnce();
46
+
47
+ present.add(SYSTEM);
48
+ fallback.mockClear();
49
+ await expect(resolveChrome()).resolves.toBe(SYSTEM);
50
+ expect(fallback).not.toHaveBeenCalled();
51
+ });
52
+
53
+ it('never falls back for an explicit executablePath', async () => {
54
+ const fallback = vi.fn(async () => '/deps/chrome');
55
+ setChromeFallback(fallback);
56
+ await expect(resolveChrome('/nope/chrome')).rejects.toThrow('Chrome executable not found');
57
+ expect(fallback).not.toHaveBeenCalled();
58
+ });
59
+ });
60
+
61
+ describe('findChrome and MONOBROWSE_CHROME_PATH', () => {
62
+ it('uses the variable only when no browser is installed', () => {
63
+ process.env.MONOBROWSE_CHROME_PATH = '/deps/chrome';
64
+ present.add('/deps/chrome');
65
+ expect(findChrome()).toBe('/deps/chrome');
66
+ present.add(SYSTEM);
67
+ expect(findChrome()).toBe(SYSTEM);
68
+ });
69
+
70
+ it('ignores a variable that points at nothing', () => {
71
+ process.env.MONOBROWSE_CHROME_PATH = '/deps/missing';
72
+ expect(() => findChrome()).toThrow('No supported browser found');
73
+ });
74
+ });
@@ -25,11 +25,36 @@ export function findChrome(executablePath?: string): string {
25
25
  } catch {
26
26
  // ignore
27
27
  }
28
+ // A browser some other tool fetched for us (monomind sets this for its own
29
+ // child processes). Last, so an installed browser always wins.
30
+ const fromEnv = process.env.MONOBROWSE_CHROME_PATH;
31
+ if (fromEnv && existsSync(fromEnv)) return fromEnv;
28
32
  throw new Error(
29
33
  'No supported browser found. Install Google Chrome, Microsoft Edge, or Chromium — or pass executablePath in BrowserConfig.',
30
34
  );
31
35
  }
32
36
 
37
+ let chromeFallback: (() => Promise<string>) | undefined;
38
+
39
+ /**
40
+ * Registers where launchBrowser() gets a browser when findChrome() finds none
41
+ * (monomind downloads one on first use). Pass undefined to clear it.
42
+ */
43
+ export function setChromeFallback(fallback: (() => Promise<string>) | undefined): void {
44
+ chromeFallback = fallback;
45
+ }
46
+
47
+ /** findChrome(), then the registered fallback when no browser is installed.
48
+ * An explicit executablePath never falls back. */
49
+ export async function resolveChrome(executablePath?: string): Promise<string> {
50
+ try {
51
+ return findChrome(executablePath);
52
+ } catch (err) {
53
+ if (executablePath || !chromeFallback) throw err;
54
+ return chromeFallback();
55
+ }
56
+ }
57
+
33
58
  export async function isPortOpen(port: number): Promise<boolean> {
34
59
  try {
35
60
  await fetchTargets(port);
@@ -8,11 +8,11 @@ import {
8
8
  browserIdOf,
9
9
  chromeIdentity,
10
10
  fetchBrowserWebSocketUrl,
11
- findChrome,
12
11
  isChromeIdentity,
13
12
  isPortOpen,
14
13
  isTcpPortOpen,
15
14
  readDevToolsActivePort,
15
+ resolveChrome,
16
16
  } from './browser-discovery.js';
17
17
  import { reapIdleLaunchedBrowser } from './browser-lifecycle.js';
18
18
  import { launchedPids, launchedUserDataDirs, ownedUserDataDirPorts } from './browser-state.js';
@@ -36,6 +36,9 @@ const POLL_INTERVAL = 200;
36
36
  * behavior). */
37
37
  const LAUNCH_PORT_SCAN_TRIES = 10;
38
38
 
39
+ /** Chrome's process exited before its CDP endpoint opened. */
40
+ class ChromeExitedEarlyError extends Error {}
41
+
39
42
  export async function launchBrowser(config: BrowserConfig = {}): Promise<number> {
40
43
  const rawPort = config.port ?? DEFAULT_PORT;
41
44
  // Port 0 means "let Chrome bind a free port and tell us which" — see
@@ -82,6 +85,9 @@ export async function launchBrowser(config: BrowserConfig = {}): Promise<number>
82
85
  for (let i = 0; i < LAUNCH_PORT_SCAN_TRIES && rawPort + i <= 65535; i++)
83
86
  candidates.push(rawPort + i);
84
87
 
88
+ // See the catch below: one early Chrome exit on a candidate nobody holds
89
+ // afterwards is read as a lost port race, not a broken Chrome.
90
+ let toleratedEarlyExit: ChromeExitedEarlyError | null = null;
85
91
  for (const candidate of candidates) {
86
92
  // TCP-level check for "is anything at all listening" — isPortOpen()
87
93
  // does a full CDP /json fetch, which returns false BOTH for a genuinely
@@ -118,10 +124,25 @@ export async function launchBrowser(config: BrowserConfig = {}): Promise<number>
118
124
  // was not a moment ago — a losing race, not a broken Chrome install —
119
125
  // so try the next candidate exactly like an already-occupied one,
120
126
  // instead of failing the whole launch outright. A candidate that
121
- // fails with nothing now listening (a real launch failure — bad
122
- // executable, sandbox refusal, etc.) is not a race: retrying the
123
- // next candidate would only fail the same way, so surface it as-is.
127
+ // fails with nothing now listening is usually a real launch failure
128
+ // (bad executable, sandbox refusal, etc.) that the next candidate would
129
+ // only repeat, so it is surfaced as-is.
124
130
  if (!isLastCandidate && (await isTcpPortOpen(candidate))) continue;
131
+ // Usually, but not always (#491): two launches that call listen() on
132
+ // the same port at the same instant can BOTH lose it on Linux — each
133
+ // socket is marked LISTEN before the kernel checks the port for
134
+ // conflicts, so each sees the other and gets EADDRINUSE. Both Chromes
135
+ // exit early and the port is free again, which is indistinguishable
136
+ // from a Chrome that cannot start. Move on once; a Chrome that really
137
+ // cannot start exits the same way on the next candidate, and the first
138
+ // of the two identical failures is surfaced.
139
+ if (err instanceof ChromeExitedEarlyError) {
140
+ if (!isLastCandidate && !toleratedEarlyExit) {
141
+ toleratedEarlyExit = err;
142
+ continue;
143
+ }
144
+ throw toleratedEarlyExit ?? err;
145
+ }
125
146
  throw err;
126
147
  }
127
148
  }
@@ -158,7 +179,7 @@ export async function launchBrowser(config: BrowserConfig = {}): Promise<number>
158
179
  * doomed pid there. It also covers Chrome's [::1] fallback described above.
159
180
  */
160
181
  async function launchOnFreePort(config: BrowserConfig, port: number): Promise<number> {
161
- const chromePath = findChrome(config.executablePath);
182
+ const chromePath = await resolveChrome(config.executablePath);
162
183
  // Unique per launch, not just per port and pid: two concurrent launches
163
184
  // that both probe the same candidate port as free (the race this function
164
185
  // exists to survive — see the retry-on-collision caller above) would
@@ -255,7 +276,7 @@ async function launchOnFreePort(config: BrowserConfig, port: number): Promise<nu
255
276
  earlyFailure ??= new Error(`Chrome failed to start on port ${port}: ${err.message}`);
256
277
  });
257
278
  child.on('exit', (code, signal) => {
258
- earlyFailure ??= new Error(
279
+ earlyFailure ??= new ChromeExitedEarlyError(
259
280
  `Chrome exited before the CDP endpoint opened on port ${port} ` +
260
281
  `(code=${code ?? 'null'}, signal=${signal ?? 'null'})`,
261
282
  );
@@ -3,7 +3,7 @@
3
3
  // re-exports everything it used to define, so every existing import path
4
4
  // keeps working.
5
5
 
6
- export { isPortOpen } from './browser-discovery.js';
6
+ export { findChrome, isPortOpen, setChromeFallback } from './browser-discovery.js';
7
7
  export { launchBrowser } from './browser-launch.js';
8
8
  export { closeBrowser, reapIdleLaunchedBrowser } from './browser-lifecycle.js';
9
9
  export {