@monoes/monobrowse 1.0.27 → 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/__tests__/disconnect-session.test.d.ts +13 -0
- package/dist/src/__tests__/disconnect-session.test.d.ts.map +1 -0
- package/dist/src/__tests__/disconnect-session.test.js +37 -0
- package/dist/src/__tests__/disconnect-session.test.js.map +1 -0
- package/dist/src/__tests__/profile-cleanup-close.test.d.ts +16 -0
- package/dist/src/__tests__/profile-cleanup-close.test.d.ts.map +1 -0
- package/dist/src/__tests__/profile-cleanup-close.test.js +250 -0
- package/dist/src/__tests__/profile-cleanup-close.test.js.map +1 -0
- package/dist/src/__tests__/profile-dir.test.d.ts +10 -0
- package/dist/src/__tests__/profile-dir.test.d.ts.map +1 -0
- package/dist/src/__tests__/profile-dir.test.js +160 -0
- package/dist/src/__tests__/profile-dir.test.js.map +1 -0
- package/dist/src/__tests__/setup/isolated-tmpdir.global.d.ts +2 -0
- package/dist/src/__tests__/setup/isolated-tmpdir.global.d.ts.map +1 -0
- package/dist/src/__tests__/setup/isolated-tmpdir.global.js +30 -0
- package/dist/src/__tests__/setup/isolated-tmpdir.global.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 +48 -10
- package/dist/src/browser/browser-launch.js.map +1 -1
- package/dist/src/browser/browser-lifecycle.d.ts +5 -0
- package/dist/src/browser/browser-lifecycle.d.ts.map +1 -1
- package/dist/src/browser/browser-lifecycle.js +23 -11
- package/dist/src/browser/browser-lifecycle.js.map +1 -1
- package/dist/src/browser/browser-state.d.ts +4 -0
- package/dist/src/browser/browser-state.d.ts.map +1 -1
- package/dist/src/browser/browser-state.js +10 -2
- package/dist/src/browser/browser-state.js.map +1 -1
- package/dist/src/browser/browser.d.ts +2 -2
- package/dist/src/browser/browser.d.ts.map +1 -1
- package/dist/src/browser/browser.js +2 -2
- package/dist/src/browser/browser.js.map +1 -1
- package/dist/src/browser/profile-dir.d.ts +88 -0
- package/dist/src/browser/profile-dir.d.ts.map +1 -0
- package/dist/src/browser/profile-dir.js +242 -0
- package/dist/src/browser/profile-dir.js.map +1 -0
- package/dist/src/browser/ref-cache.d.ts +4 -0
- package/dist/src/browser/ref-cache.d.ts.map +1 -1
- package/dist/src/browser/ref-cache.js +2 -0
- package/dist/src/browser/ref-cache.js.map +1 -1
- package/dist/src/browser/types.d.ts +6 -0
- package/dist/src/browser/types.d.ts.map +1 -1
- package/dist/src/browser/types.js.map +1 -1
- package/dist/src/cli/commands.d.ts +1 -0
- package/dist/src/cli/commands.d.ts.map +1 -1
- package/dist/src/cli/commands.js +2 -0
- package/dist/src/cli/commands.js.map +1 -1
- package/dist/src/cli/session.d.ts +9 -0
- package/dist/src/cli/session.d.ts.map +1 -1
- package/dist/src/cli/session.js +21 -4
- package/dist/src/cli/session.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/__tests__/disconnect-session.test.ts +42 -0
- package/src/__tests__/profile-cleanup-close.test.ts +285 -0
- package/src/__tests__/profile-dir.test.ts +196 -0
- package/src/__tests__/setup/isolated-tmpdir.global.ts +28 -0
- package/src/browser/browser-discovery.ts +25 -0
- package/src/browser/browser-launch.ts +49 -11
- package/src/browser/browser-lifecycle.ts +20 -10
- package/src/browser/browser-state.ts +11 -2
- package/src/browser/browser.ts +6 -2
- package/src/browser/profile-dir.ts +249 -0
- package/src/browser/ref-cache.ts +7 -0
- package/src/browser/types.ts +6 -0
- package/src/cli/commands.ts +2 -0
- package/src/cli/session.ts +22 -4
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #395: the crash sweep and ownership checks for the temp Chrome profiles
|
|
3
|
+
* monobrowse creates (profile-dir.ts).
|
|
4
|
+
*
|
|
5
|
+
* Every test sweeps a root of its own made with mkdtemp — never the real
|
|
6
|
+
* /tmp, where other sessions' live Chrome profiles live. (The whole package
|
|
7
|
+
* run also gets a throwaway TMPDIR, see setup/isolated-tmpdir.global.ts.)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { type ChildProcess, spawn } from 'node:child_process';
|
|
11
|
+
import { existsSync } from 'node:fs';
|
|
12
|
+
import { lutimes, mkdir, mkdtemp, rm, symlink, utimes, writeFile } from 'node:fs/promises';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
16
|
+
import {
|
|
17
|
+
isOwnedProfileDir,
|
|
18
|
+
isProfileDirInUse,
|
|
19
|
+
profileDirOwnerPid,
|
|
20
|
+
removeOwnedProfileDir,
|
|
21
|
+
sweepStaleProfileDirs,
|
|
22
|
+
} from '../browser/profile-dir.js';
|
|
23
|
+
|
|
24
|
+
let root: string;
|
|
25
|
+
let outside: string;
|
|
26
|
+
const children: ChildProcess[] = [];
|
|
27
|
+
const HOUR_AGO = new Date(Date.now() - 60 * 60 * 1000);
|
|
28
|
+
|
|
29
|
+
beforeEach(async () => {
|
|
30
|
+
root = await mkdtemp(join(tmpdir(), 'monobrowse-sweep-root-'));
|
|
31
|
+
outside = await mkdtemp(join(tmpdir(), 'monobrowse-sweep-outside-'));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(async () => {
|
|
35
|
+
for (const child of children.splice(0)) child.kill('SIGKILL');
|
|
36
|
+
await rm(root, { recursive: true, force: true });
|
|
37
|
+
await rm(outside, { recursive: true, force: true });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/** A pid that belonged to a process which has since exited. */
|
|
41
|
+
async function deadPid(): Promise<number> {
|
|
42
|
+
const child = spawn(process.execPath, ['-e', ''], { stdio: 'ignore' });
|
|
43
|
+
await new Promise((resolve) => child.once('exit', resolve));
|
|
44
|
+
return child.pid!;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A live process whose command line passes `--user-data-dir=<dir>`, the way
|
|
48
|
+
* Chrome's own does. */
|
|
49
|
+
async function holder(dir: string): Promise<ChildProcess> {
|
|
50
|
+
const child = spawn(
|
|
51
|
+
process.execPath,
|
|
52
|
+
['-e', 'setInterval(() => {}, 1000)', '--', `--user-data-dir=${dir}`],
|
|
53
|
+
{ stdio: 'ignore' },
|
|
54
|
+
);
|
|
55
|
+
children.push(child);
|
|
56
|
+
await new Promise((resolve) => child.once('spawn', resolve));
|
|
57
|
+
return child;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A profile-looking directory with some content, backdated past the grace. */
|
|
61
|
+
async function profile(name: string, opts: { young?: boolean } = {}): Promise<string> {
|
|
62
|
+
const dir = join(root, name);
|
|
63
|
+
await mkdir(join(dir, 'Default'), { recursive: true });
|
|
64
|
+
await writeFile(join(dir, 'Default', 'Preferences'), '{}');
|
|
65
|
+
if (!opts.young) await utimes(dir, HOUR_AGO, HOUR_AGO);
|
|
66
|
+
return dir;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe('#395 profileDirOwnerPid', () => {
|
|
70
|
+
it('reads the pid from exactly our two name patterns', () => {
|
|
71
|
+
expect(profileDirOwnerPid('monomind-browser-9222-1234-0a1b2c3d')).toBe(1234);
|
|
72
|
+
expect(profileDirOwnerPid('monomind-browse-4321-0a1b2c3d')).toBe(4321);
|
|
73
|
+
for (const name of [
|
|
74
|
+
'monomind-browse-4321-0A1B2C3D',
|
|
75
|
+
'monomind-browse-4321-0a1b2c3d0',
|
|
76
|
+
'monomind-browse-x-0a1b2c3d',
|
|
77
|
+
'monomind-browser-4321-0a1b2c3d',
|
|
78
|
+
'xmonomind-browse-4321-0a1b2c3d',
|
|
79
|
+
'org.chromium.Chromium.abc123',
|
|
80
|
+
]) {
|
|
81
|
+
expect(profileDirOwnerPid(name), name).toBeNull();
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('#395 sweepStaleProfileDirs', () => {
|
|
87
|
+
it('deletes stale dirs whose owner is dead and nothing uses', async () => {
|
|
88
|
+
const pid = await deadPid();
|
|
89
|
+
const a = await profile(`monomind-browser-9222-${pid}-aaaaaaaa`);
|
|
90
|
+
const b = await profile(`monomind-browse-${pid}-bbbbbbbb`);
|
|
91
|
+
|
|
92
|
+
const removed = await sweepStaleProfileDirs({ root });
|
|
93
|
+
|
|
94
|
+
expect(removed.sort()).toEqual([a, b].sort());
|
|
95
|
+
expect(existsSync(a)).toBe(false);
|
|
96
|
+
expect(existsSync(b)).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('keeps young dirs, dirs whose pid is alive, non-matching names, symlinks and files', async () => {
|
|
100
|
+
const pid = await deadPid();
|
|
101
|
+
const young = await profile(`monomind-browse-${pid}-cccccccc`, { young: true });
|
|
102
|
+
const alive = await profile(`monomind-browse-${process.pid}-dddddddd`);
|
|
103
|
+
const foreign = [
|
|
104
|
+
await profile('org.chromium.Chromium.abc123'),
|
|
105
|
+
await profile(`monomind-browse-${pid}-ABCDEF12`),
|
|
106
|
+
await profile(`monomind-browse-${pid}-abcdef12-extra`),
|
|
107
|
+
await profile(`other-monomind-browse-${pid}-abcdef12`),
|
|
108
|
+
];
|
|
109
|
+
// A symlink with our name, pointing out of the root at a real profile.
|
|
110
|
+
const target = join(outside, 'victim');
|
|
111
|
+
await mkdir(target);
|
|
112
|
+
await writeFile(join(target, 'keep.txt'), 'keep');
|
|
113
|
+
const link = join(root, `monomind-browse-${pid}-11111111`);
|
|
114
|
+
await symlink(target, link);
|
|
115
|
+
await lutimes(link, HOUR_AGO, HOUR_AGO);
|
|
116
|
+
// A plain file with our name.
|
|
117
|
+
const file = join(root, `monomind-browse-${pid}-22222222`);
|
|
118
|
+
await writeFile(file, 'not a dir');
|
|
119
|
+
await utimes(file, HOUR_AGO, HOUR_AGO);
|
|
120
|
+
|
|
121
|
+
expect(await sweepStaleProfileDirs({ root })).toEqual([]);
|
|
122
|
+
|
|
123
|
+
for (const kept of [young, alive, ...foreign, link, file]) expect(existsSync(kept)).toBe(true);
|
|
124
|
+
expect(existsSync(join(target, 'keep.txt'))).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it.skipIf(process.platform !== 'linux')(
|
|
128
|
+
'keeps a dir a live process uses as --user-data-dir, though its named pid is dead',
|
|
129
|
+
async () => {
|
|
130
|
+
// A CLI `open` exits right after launching, so the pid in the name is
|
|
131
|
+
// dead while its Chrome runs on: only the live user of the dir says so.
|
|
132
|
+
const pid = await deadPid();
|
|
133
|
+
const inUse = await profile(`monomind-browse-${pid}-eeeeeeee`);
|
|
134
|
+
await holder(inUse);
|
|
135
|
+
|
|
136
|
+
expect(await sweepStaleProfileDirs({ root })).toEqual([]);
|
|
137
|
+
expect(existsSync(inUse)).toBe(true);
|
|
138
|
+
},
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
it('without /proc, keeps a dir whose SingletonLock names a live pid', async () => {
|
|
142
|
+
const pid = await deadPid();
|
|
143
|
+
const locked = await profile(`monomind-browse-${pid}-ffffffff`);
|
|
144
|
+
await symlink(`somehost-${process.pid}`, join(locked, 'SingletonLock'));
|
|
145
|
+
await utimes(locked, HOUR_AGO, HOUR_AGO);
|
|
146
|
+
const staleLock = await profile(`monomind-browse-${pid}-99999999`);
|
|
147
|
+
await symlink(`somehost-${pid}`, join(staleLock, 'SingletonLock'));
|
|
148
|
+
await utimes(staleLock, HOUR_AGO, HOUR_AGO);
|
|
149
|
+
|
|
150
|
+
expect(await sweepStaleProfileDirs({ root, procDir: null })).toEqual([staleLock]);
|
|
151
|
+
expect(existsSync(locked)).toBe(true);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('never throws, and sweeps nothing, when the root is missing', async () => {
|
|
155
|
+
await expect(sweepStaleProfileDirs({ root: join(root, 'missing') })).resolves.toEqual([]);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe('#395 isOwnedProfileDir / removeOwnedProfileDir', () => {
|
|
160
|
+
it('refuses a caller-supplied dir, a dir outside the root, and a symlink', async () => {
|
|
161
|
+
const caller = join(root, 'my-profile');
|
|
162
|
+
await mkdir(caller);
|
|
163
|
+
const nested = join(root, 'sub', `monomind-browse-${process.pid}-aaaaaaaa`);
|
|
164
|
+
await mkdir(nested, { recursive: true });
|
|
165
|
+
const target = join(outside, 'victim');
|
|
166
|
+
await mkdir(target);
|
|
167
|
+
const link = join(root, `monomind-browse-${process.pid}-bbbbbbbb`);
|
|
168
|
+
await symlink(target, link);
|
|
169
|
+
|
|
170
|
+
for (const dir of [caller, nested, link]) {
|
|
171
|
+
expect(await isOwnedProfileDir(dir, root), dir).toBe(false);
|
|
172
|
+
expect(await removeOwnedProfileDir(dir, { root }), dir).toBe(false);
|
|
173
|
+
expect(existsSync(dir), dir).toBe(true);
|
|
174
|
+
}
|
|
175
|
+
expect(existsSync(target)).toBe(true);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('removes an owned dir once its browser pid has exited', async () => {
|
|
179
|
+
const dir = await profile(`monomind-browse-${process.pid}-cccccccc`, { young: true });
|
|
180
|
+
const pid = await deadPid();
|
|
181
|
+
expect(await removeOwnedProfileDir(dir, { root, pid })).toBe(true);
|
|
182
|
+
expect(existsSync(dir)).toBe(false);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it.skipIf(process.platform !== 'linux')(
|
|
186
|
+
'keeps an owned dir while a live process still uses it',
|
|
187
|
+
async () => {
|
|
188
|
+
const dir = await profile(`monomind-browse-${process.pid}-dddddddd`, { young: true });
|
|
189
|
+
const child = await holder(dir);
|
|
190
|
+
expect(await isProfileDirInUse(dir)).toBe(true);
|
|
191
|
+
expect(await removeOwnedProfileDir(dir, { root, pid: child.pid })).toBe(false);
|
|
192
|
+
expect(existsSync(dir)).toBe(true);
|
|
193
|
+
},
|
|
194
|
+
15_000,
|
|
195
|
+
);
|
|
196
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vitest globalSetup: run every monobrowse test worker against a throwaway
|
|
3
|
+
* TMPDIR (#395).
|
|
4
|
+
*
|
|
5
|
+
* launchBrowser() sweeps stale `monomind-browser-*` / `monomind-browse-*`
|
|
6
|
+
* profile dirs out of os.tmpdir() on every launch. The sweep only removes
|
|
7
|
+
* dead, idle, unused dirs, but a test run must never touch the real /tmp at
|
|
8
|
+
* all: other sessions' live Chrome profiles live there. Set in the main
|
|
9
|
+
* process before any worker starts, so workers and the CLI processes tests
|
|
10
|
+
* spawn inherit it.
|
|
11
|
+
*/
|
|
12
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
|
|
16
|
+
export default function setup(): () => void {
|
|
17
|
+
const saved = process.env.TMPDIR;
|
|
18
|
+
// Short on purpose: real-Chrome tests nest their own dirs under it, and
|
|
19
|
+
// Chrome aborts at startup (SIGABRT) once the unix socket path it makes in
|
|
20
|
+
// TMPDIR (…/org.chromium.Chromium.XXXXXX/SingletonSocket) passes ~107 bytes.
|
|
21
|
+
const dir = mkdtempSync(join(tmpdir(), 'mb-'));
|
|
22
|
+
process.env.TMPDIR = dir;
|
|
23
|
+
return () => {
|
|
24
|
+
if (saved === undefined) delete process.env.TMPDIR;
|
|
25
|
+
else process.env.TMPDIR = saved;
|
|
26
|
+
rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -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);
|
|
@@ -3,20 +3,24 @@
|
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
5
|
import { rmSync } from 'node:fs';
|
|
6
|
-
import { tmpdir } from 'node:os';
|
|
7
6
|
import { join } from 'node:path';
|
|
8
7
|
import {
|
|
9
8
|
browserIdOf,
|
|
10
9
|
chromeIdentity,
|
|
11
10
|
fetchBrowserWebSocketUrl,
|
|
12
|
-
findChrome,
|
|
13
11
|
isChromeIdentity,
|
|
14
12
|
isPortOpen,
|
|
15
13
|
isTcpPortOpen,
|
|
16
14
|
readDevToolsActivePort,
|
|
15
|
+
resolveChrome,
|
|
17
16
|
} from './browser-discovery.js';
|
|
18
17
|
import { reapIdleLaunchedBrowser } from './browser-lifecycle.js';
|
|
19
|
-
import { launchedPids, launchedUserDataDirs } from './browser-state.js';
|
|
18
|
+
import { launchedPids, launchedUserDataDirs, ownedUserDataDirPorts } from './browser-state.js';
|
|
19
|
+
import {
|
|
20
|
+
launchProfileDirPath,
|
|
21
|
+
removeOwnedProfileDir,
|
|
22
|
+
sweepStaleProfileDirs,
|
|
23
|
+
} from './profile-dir.js';
|
|
20
24
|
import type { BrowserConfig } from './types.js';
|
|
21
25
|
|
|
22
26
|
const DEFAULT_PORT = 9222;
|
|
@@ -32,6 +36,9 @@ const POLL_INTERVAL = 200;
|
|
|
32
36
|
* behavior). */
|
|
33
37
|
const LAUNCH_PORT_SCAN_TRIES = 10;
|
|
34
38
|
|
|
39
|
+
/** Chrome's process exited before its CDP endpoint opened. */
|
|
40
|
+
class ChromeExitedEarlyError extends Error {}
|
|
41
|
+
|
|
35
42
|
export async function launchBrowser(config: BrowserConfig = {}): Promise<number> {
|
|
36
43
|
const rawPort = config.port ?? DEFAULT_PORT;
|
|
37
44
|
// Port 0 means "let Chrome bind a free port and tell us which" — see
|
|
@@ -52,6 +59,10 @@ export async function launchBrowser(config: BrowserConfig = {}): Promise<number>
|
|
|
52
59
|
// is no daemon to do it on a timer. Bounded and non-throwing; a stale
|
|
53
60
|
// instance on the port we are about to use is freed before we probe it.
|
|
54
61
|
await reapIdleLaunchedBrowser();
|
|
62
|
+
// Same reasoning for temp profile dirs (#395): a process that crashed or
|
|
63
|
+
// was killed never deleted the one its browser used. Only dead, idle,
|
|
64
|
+
// unused dirs of our own naming go — see sweepStaleProfileDirs.
|
|
65
|
+
await sweepStaleProfileDirs();
|
|
55
66
|
|
|
56
67
|
if (rawPort === 0) return launchOnFreePort(config, 0);
|
|
57
68
|
|
|
@@ -74,6 +85,9 @@ export async function launchBrowser(config: BrowserConfig = {}): Promise<number>
|
|
|
74
85
|
for (let i = 0; i < LAUNCH_PORT_SCAN_TRIES && rawPort + i <= 65535; i++)
|
|
75
86
|
candidates.push(rawPort + i);
|
|
76
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;
|
|
77
91
|
for (const candidate of candidates) {
|
|
78
92
|
// TCP-level check for "is anything at all listening" — isPortOpen()
|
|
79
93
|
// does a full CDP /json fetch, which returns false BOTH for a genuinely
|
|
@@ -110,10 +124,25 @@ export async function launchBrowser(config: BrowserConfig = {}): Promise<number>
|
|
|
110
124
|
// was not a moment ago — a losing race, not a broken Chrome install —
|
|
111
125
|
// so try the next candidate exactly like an already-occupied one,
|
|
112
126
|
// instead of failing the whole launch outright. A candidate that
|
|
113
|
-
// fails with nothing now listening
|
|
114
|
-
// executable, sandbox refusal, etc.)
|
|
115
|
-
//
|
|
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.
|
|
116
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
|
+
}
|
|
117
146
|
throw err;
|
|
118
147
|
}
|
|
119
148
|
}
|
|
@@ -150,16 +179,17 @@ export async function launchBrowser(config: BrowserConfig = {}): Promise<number>
|
|
|
150
179
|
* doomed pid there. It also covers Chrome's [::1] fallback described above.
|
|
151
180
|
*/
|
|
152
181
|
async function launchOnFreePort(config: BrowserConfig, port: number): Promise<number> {
|
|
153
|
-
const chromePath =
|
|
182
|
+
const chromePath = await resolveChrome(config.executablePath);
|
|
154
183
|
// Unique per launch, not just per port and pid: two concurrent launches
|
|
155
184
|
// that both probe the same candidate port as free (the race this function
|
|
156
185
|
// exists to survive — see the retry-on-collision caller above) would
|
|
157
186
|
// otherwise share one profile directory (lock files, preferences, the
|
|
158
187
|
// DevToolsActivePort file itself) — including two launches from the same
|
|
159
188
|
// process, where a pid suffix alone is identical.
|
|
160
|
-
const userDataDir =
|
|
161
|
-
|
|
162
|
-
|
|
189
|
+
const userDataDir = config.userDataDir ?? launchProfileDirPath(port, randomUUID().slice(0, 8));
|
|
190
|
+
// A dir we created is deleted once its browser is gone (#395); a
|
|
191
|
+
// caller-supplied one only when the caller says it made it for us.
|
|
192
|
+
const ownsUserDataDir = config.userDataDir === undefined || config.ownsUserDataDir === true;
|
|
163
193
|
const activePortFile = join(userDataDir, 'DevToolsActivePort');
|
|
164
194
|
// A reused profile dir may hold a previous run's file naming a port some
|
|
165
195
|
// other process now owns.
|
|
@@ -246,7 +276,7 @@ async function launchOnFreePort(config: BrowserConfig, port: number): Promise<nu
|
|
|
246
276
|
earlyFailure ??= new Error(`Chrome failed to start on port ${port}: ${err.message}`);
|
|
247
277
|
});
|
|
248
278
|
child.on('exit', (code, signal) => {
|
|
249
|
-
earlyFailure ??= new
|
|
279
|
+
earlyFailure ??= new ChromeExitedEarlyError(
|
|
250
280
|
`Chrome exited before the CDP endpoint opened on port ${port} ` +
|
|
251
281
|
`(code=${code ?? 'null'}, signal=${signal ?? 'null'})`,
|
|
252
282
|
);
|
|
@@ -257,6 +287,8 @@ async function launchOnFreePort(config: BrowserConfig, port: number): Promise<nu
|
|
|
257
287
|
if (!child.pid) return;
|
|
258
288
|
launchedPids.set(boundPort, child.pid);
|
|
259
289
|
launchedUserDataDirs.set(boundPort, userDataDir);
|
|
290
|
+
if (ownsUserDataDir) ownedUserDataDirPorts.add(boundPort);
|
|
291
|
+
else ownedUserDataDirPorts.delete(boundPort);
|
|
260
292
|
};
|
|
261
293
|
// Tracked only once Chrome is CONFIRMED up on `boundPort`, not right after
|
|
262
294
|
// spawn(). For port !== 0 this used to track(port) unconditionally the
|
|
@@ -291,6 +323,7 @@ async function launchOnFreePort(config: BrowserConfig, port: number): Promise<nu
|
|
|
291
323
|
|
|
292
324
|
const launchTimeout = config.launchTimeoutMs ?? LAUNCH_TIMEOUT;
|
|
293
325
|
const deadline = Date.now() + launchTimeout;
|
|
326
|
+
let launched = false;
|
|
294
327
|
try {
|
|
295
328
|
while (Date.now() < deadline) {
|
|
296
329
|
if (earlyFailure) throw earlyFailure;
|
|
@@ -302,6 +335,7 @@ async function launchOnFreePort(config: BrowserConfig, port: number): Promise<nu
|
|
|
302
335
|
if (identity === 'chrome') {
|
|
303
336
|
if (await isOwnEndpoint(boundPort)) {
|
|
304
337
|
track(boundPort);
|
|
338
|
+
launched = true;
|
|
305
339
|
return boundPort;
|
|
306
340
|
}
|
|
307
341
|
// Some Chrome answers on the port, but not the one we started: a
|
|
@@ -350,6 +384,10 @@ async function launchOnFreePort(config: BrowserConfig, port: number): Promise<nu
|
|
|
350
384
|
throw new Error(`Chrome failed to start on port ${port} within ${launchTimeout}ms`);
|
|
351
385
|
} finally {
|
|
352
386
|
child.stderr?.destroy();
|
|
387
|
+
// A failed launch (lost port race, refusal, timeout) is never tracked,
|
|
388
|
+
// so no close will ever clean its profile — and the port-scan retry in
|
|
389
|
+
// launchBrowser can make several of these per call.
|
|
390
|
+
if (!launched && ownsUserDataDir) await removeOwnedProfileDir(userDataDir, { pid: child.pid });
|
|
353
391
|
}
|
|
354
392
|
}
|
|
355
393
|
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Split out of browser.ts (file-size sweep). Pure move: no behaviour change.
|
|
2
2
|
|
|
3
3
|
import { fetchBrowserWebSocketUrl } from './browser-discovery.js';
|
|
4
|
-
import { launchedPids, launchedUserDataDirs } from './browser-state.js';
|
|
4
|
+
import { launchedPids, launchedUserDataDirs, ownedUserDataDirPorts } from './browser-state.js';
|
|
5
5
|
import { CdpClient, fetchTargets } from './cdp.js';
|
|
6
|
+
import { removeOwnedProfileDir } from './profile-dir.js';
|
|
6
7
|
import {
|
|
7
8
|
listSessionRecords,
|
|
8
9
|
loadSessionRecord,
|
|
@@ -41,6 +42,11 @@ const REAP_CONNECT_TIMEOUT_MS = 3000;
|
|
|
41
42
|
* singleton lock, and if the caller's process exited first the kill never
|
|
42
43
|
* happened at all. Callers reasonably read a resolved close() as "that
|
|
43
44
|
* browser is gone"; on the graceful path it now means that.
|
|
45
|
+
*
|
|
46
|
+
* A profile directory monobrowse created for that browser (the default temp
|
|
47
|
+
* profile, or a CLI session's — never a caller-supplied one) is deleted once
|
|
48
|
+
* the process is gone, on both paths (#395). In a fresh process the dir and
|
|
49
|
+
* its ownership come from the persisted session record.
|
|
44
50
|
*/
|
|
45
51
|
export async function closeBrowser(client: CdpClient, port: number): Promise<void> {
|
|
46
52
|
let gracefullyClosed = false;
|
|
@@ -71,6 +77,7 @@ export async function closeBrowser(client: CdpClient, port: number): Promise<voi
|
|
|
71
77
|
}
|
|
72
78
|
|
|
73
79
|
let pid = launchedPids.get(port);
|
|
80
|
+
let profileDir = ownedUserDataDirPorts.has(port) ? launchedUserDataDirs.get(port) : undefined;
|
|
74
81
|
if (pid === undefined) {
|
|
75
82
|
// Fresh process (each CLI invocation is its own node process — see
|
|
76
83
|
// module header) — launchedPids is per-process and empty here even
|
|
@@ -89,6 +96,7 @@ export async function closeBrowser(client: CdpClient, port: number): Promise<voi
|
|
|
89
96
|
const age = persisted.savedAt !== undefined ? Date.now() - persisted.savedAt : undefined;
|
|
90
97
|
if (age === undefined || age <= PERSISTED_PID_MAX_AGE_MS) {
|
|
91
98
|
pid = persisted.pid;
|
|
99
|
+
if (persisted.ownsUserDataDir) profileDir = persisted.userDataDir;
|
|
92
100
|
}
|
|
93
101
|
}
|
|
94
102
|
} catch {
|
|
@@ -97,6 +105,7 @@ export async function closeBrowser(client: CdpClient, port: number): Promise<voi
|
|
|
97
105
|
}
|
|
98
106
|
if (pid === undefined) return; // not a process we launched, or too stale to trust — nothing to kill
|
|
99
107
|
launchedPids.delete(port);
|
|
108
|
+
ownedUserDataDirPorts.delete(port);
|
|
100
109
|
|
|
101
110
|
// Liveness-check before every SIGKILL (not just the safety-net path below)
|
|
102
111
|
// — process.kill(pid, 0) throws ESRCH if the PID is no longer running,
|
|
@@ -109,17 +118,18 @@ export async function closeBrowser(client: CdpClient, port: number): Promise<voi
|
|
|
109
118
|
} catch {
|
|
110
119
|
/* already exited, or never was — nothing to do */
|
|
111
120
|
}
|
|
112
|
-
|
|
121
|
+
} else if (!(await waitForProcessExit(pid))) {
|
|
122
|
+
// Browser.close was acknowledged — wait for the process to actually go
|
|
123
|
+
// away, then force-kill as a safety net in case it hung.
|
|
124
|
+
try {
|
|
125
|
+
process.kill(pid, 'SIGKILL');
|
|
126
|
+
} catch {
|
|
127
|
+
/* raced us to exit — expected */
|
|
128
|
+
}
|
|
113
129
|
}
|
|
114
130
|
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
if (await waitForProcessExit(pid)) return;
|
|
118
|
-
try {
|
|
119
|
-
process.kill(pid, 'SIGKILL');
|
|
120
|
-
} catch {
|
|
121
|
-
/* raced us to exit — expected */
|
|
122
|
-
}
|
|
131
|
+
// Waits (bounded) for the killed process to be gone before deleting.
|
|
132
|
+
if (profileDir) await removeOwnedProfileDir(profileDir, { pid });
|
|
123
133
|
}
|
|
124
134
|
|
|
125
135
|
/** Poll until `pid` is gone or PROCESS_EXIT_TIMEOUT_MS elapses. Returns
|
|
@@ -6,9 +6,12 @@
|
|
|
6
6
|
// to (already-running browser) are never recorded here — we only ever kill
|
|
7
7
|
// processes we launched ourselves.
|
|
8
8
|
export const launchedPids = new Map<number, number>();
|
|
9
|
-
// userDataDir per launched port
|
|
10
|
-
//
|
|
9
|
+
// userDataDir per launched port, persisted alongside the pid so a later
|
|
10
|
+
// process can clean up the temp profile dir too (#395).
|
|
11
11
|
export const launchedUserDataDirs = new Map<number, string>();
|
|
12
|
+
// Launched ports whose userDataDir monobrowse created itself (not one the
|
|
13
|
+
// caller supplied). Only these profile dirs are ever deleted on close.
|
|
14
|
+
export const ownedUserDataDirPorts = new Set<number>();
|
|
12
15
|
|
|
13
16
|
/** PID of the Chrome instance *this process* launched on `port`, if any. */
|
|
14
17
|
export function getLaunchedPid(port: number): number | undefined {
|
|
@@ -19,3 +22,9 @@ export function getLaunchedPid(port: number): number | undefined {
|
|
|
19
22
|
export function getLaunchedUserDataDir(port: number): string | undefined {
|
|
20
23
|
return launchedUserDataDirs.get(port);
|
|
21
24
|
}
|
|
25
|
+
|
|
26
|
+
/** Whether the userDataDir of the Chrome *this process* launched on `port`
|
|
27
|
+
* was created by monobrowse, and so is deleted when that browser closes. */
|
|
28
|
+
export function ownsLaunchedUserDataDir(port: number): boolean {
|
|
29
|
+
return ownedUserDataDirPorts.has(port);
|
|
30
|
+
}
|
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 {
|
|
@@ -14,4 +14,8 @@ export {
|
|
|
14
14
|
openUrl,
|
|
15
15
|
waitForLoad,
|
|
16
16
|
} from './browser-page.js';
|
|
17
|
-
export {
|
|
17
|
+
export {
|
|
18
|
+
getLaunchedPid,
|
|
19
|
+
getLaunchedUserDataDir,
|
|
20
|
+
ownsLaunchedUserDataDir,
|
|
21
|
+
} from './browser-state.js';
|