@monoes/monobrowse 1.0.28 → 1.0.30
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-double-loss.test.d.ts +16 -0
- package/dist/src/__tests__/browser-launch-double-loss.test.d.ts.map +1 -0
- package/dist/src/__tests__/browser-launch-double-loss.test.js +119 -0
- package/dist/src/__tests__/browser-launch-double-loss.test.js.map +1 -0
- package/dist/src/__tests__/chrome-fallback.test.d.ts +2 -0
- package/dist/src/__tests__/chrome-fallback.test.d.ts.map +1 -0
- package/dist/src/__tests__/chrome-fallback.test.js +63 -0
- package/dist/src/__tests__/chrome-fallback.test.js.map +1 -0
- package/dist/src/browser/browser-discovery.d.ts +8 -0
- package/dist/src/browser/browser-discovery.d.ts.map +1 -1
- package/dist/src/browser/browser-discovery.js +25 -0
- package/dist/src/browser/browser-discovery.js.map +1 -1
- package/dist/src/browser/browser-launch.d.ts.map +1 -1
- package/dist/src/browser/browser-launch.js +27 -6
- package/dist/src/browser/browser-launch.js.map +1 -1
- package/dist/src/browser/browser.d.ts +1 -1
- package/dist/src/browser/browser.d.ts.map +1 -1
- package/dist/src/browser/browser.js +1 -1
- 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-double-loss.test.ts +128 -0
- package/src/__tests__/chrome-fallback.test.ts +74 -0
- package/src/browser/browser-discovery.ts +25 -0
- package/src/browser/browser-launch.ts +27 -6
- package/src/browser/browser.ts +1 -1
package/package.json
CHANGED
|
@@ -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
|
|
122
|
-
// executable, sandbox refusal, etc.)
|
|
123
|
-
//
|
|
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 =
|
|
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
|
|
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
|
);
|
package/src/browser/browser.ts
CHANGED
|
@@ -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 {
|