@monoes/monobrowse 1.0.17 → 1.0.19
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.test.js +132 -1
- package/dist/src/__tests__/browser-launch.test.js.map +1 -1
- package/dist/src/browser/browser.d.ts.map +1 -1
- package/dist/src/browser/browser.js +128 -33
- package/dist/src/browser/browser.js.map +1 -1
- package/dist/src/browser/types.d.ts +2 -0
- package/dist/src/browser/types.d.ts.map +1 -1
- package/dist/src/browser/types.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/__tests__/browser-launch.test.ts +142 -1
- package/src/browser/browser.ts +126 -30
- package/src/browser/types.ts +2 -0
package/package.json
CHANGED
|
@@ -9,10 +9,13 @@
|
|
|
9
9
|
* services; each test binds/tears down its own listeners.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
12
13
|
import { createServer as createHttpServer, type Server as HttpServer } from 'node:http';
|
|
13
14
|
import { createServer as createTcpServer, type Socket, type Server as TcpServer } from 'node:net';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
14
17
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
15
|
-
import { launchBrowser } from '../browser/browser.js';
|
|
18
|
+
import { getLaunchedPid, getLaunchedUserDataDir, launchBrowser } from '../browser/browser.js';
|
|
16
19
|
|
|
17
20
|
const BASE = 23470;
|
|
18
21
|
|
|
@@ -111,3 +114,141 @@ describe('launchBrowser — port scan/attach decisions', () => {
|
|
|
111
114
|
);
|
|
112
115
|
}, 15000); // generous margin — 10 candidates, each a fast isTcpPortOpen check
|
|
113
116
|
});
|
|
117
|
+
|
|
118
|
+
// A stand-in for the Chrome binary: like real Chrome given
|
|
119
|
+
// --remote-debugging-port=0, it binds a kernel-assigned port and only then
|
|
120
|
+
// writes that port to <user-data-dir>/DevToolsActivePort.
|
|
121
|
+
const FAKE_CHROME = `#!/usr/bin/env node
|
|
122
|
+
const { createServer } = require('node:http');
|
|
123
|
+
const { writeFileSync } = require('node:fs');
|
|
124
|
+
const { join } = require('node:path');
|
|
125
|
+
const dir = process.argv.find((a) => a.startsWith('--user-data-dir=')).slice('--user-data-dir='.length);
|
|
126
|
+
const server = createServer((req, res) => {
|
|
127
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
128
|
+
res.end(req.url === '/json/version' ? JSON.stringify({ Browser: 'Chrome/999.0.0.0' }) : '[]');
|
|
129
|
+
});
|
|
130
|
+
// Start slower than launchBrowser's first poll, so a stale file is read first.
|
|
131
|
+
setTimeout(() => server.listen(0, '127.0.0.1', () => {
|
|
132
|
+
writeFileSync(join(dir, 'DevToolsActivePort'), server.address().port + '\\n/devtools/browser/fake');
|
|
133
|
+
}), 600);
|
|
134
|
+
`;
|
|
135
|
+
|
|
136
|
+
describe.skipIf(process.platform === 'win32')(
|
|
137
|
+
'launchBrowser — port 0 (Chrome picks the port)',
|
|
138
|
+
() => {
|
|
139
|
+
it('returns the port its own Chrome reported, never another CDP endpoint', async () => {
|
|
140
|
+
const dir = mkdtempSync(join(tmpdir(), 'monobrowse-port0-'));
|
|
141
|
+
const exe = join(dir, 'fake-chrome.cjs');
|
|
142
|
+
writeFileSync(exe, FAKE_CHROME);
|
|
143
|
+
chmodSync(exe, 0o755);
|
|
144
|
+
const profile = join(dir, 'profile');
|
|
145
|
+
// A Chrome-looking endpoint that is NOT ours, named by a stale
|
|
146
|
+
// DevToolsActivePort left in the profile dir by an earlier run.
|
|
147
|
+
const foreign = BASE + 5;
|
|
148
|
+
const s = createHttpServer((req, res) => {
|
|
149
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
150
|
+
res.end(req.url === '/json/version' ? JSON.stringify({ Browser: 'Chrome/1.0' }) : '[]');
|
|
151
|
+
});
|
|
152
|
+
s.on('connection', (sock) => sockets.push(sock));
|
|
153
|
+
await new Promise<void>((resolve) => s.listen(foreign, '127.0.0.1', () => resolve()));
|
|
154
|
+
servers.push(s);
|
|
155
|
+
mkdirSync(profile);
|
|
156
|
+
writeFileSync(join(profile, 'DevToolsActivePort'), `${foreign}\n/devtools/browser/stale`);
|
|
157
|
+
|
|
158
|
+
let port: number | undefined;
|
|
159
|
+
try {
|
|
160
|
+
port = await launchBrowser({ port: 0, userDataDir: profile, executablePath: exe });
|
|
161
|
+
expect(port).not.toBe(foreign);
|
|
162
|
+
expect(port).toBeGreaterThan(0);
|
|
163
|
+
expect(getLaunchedPid(port)).toBeTypeOf('number');
|
|
164
|
+
} finally {
|
|
165
|
+
const pid = port === undefined ? undefined : getLaunchedPid(port);
|
|
166
|
+
if (pid) process.kill(pid, 'SIGKILL');
|
|
167
|
+
rmSync(dir, { recursive: true, force: true });
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('refuses port 0 without a dedicated userDataDir', async () => {
|
|
172
|
+
await expect(launchBrowser({ port: 0 })).rejects.toThrow(/requires a dedicated userDataDir/);
|
|
173
|
+
});
|
|
174
|
+
},
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
// A stand-in for the Chrome binary that, unlike FAKE_CHROME above, actually
|
|
178
|
+
// binds the FIXED port it is given via --remote-debugging-port (real Chrome,
|
|
179
|
+
// launched with no --port flag, always resolves to the same literal default
|
|
180
|
+
// port too — see cli/commands.ts's `port` option default). If the bind
|
|
181
|
+
// fails (another process already has it), it exits the way real Chrome does
|
|
182
|
+
// when a concurrent launch wins the same "free-looking" port: before its
|
|
183
|
+
// CDP endpoint ever opens.
|
|
184
|
+
const FAKE_CHROME_FIXED_PORT = `#!/usr/bin/env node
|
|
185
|
+
const { createServer } = require('node:http');
|
|
186
|
+
const { writeFileSync, mkdirSync } = require('node:fs');
|
|
187
|
+
const { join } = require('node:path');
|
|
188
|
+
const port = Number(
|
|
189
|
+
process.argv.find((a) => a.startsWith('--remote-debugging-port=')).slice('--remote-debugging-port='.length),
|
|
190
|
+
);
|
|
191
|
+
const dir = process.argv.find((a) => a.startsWith('--user-data-dir=')).slice('--user-data-dir='.length);
|
|
192
|
+
const server = createServer((req, res) => {
|
|
193
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
194
|
+
res.end(req.url === '/json/version' ? JSON.stringify({ Browser: 'Chrome/999.0.0.0' }) : '[]');
|
|
195
|
+
});
|
|
196
|
+
server.on('error', () => {
|
|
197
|
+
process.exit(21);
|
|
198
|
+
});
|
|
199
|
+
server.listen(port, '127.0.0.1', () => {
|
|
200
|
+
mkdirSync(dir, { recursive: true });
|
|
201
|
+
writeFileSync(join(dir, 'DevToolsActivePort'), port + '\\n/devtools/browser/fake');
|
|
202
|
+
});
|
|
203
|
+
`;
|
|
204
|
+
|
|
205
|
+
describe.skipIf(process.platform === 'win32')(
|
|
206
|
+
'launchBrowser — concurrent launches with no explicit --port (TOCTOU race)',
|
|
207
|
+
() => {
|
|
208
|
+
it('two concurrent launches racing for the same default port both succeed, on different ports and profiles', async () => {
|
|
209
|
+
const dir = mkdtempSync(join(tmpdir(), 'monobrowse-concurrent-'));
|
|
210
|
+
const exe = join(dir, 'fake-chrome.cjs');
|
|
211
|
+
writeFileSync(exe, FAKE_CHROME_FIXED_PORT);
|
|
212
|
+
chmodSync(exe, 0o755);
|
|
213
|
+
// Both calls target the SAME literal port on purpose: that is exactly
|
|
214
|
+
// what two real `monomind browse open` invocations with no --port flag
|
|
215
|
+
// do, since the CLI's `port` option always defaults to the same value
|
|
216
|
+
// whether or not the user passed it (cli/commands.ts:288).
|
|
217
|
+
const port = BASE + 10;
|
|
218
|
+
|
|
219
|
+
let ports: number[] = [];
|
|
220
|
+
try {
|
|
221
|
+
ports = await Promise.all([
|
|
222
|
+
launchBrowser({ port, executablePath: exe, launchTimeoutMs: 5000 }),
|
|
223
|
+
launchBrowser({ port, executablePath: exe, launchTimeoutMs: 5000 }),
|
|
224
|
+
]);
|
|
225
|
+
} finally {
|
|
226
|
+
for (const p of ports) {
|
|
227
|
+
const pid = getLaunchedPid(p);
|
|
228
|
+
if (pid) {
|
|
229
|
+
try {
|
|
230
|
+
process.kill(pid, 'SIGKILL');
|
|
231
|
+
} catch {
|
|
232
|
+
/* already gone */
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
rmSync(dir, { recursive: true, force: true });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
expect(ports).toHaveLength(2);
|
|
240
|
+
// The old probe-then-launch race let both calls observe the same
|
|
241
|
+
// candidate as free and spawn Chrome on it — one bind wins, the
|
|
242
|
+
// other's Chrome exits before its CDP endpoint opens and the whole
|
|
243
|
+
// launchBrowser() call rejects. Both resolving at all is the
|
|
244
|
+
// regression check; landing on different ports (rather than one
|
|
245
|
+
// silently adopting the other's browser) proves the scan actually
|
|
246
|
+
// moved on instead of colliding.
|
|
247
|
+
expect(ports[0]).not.toBe(ports[1]);
|
|
248
|
+
const dirs = ports.map((p) => getLaunchedUserDataDir(p));
|
|
249
|
+
expect(dirs[0]).not.toBe(dirs[1]);
|
|
250
|
+
expect(dirs[0]).toBeTruthy();
|
|
251
|
+
expect(dirs[1]).toBeTruthy();
|
|
252
|
+
}, 15000);
|
|
253
|
+
},
|
|
254
|
+
);
|
package/src/browser/browser.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execSync, spawn } from 'node:child_process';
|
|
2
|
-
import { existsSync } from 'node:fs';
|
|
2
|
+
import { existsSync, readFileSync, rmSync } from 'node:fs';
|
|
3
3
|
import { connect } from 'node:net';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
@@ -128,9 +128,17 @@ const LAUNCH_PORT_SCAN_TRIES = 10;
|
|
|
128
128
|
|
|
129
129
|
export async function launchBrowser(config: BrowserConfig = {}): Promise<number> {
|
|
130
130
|
const rawPort = config.port ?? DEFAULT_PORT;
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
131
|
+
// Port 0 means "let Chrome bind a free port and tell us which" — see
|
|
132
|
+
// launchOnFreePort. Otherwise validate port is in a safe range for
|
|
133
|
+
// localhost CDP debugging.
|
|
134
|
+
if (rawPort === 0) {
|
|
135
|
+
if (!config.userDataDir) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
'port 0 requires a dedicated userDataDir (Chrome reports the port it bound inside it).',
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
} else if (!Number.isInteger(rawPort) || rawPort < 1024 || rawPort > 65535) {
|
|
141
|
+
throw new Error(`Invalid port: ${rawPort}. Must be 0 or an integer between 1024 and 65535.`);
|
|
134
142
|
}
|
|
135
143
|
|
|
136
144
|
// Every launch/attach is this tool's only chance to notice that a previous
|
|
@@ -139,6 +147,8 @@ export async function launchBrowser(config: BrowserConfig = {}): Promise<number>
|
|
|
139
147
|
// instance on the port we are about to use is freed before we probe it.
|
|
140
148
|
await reapIdleLaunchedBrowser();
|
|
141
149
|
|
|
150
|
+
if (rawPort === 0) return launchOnFreePort(config, 0);
|
|
151
|
+
|
|
142
152
|
// strictPort: fail fast on the exact requested port, matching the old
|
|
143
153
|
// behavior (Vite has the same escape hatch for the same reason) — for
|
|
144
154
|
// callers that treat the error as a signal ("this port is taken by
|
|
@@ -158,19 +168,7 @@ export async function launchBrowser(config: BrowserConfig = {}): Promise<number>
|
|
|
158
168
|
for (let i = 0; i < LAUNCH_PORT_SCAN_TRIES && rawPort + i <= 65535; i++)
|
|
159
169
|
candidates.push(rawPort + i);
|
|
160
170
|
|
|
161
|
-
|
|
162
|
-
// original, deliberate, single-port risk ("don't silently take over an
|
|
163
|
-
// unrelated real browser that happens to be on this port"). Scanning past
|
|
164
|
-
// an occupied default must not let that same shortcut attach to a
|
|
165
|
-
// DIFFERENT Chrome instance the caller never named; forward candidates are
|
|
166
|
-
// launch-only (skip if anything is there, Chrome or not).
|
|
167
|
-
if (await isTcpPortOpen(rawPort)) {
|
|
168
|
-
if (await isChromeIdentity(rawPort)) return rawPort;
|
|
169
|
-
} else {
|
|
170
|
-
return launchOnFreePort(config, rawPort);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
for (const candidate of candidates.slice(1)) {
|
|
171
|
+
for (const candidate of candidates) {
|
|
174
172
|
// TCP-level check for "is anything at all listening" — isPortOpen()
|
|
175
173
|
// does a full CDP /json fetch, which returns false BOTH for a genuinely
|
|
176
174
|
// free port and for one occupied by a non-CDP process (that ambiguity is
|
|
@@ -179,10 +177,39 @@ export async function launchBrowser(config: BrowserConfig = {}): Promise<number>
|
|
|
179
177
|
// socket first tells free and occupied apart up front, so the scan can
|
|
180
178
|
// skip an occupied candidate instead of trying to spawn Chrome on top of
|
|
181
179
|
// it and only discovering the conflict after a timeout.
|
|
182
|
-
if (
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
180
|
+
if (await isTcpPortOpen(candidate)) {
|
|
181
|
+
// Attach-if-already-Chrome only applies to the EXACT requested port —
|
|
182
|
+
// the original, deliberate, single-port risk ("don't silently take
|
|
183
|
+
// over an unrelated real browser that happens to be on this port").
|
|
184
|
+
// Scanning past an occupied default must not let that same shortcut
|
|
185
|
+
// attach to a DIFFERENT Chrome instance the caller never named;
|
|
186
|
+
// forward candidates are launch-only (skip if anything is there,
|
|
187
|
+
// Chrome or not).
|
|
188
|
+
if (candidate === rawPort && (await isChromeIdentity(candidate))) return candidate;
|
|
189
|
+
// Occupied (by anything — not just non-Chrome, per the note above) —
|
|
190
|
+
// try the next candidate instead of failing outright, same as a
|
|
191
|
+
// normal EADDRINUSE retry would.
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
return await launchOnFreePort(config, candidate);
|
|
196
|
+
} catch (err) {
|
|
197
|
+
const isLastCandidate = candidate === candidates[candidates.length - 1];
|
|
198
|
+
// isTcpPortOpen() above is a connect() probe, not an atomic claim: two
|
|
199
|
+
// concurrent launchBrowser() calls with no explicit --port can both
|
|
200
|
+
// observe the same candidate as free and both spawn Chrome on it
|
|
201
|
+
// before either binds. One wins; the other's Chrome exits before its
|
|
202
|
+
// CDP endpoint opens ("Chrome exited before the CDP endpoint opened
|
|
203
|
+
// ... code=21"). Something is listening on the candidate NOW that
|
|
204
|
+
// was not a moment ago — a losing race, not a broken Chrome install —
|
|
205
|
+
// so try the next candidate exactly like an already-occupied one,
|
|
206
|
+
// instead of failing the whole launch outright. A candidate that
|
|
207
|
+
// fails with nothing now listening (a real launch failure — bad
|
|
208
|
+
// executable, sandbox refusal, etc.) is not a race: retrying the
|
|
209
|
+
// next candidate would only fail the same way, so surface it as-is.
|
|
210
|
+
if (!isLastCandidate && (await isTcpPortOpen(candidate))) continue;
|
|
211
|
+
throw err;
|
|
212
|
+
}
|
|
186
213
|
}
|
|
187
214
|
throw new Error(
|
|
188
215
|
`Ports ${candidates[0]}-${candidates[candidates.length - 1]} are all occupied and port ${candidates[0]} ` +
|
|
@@ -190,9 +217,50 @@ export async function launchBrowser(config: BrowserConfig = {}): Promise<number>
|
|
|
190
217
|
);
|
|
191
218
|
}
|
|
192
219
|
|
|
220
|
+
/** The port Chrome wrote to `<userDataDir>/DevToolsActivePort` once its
|
|
221
|
+
* DevTools server was listening, or null if it has not (yet). */
|
|
222
|
+
function readDevToolsActivePort(file: string): number | null {
|
|
223
|
+
try {
|
|
224
|
+
const port = Number.parseInt(readFileSync(file, 'utf8').split('\n')[0], 10);
|
|
225
|
+
return Number.isInteger(port) && port > 0 && port <= 65535 ? port : null;
|
|
226
|
+
} catch {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Spawn Chrome and wait for its CDP endpoint.
|
|
233
|
+
*
|
|
234
|
+
* With a fixed `port`, "our Chrome is up" is inferred from *some* Chrome
|
|
235
|
+
* answering on 127.0.0.1:<port> — which is not necessarily ours. When the port
|
|
236
|
+
* is already bound on 127.0.0.1 (a concurrent launch picked the same "free"
|
|
237
|
+
* port between its probe and Chrome's bind), Chrome does not fail: it logs
|
|
238
|
+
* `bind() failed: Address already in use` and listens on [::1]:<port>
|
|
239
|
+
* instead. The poll then accepts the OTHER launcher's browser, both callers
|
|
240
|
+
* drive one Chrome, and whichever closes first kills the other's connections
|
|
241
|
+
* ("CDP connection closed") while this launch's own Chrome is orphaned.
|
|
242
|
+
*
|
|
243
|
+
* `port: 0` removes the race instead of narrowing it: Chrome asks the kernel
|
|
244
|
+
* for a free port at bind time — atomic, nothing to collide on — and writes
|
|
245
|
+
* the port it got to DevToolsActivePort in its own (caller-dedicated) profile
|
|
246
|
+
* directory, which is proof the endpoint is the process we spawned.
|
|
247
|
+
*/
|
|
193
248
|
async function launchOnFreePort(config: BrowserConfig, port: number): Promise<number> {
|
|
194
249
|
const chromePath = findChrome(config.executablePath);
|
|
195
|
-
|
|
250
|
+
// Suffixed with our own pid: two concurrent launches that both probe the
|
|
251
|
+
// same candidate port as free (the race this function exists to survive —
|
|
252
|
+
// see the retry-on-collision caller above) briefly run Chrome with
|
|
253
|
+
// *identical* --user-data-dir values if it were derived from the port
|
|
254
|
+
// alone, sharing a profile directory (lock files, preferences, the
|
|
255
|
+
// DevToolsActivePort file itself) between two unrelated Chrome processes
|
|
256
|
+
// for as long as the loser stays alive. Different processes always have
|
|
257
|
+
// different pids, so this can never collide even when the port does.
|
|
258
|
+
const userDataDir =
|
|
259
|
+
config.userDataDir ?? join(tmpdir(), `monomind-browser-${port}-${process.pid}`);
|
|
260
|
+
const activePortFile = join(userDataDir, 'DevToolsActivePort');
|
|
261
|
+
// A reused profile dir may hold a previous run's file naming a port some
|
|
262
|
+
// other process now owns.
|
|
263
|
+
if (port === 0) rmSync(activePortFile, { force: true });
|
|
196
264
|
|
|
197
265
|
const defaultArgs = [
|
|
198
266
|
`--remote-debugging-port=${port}`,
|
|
@@ -264,10 +332,21 @@ async function launchOnFreePort(config: BrowserConfig, port: number): Promise<nu
|
|
|
264
332
|
});
|
|
265
333
|
|
|
266
334
|
child.unref();
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
335
|
+
const track = (boundPort: number) => {
|
|
336
|
+
if (!child.pid) return;
|
|
337
|
+
launchedPids.set(boundPort, child.pid);
|
|
338
|
+
launchedUserDataDirs.set(boundPort, userDataDir);
|
|
339
|
+
};
|
|
340
|
+
// Tracked only once Chrome is CONFIRMED up on `boundPort`, not right after
|
|
341
|
+
// spawn(). For port !== 0 this used to track(port) unconditionally the
|
|
342
|
+
// moment the child was spawned, before knowing whether it would actually
|
|
343
|
+
// win the port — when a racing launchBrowser() call retries a candidate
|
|
344
|
+
// that a concurrent process also spawned Chrome on (see the retry loop in
|
|
345
|
+
// launchBrowser), BOTH child processes called track() for the SAME
|
|
346
|
+
// candidate, and whichever call's spawn happened to run last clobbered the
|
|
347
|
+
// map entry with its own pid — including the LOSING, about-to-exit
|
|
348
|
+
// process's pid overwriting the real winner's, corrupting the very map
|
|
349
|
+
// closeBrowser()'s kill fallback depends on.
|
|
271
350
|
|
|
272
351
|
const launchTimeout = config.launchTimeoutMs ?? LAUNCH_TIMEOUT;
|
|
273
352
|
const deadline = Date.now() + launchTimeout;
|
|
@@ -275,17 +354,34 @@ async function launchOnFreePort(config: BrowserConfig, port: number): Promise<nu
|
|
|
275
354
|
if (earlyFailure) throw earlyFailure;
|
|
276
355
|
await sleep(POLL_INTERVAL);
|
|
277
356
|
if (earlyFailure) throw earlyFailure;
|
|
278
|
-
|
|
279
|
-
|
|
357
|
+
const boundPort = port === 0 ? readDevToolsActivePort(activePortFile) : port;
|
|
358
|
+
if (boundPort !== null && (await isPortOpen(boundPort))) {
|
|
359
|
+
if (await isChromeIdentity(boundPort)) {
|
|
360
|
+
track(boundPort);
|
|
361
|
+
return boundPort;
|
|
362
|
+
}
|
|
280
363
|
throw new Error(
|
|
281
|
-
`Port ${
|
|
282
|
-
`Refusing to attach — pass a different port or free port ${
|
|
364
|
+
`Port ${boundPort} is occupied by a CDP-speaking process that does not identify as Chrome/Chromium. ` +
|
|
365
|
+
`Refusing to attach — pass a different port or free port ${boundPort}.`,
|
|
283
366
|
);
|
|
284
367
|
}
|
|
285
368
|
}
|
|
286
369
|
|
|
287
370
|
if (earlyFailure) throw earlyFailure;
|
|
288
371
|
|
|
372
|
+
if (port === 0) {
|
|
373
|
+
// Not tracked under any port yet, so no closeBrowser() could ever reach
|
|
374
|
+
// it — stop it here rather than leave it running.
|
|
375
|
+
try {
|
|
376
|
+
if (child.pid) process.kill(child.pid, 'SIGKILL');
|
|
377
|
+
} catch {
|
|
378
|
+
/* already gone */
|
|
379
|
+
}
|
|
380
|
+
throw new Error(
|
|
381
|
+
`Chrome did not report a CDP port in ${activePortFile} within ${launchTimeout}ms`,
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
289
385
|
// Timed out waiting for our Chrome to come up on the port. Distinguish
|
|
290
386
|
// "nothing is listening" (real launch failure) from "something non-CDP is
|
|
291
387
|
// squatting the port" (confusing generic timeout otherwise) via a raw TCP probe.
|
package/src/browser/types.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export interface BrowserConfig {
|
|
2
|
+
/** CDP port. 0 lets Chrome bind a free one (requires `userDataDir`); the
|
|
3
|
+
* port it bound is launchBrowser's return value. */
|
|
2
4
|
port?: number;
|
|
3
5
|
/** Fail fast if `port` is occupied by anything other than an attachable
|
|
4
6
|
* Chrome, instead of scanning forward to the next free port. Default false
|