@monoes/monobrowse 1.0.27 → 1.0.28
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__/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-launch.d.ts.map +1 -1
- package/dist/src/browser/browser-launch.js +21 -4
- 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 +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/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__/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-launch.ts +22 -5
- package/src/browser/browser-lifecycle.ts +20 -10
- package/src/browser/browser-state.ts +11 -2
- package/src/browser/browser.ts +5 -1
- 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
package/package.json
CHANGED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* disconnectSession() — drops this process's CDP connection without touching
|
|
3
|
+
* the browser (#408).
|
|
4
|
+
*
|
|
5
|
+
* `monomind browse` runs inside a CLI that lets the event loop drain instead
|
|
6
|
+
* of calling process.exit() (ADR-R001). A CDP websocket left open after the
|
|
7
|
+
* subcommand printed its output kept every `monomind browse …` alive until the
|
|
8
|
+
* 5 s force-exit watchdog. The embedder calls disconnectSession() once the
|
|
9
|
+
* subcommand is done; the browser must survive it, because the next command
|
|
10
|
+
* (a new process) reconnects to that same session.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
14
|
+
import type { CdpClient } from '../browser/cdp.js';
|
|
15
|
+
import { disconnectSession } from '../cli/commands.js';
|
|
16
|
+
import { session } from '../cli/session.js';
|
|
17
|
+
|
|
18
|
+
describe('disconnectSession', () => {
|
|
19
|
+
it('closes the CDP client and clears it, leaving the session record alone', () => {
|
|
20
|
+
const close = vi.fn();
|
|
21
|
+
const send = vi.fn();
|
|
22
|
+
session.client = { close, send } as unknown as CdpClient;
|
|
23
|
+
session.sessionId = 'sid-1';
|
|
24
|
+
session.port = 9333;
|
|
25
|
+
|
|
26
|
+
disconnectSession();
|
|
27
|
+
|
|
28
|
+
expect(close).toHaveBeenCalledTimes(1);
|
|
29
|
+
// No CDP command either: Browser.close would end the session others reuse.
|
|
30
|
+
expect(send).not.toHaveBeenCalled();
|
|
31
|
+
expect(session.client).toBeNull();
|
|
32
|
+
expect(session.sessionId).toBe('');
|
|
33
|
+
// The port pin survives so a later in-process command resolves the same session.
|
|
34
|
+
expect(session.port).toBe(9333);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('is a no-op when no client was ever connected', () => {
|
|
38
|
+
session.client = null;
|
|
39
|
+
expect(() => disconnectSession()).not.toThrow();
|
|
40
|
+
expect(session.client).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #395: closing (or killing) a browser monobrowse launched deletes the temp
|
|
3
|
+
* profile dir monobrowse created for it — and never a caller-supplied one.
|
|
4
|
+
*
|
|
5
|
+
* No real Chrome: launchBrowser spawns a fake-chrome script that behaves
|
|
6
|
+
* like Chrome where it matters here (creates and writes into its
|
|
7
|
+
* --user-data-dir, serves /json/version, announces its browser id on stderr,
|
|
8
|
+
* writes DevToolsActivePort for port 0). Browser.close goes over a fake `ws`
|
|
9
|
+
* socket that kills the fixture, as a real Chrome exits on it. A client that
|
|
10
|
+
* never connected drives the kill fallback (its Browser.close fails at once).
|
|
11
|
+
*
|
|
12
|
+
* TMPDIR is pointed at a root of each test's own, so both the launch-time
|
|
13
|
+
* sweep and the default profile dir live there — never the real /tmp.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { type ChildProcess, spawn } from 'node:child_process';
|
|
17
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
19
|
+
import { createServer } from 'node:net';
|
|
20
|
+
import { tmpdir } from 'node:os';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
23
|
+
|
|
24
|
+
let onBrowserClose: (() => void) | null = null;
|
|
25
|
+
let lastSocket: FakeWs | null = null;
|
|
26
|
+
|
|
27
|
+
/** Acknowledges every command; `Browser.close` also runs onBrowserClose. */
|
|
28
|
+
class FakeWs {
|
|
29
|
+
handlers = new Map<string, Array<(...a: unknown[]) => void>>();
|
|
30
|
+
constructor(public url: string) {
|
|
31
|
+
lastSocket = this;
|
|
32
|
+
}
|
|
33
|
+
on(event: string, fn: (...a: unknown[]) => void): void {
|
|
34
|
+
if (!this.handlers.has(event)) this.handlers.set(event, []);
|
|
35
|
+
this.handlers.get(event)!.push(fn);
|
|
36
|
+
}
|
|
37
|
+
emit(event: string, ...args: unknown[]): void {
|
|
38
|
+
for (const fn of [...(this.handlers.get(event) ?? [])]) fn(...args);
|
|
39
|
+
}
|
|
40
|
+
send(data: string): void {
|
|
41
|
+
const { id, method } = JSON.parse(data) as { id: number; method: string };
|
|
42
|
+
if (method === 'Browser.close') onBrowserClose?.();
|
|
43
|
+
queueMicrotask(() => this.emit('message', Buffer.from(JSON.stringify({ id, result: {} }))));
|
|
44
|
+
}
|
|
45
|
+
close(): void {}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
vi.mock('ws', () => ({ WebSocket: FakeWs }));
|
|
49
|
+
|
|
50
|
+
// Exits once the test process is gone, and within a minute regardless, so a
|
|
51
|
+
// fixture never outlives the run.
|
|
52
|
+
const FAKE_CHROME = `#!/usr/bin/env node
|
|
53
|
+
const parent = process.ppid;
|
|
54
|
+
setInterval(() => {
|
|
55
|
+
try { if (process.ppid !== parent) process.exit(0); process.kill(parent, 0); } catch { process.exit(0); }
|
|
56
|
+
}, 1000).unref();
|
|
57
|
+
setTimeout(() => process.exit(0), 60000).unref();
|
|
58
|
+
const { createServer } = require('node:http');
|
|
59
|
+
const { mkdirSync, writeFileSync } = require('node:fs');
|
|
60
|
+
const { join } = require('node:path');
|
|
61
|
+
const arg = (k) => process.argv.find((a) => a.startsWith(k + '=')).slice(k.length + 1);
|
|
62
|
+
const dir = arg('--user-data-dir');
|
|
63
|
+
const wanted = Number(arg('--remote-debugging-port'));
|
|
64
|
+
mkdirSync(join(dir, 'Default'), { recursive: true });
|
|
65
|
+
writeFileSync(join(dir, 'Default', 'Preferences'), '{}');
|
|
66
|
+
const server = createServer((req, res) => {
|
|
67
|
+
const port = server.address().port;
|
|
68
|
+
const ws = 'ws://127.0.0.1:' + port + '/devtools/browser/fake-' + process.pid;
|
|
69
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
70
|
+
res.end(req.url === '/json/version'
|
|
71
|
+
? JSON.stringify({ Browser: 'Chrome/999.0.0.0', webSocketDebuggerUrl: ws })
|
|
72
|
+
: '[]');
|
|
73
|
+
});
|
|
74
|
+
server.listen(wanted, '127.0.0.1', () => {
|
|
75
|
+
const port = server.address().port;
|
|
76
|
+
if (wanted === 0) writeFileSync(join(dir, 'DevToolsActivePort'), port + '\\n/devtools/browser/fake');
|
|
77
|
+
process.stderr.write('DevTools listening on ws://127.0.0.1:' + port + '/devtools/browser/fake-' + process.pid + '\\n');
|
|
78
|
+
});
|
|
79
|
+
`;
|
|
80
|
+
|
|
81
|
+
let root: string;
|
|
82
|
+
let fixtures: string;
|
|
83
|
+
let cwd: string;
|
|
84
|
+
let exe: string;
|
|
85
|
+
const children: ChildProcess[] = [];
|
|
86
|
+
const launchedPids: number[] = [];
|
|
87
|
+
|
|
88
|
+
beforeEach(async () => {
|
|
89
|
+
root = await mkdtemp(join(tmpdir(), 'monobrowse-395-root-'));
|
|
90
|
+
fixtures = await mkdtemp(join(tmpdir(), 'monobrowse-395-fixture-'));
|
|
91
|
+
cwd = await mkdtemp(join(tmpdir(), 'monobrowse-395-cwd-'));
|
|
92
|
+
vi.stubEnv('TMPDIR', root);
|
|
93
|
+
vi.spyOn(process, 'cwd').mockReturnValue(cwd);
|
|
94
|
+
exe = join(fixtures, 'fake-chrome.cjs');
|
|
95
|
+
writeFileSync(exe, FAKE_CHROME);
|
|
96
|
+
chmodSync(exe, 0o755);
|
|
97
|
+
onBrowserClose = null;
|
|
98
|
+
lastSocket = null;
|
|
99
|
+
vi.resetModules();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
afterEach(async () => {
|
|
103
|
+
vi.unstubAllEnvs();
|
|
104
|
+
vi.restoreAllMocks();
|
|
105
|
+
for (const pid of launchedPids.splice(0)) {
|
|
106
|
+
try {
|
|
107
|
+
process.kill(pid, 'SIGKILL');
|
|
108
|
+
} catch {
|
|
109
|
+
/* already gone */
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
for (const child of children.splice(0)) child.kill('SIGKILL');
|
|
113
|
+
for (const dir of [root, fixtures, cwd]) await rm(dir, { recursive: true, force: true });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
function freePort(): Promise<number> {
|
|
117
|
+
return new Promise((resolve, reject) => {
|
|
118
|
+
const server = createServer();
|
|
119
|
+
server.once('error', reject);
|
|
120
|
+
server.listen(0, '127.0.0.1', () => {
|
|
121
|
+
const { port } = server.address() as { port: number };
|
|
122
|
+
server.close(() => resolve(port));
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A client whose Browser.close is acknowledged, and makes `pid` exit the
|
|
128
|
+
* way Chrome does on it. */
|
|
129
|
+
async function gracefulClient(pid: number) {
|
|
130
|
+
const { CdpClient } = await import('../browser/cdp.js');
|
|
131
|
+
const client = new CdpClient();
|
|
132
|
+
const p = client.connect('ws://127.0.0.1:1/devtools/browser/fake');
|
|
133
|
+
lastSocket!.emit('open');
|
|
134
|
+
await p;
|
|
135
|
+
onBrowserClose = () => process.kill(pid, 'SIGTERM');
|
|
136
|
+
return client;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A client that never connected: Browser.close fails at once, so
|
|
140
|
+
* closeBrowser takes its kill fallback. */
|
|
141
|
+
async function deadClient() {
|
|
142
|
+
const { CdpClient } = await import('../browser/cdp.js');
|
|
143
|
+
return new CdpClient();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function launch(config: Record<string, unknown>) {
|
|
147
|
+
const browser = await import('../browser/browser.js');
|
|
148
|
+
const port = await browser.launchBrowser({ executablePath: exe, ...config });
|
|
149
|
+
const pid = browser.getLaunchedPid(port)!;
|
|
150
|
+
launchedPids.push(pid);
|
|
151
|
+
return { browser, port, pid, dir: browser.getLaunchedUserDataDir(port)! };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
describe.skipIf(process.platform === 'win32')('#395 profile dir cleanup on close', () => {
|
|
155
|
+
it('launch then graceful close removes the default profile dir it created', async () => {
|
|
156
|
+
const { browser, port, pid, dir } = await launch({ port: await freePort() });
|
|
157
|
+
expect(dir.startsWith(root)).toBe(true);
|
|
158
|
+
expect(existsSync(join(dir, 'Default', 'Preferences'))).toBe(true);
|
|
159
|
+
expect(browser.ownsLaunchedUserDataDir(port)).toBe(true);
|
|
160
|
+
|
|
161
|
+
await browser.closeBrowser(await gracefulClient(pid), port);
|
|
162
|
+
|
|
163
|
+
expect(existsSync(dir)).toBe(false);
|
|
164
|
+
}, 20_000);
|
|
165
|
+
|
|
166
|
+
it('removes a CLI session profile (port 0, ownsUserDataDir) on close', async () => {
|
|
167
|
+
const browser = await import('../browser/browser.js');
|
|
168
|
+
const { sessionProfileDirPath } = await import('../browser/profile-dir.js');
|
|
169
|
+
const own = sessionProfileDirPath('abcdef12');
|
|
170
|
+
expect(own.startsWith(root)).toBe(true);
|
|
171
|
+
const { port, pid, dir } = await launch({ port: 0, userDataDir: own, ownsUserDataDir: true });
|
|
172
|
+
expect(dir).toBe(own);
|
|
173
|
+
|
|
174
|
+
await browser.closeBrowser(await gracefulClient(pid), port);
|
|
175
|
+
|
|
176
|
+
expect(existsSync(own)).toBe(false);
|
|
177
|
+
}, 20_000);
|
|
178
|
+
|
|
179
|
+
it('never deletes a caller-supplied userDataDir, even one named like ours', async () => {
|
|
180
|
+
const mine = join(root, 'my-profile');
|
|
181
|
+
const lookalike = join(root, `monomind-browse-${process.pid}-0badc0de`);
|
|
182
|
+
for (const userDataDir of [mine, lookalike]) {
|
|
183
|
+
mkdirSync(userDataDir);
|
|
184
|
+
const { browser, port, pid } = await launch({ port: 0, userDataDir });
|
|
185
|
+
expect(browser.ownsLaunchedUserDataDir(port)).toBe(false);
|
|
186
|
+
await browser.closeBrowser(await gracefulClient(pid), port);
|
|
187
|
+
expect(existsSync(join(userDataDir, 'Default', 'Preferences')), userDataDir).toBe(true);
|
|
188
|
+
}
|
|
189
|
+
}, 30_000);
|
|
190
|
+
|
|
191
|
+
it('the kill fallback removes the created dir once the process is gone', async () => {
|
|
192
|
+
const { browser, port, pid, dir } = await launch({ port: await freePort() });
|
|
193
|
+
expect(existsSync(dir)).toBe(true);
|
|
194
|
+
|
|
195
|
+
await browser.closeBrowser(await deadClient(), port);
|
|
196
|
+
|
|
197
|
+
expect(() => process.kill(pid, 0)).toThrow();
|
|
198
|
+
expect(existsSync(dir)).toBe(false);
|
|
199
|
+
}, 20_000);
|
|
200
|
+
|
|
201
|
+
it('a failed launch removes the profile dir it created', async () => {
|
|
202
|
+
const dying = join(fixtures, 'dying-chrome.cjs');
|
|
203
|
+
writeFileSync(
|
|
204
|
+
dying,
|
|
205
|
+
`#!/usr/bin/env node
|
|
206
|
+
const dir = process.argv.find((a) => a.startsWith('--user-data-dir=')).slice(16);
|
|
207
|
+
require('node:fs').mkdirSync(dir, { recursive: true });
|
|
208
|
+
process.exit(1);
|
|
209
|
+
`,
|
|
210
|
+
);
|
|
211
|
+
chmodSync(dying, 0o755);
|
|
212
|
+
const browser = await import('../browser/browser.js');
|
|
213
|
+
const port = await freePort();
|
|
214
|
+
await expect(
|
|
215
|
+
browser.launchBrowser({ port, strictPort: true, executablePath: dying }),
|
|
216
|
+
).rejects.toThrow(/exited before the CDP endpoint opened/);
|
|
217
|
+
|
|
218
|
+
expect(readdirSync(root)).toEqual([]);
|
|
219
|
+
}, 20_000);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe.skipIf(process.platform === 'win32')(
|
|
223
|
+
'#395 fresh-process close via the persisted session',
|
|
224
|
+
() => {
|
|
225
|
+
/** A live stand-in for a Chrome some earlier CLI process launched, its
|
|
226
|
+
* profile dir, and the session record that process left behind. */
|
|
227
|
+
async function persistedSession(ownsUserDataDir: boolean | undefined) {
|
|
228
|
+
const dir = join(
|
|
229
|
+
root,
|
|
230
|
+
`monomind-browse-${process.pid}-5e55107${ownsUserDataDir ? 'a' : 'b'}`,
|
|
231
|
+
);
|
|
232
|
+
await mkdir(join(dir, 'Default'), { recursive: true });
|
|
233
|
+
await writeFile(join(dir, 'Default', 'Preferences'), '{}');
|
|
234
|
+
const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
|
|
235
|
+
stdio: 'ignore',
|
|
236
|
+
});
|
|
237
|
+
children.push(child);
|
|
238
|
+
await new Promise((resolve) => child.once('spawn', resolve));
|
|
239
|
+
const port = await freePort();
|
|
240
|
+
const sessions = join(cwd, '.monomind', 'monobrowse', 'sessions');
|
|
241
|
+
await mkdir(sessions, { recursive: true });
|
|
242
|
+
await writeFile(
|
|
243
|
+
join(sessions, `${port}.json`),
|
|
244
|
+
JSON.stringify({
|
|
245
|
+
port,
|
|
246
|
+
launched: true,
|
|
247
|
+
pid: child.pid,
|
|
248
|
+
userDataDir: dir,
|
|
249
|
+
ownsUserDataDir,
|
|
250
|
+
savedAt: Date.now(),
|
|
251
|
+
}),
|
|
252
|
+
);
|
|
253
|
+
return { dir, pid: child.pid!, port };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
it('removes the dir when the record says monobrowse owns it', async () => {
|
|
257
|
+
const { dir, port } = await persistedSession(true);
|
|
258
|
+
const browser = await import('../browser/browser.js');
|
|
259
|
+
const { loadSessionRecord } = await import('../browser/ref-cache.js');
|
|
260
|
+
expect((await loadSessionRecord(port))?.ownsUserDataDir).toBe(true);
|
|
261
|
+
|
|
262
|
+
await browser.closeBrowser(await deadClient(), port);
|
|
263
|
+
|
|
264
|
+
expect(existsSync(dir)).toBe(false);
|
|
265
|
+
}, 20_000);
|
|
266
|
+
|
|
267
|
+
it('keeps the dir (still killing the browser) when the record has no ownership flag', async () => {
|
|
268
|
+
const { dir, pid, port } = await persistedSession(undefined);
|
|
269
|
+
const browser = await import('../browser/browser.js');
|
|
270
|
+
|
|
271
|
+
await browser.closeBrowser(await deadClient(), port);
|
|
272
|
+
|
|
273
|
+
for (let i = 0; i < 40; i++) {
|
|
274
|
+
try {
|
|
275
|
+
process.kill(pid, 0);
|
|
276
|
+
} catch {
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
280
|
+
}
|
|
281
|
+
expect(() => process.kill(pid, 0)).toThrow();
|
|
282
|
+
expect(existsSync(join(dir, 'Default', 'Preferences'))).toBe(true);
|
|
283
|
+
}, 20_000);
|
|
284
|
+
},
|
|
285
|
+
);
|
|
@@ -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
|
+
}
|
|
@@ -3,7 +3,6 @@
|
|
|
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,
|
|
@@ -16,7 +15,12 @@ import {
|
|
|
16
15
|
readDevToolsActivePort,
|
|
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;
|
|
@@ -52,6 +56,10 @@ export async function launchBrowser(config: BrowserConfig = {}): Promise<number>
|
|
|
52
56
|
// is no daemon to do it on a timer. Bounded and non-throwing; a stale
|
|
53
57
|
// instance on the port we are about to use is freed before we probe it.
|
|
54
58
|
await reapIdleLaunchedBrowser();
|
|
59
|
+
// Same reasoning for temp profile dirs (#395): a process that crashed or
|
|
60
|
+
// was killed never deleted the one its browser used. Only dead, idle,
|
|
61
|
+
// unused dirs of our own naming go — see sweepStaleProfileDirs.
|
|
62
|
+
await sweepStaleProfileDirs();
|
|
55
63
|
|
|
56
64
|
if (rawPort === 0) return launchOnFreePort(config, 0);
|
|
57
65
|
|
|
@@ -157,9 +165,10 @@ async function launchOnFreePort(config: BrowserConfig, port: number): Promise<nu
|
|
|
157
165
|
// otherwise share one profile directory (lock files, preferences, the
|
|
158
166
|
// DevToolsActivePort file itself) — including two launches from the same
|
|
159
167
|
// process, where a pid suffix alone is identical.
|
|
160
|
-
const userDataDir =
|
|
161
|
-
|
|
162
|
-
|
|
168
|
+
const userDataDir = config.userDataDir ?? launchProfileDirPath(port, randomUUID().slice(0, 8));
|
|
169
|
+
// A dir we created is deleted once its browser is gone (#395); a
|
|
170
|
+
// caller-supplied one only when the caller says it made it for us.
|
|
171
|
+
const ownsUserDataDir = config.userDataDir === undefined || config.ownsUserDataDir === true;
|
|
163
172
|
const activePortFile = join(userDataDir, 'DevToolsActivePort');
|
|
164
173
|
// A reused profile dir may hold a previous run's file naming a port some
|
|
165
174
|
// other process now owns.
|
|
@@ -257,6 +266,8 @@ async function launchOnFreePort(config: BrowserConfig, port: number): Promise<nu
|
|
|
257
266
|
if (!child.pid) return;
|
|
258
267
|
launchedPids.set(boundPort, child.pid);
|
|
259
268
|
launchedUserDataDirs.set(boundPort, userDataDir);
|
|
269
|
+
if (ownsUserDataDir) ownedUserDataDirPorts.add(boundPort);
|
|
270
|
+
else ownedUserDataDirPorts.delete(boundPort);
|
|
260
271
|
};
|
|
261
272
|
// Tracked only once Chrome is CONFIRMED up on `boundPort`, not right after
|
|
262
273
|
// spawn(). For port !== 0 this used to track(port) unconditionally the
|
|
@@ -291,6 +302,7 @@ async function launchOnFreePort(config: BrowserConfig, port: number): Promise<nu
|
|
|
291
302
|
|
|
292
303
|
const launchTimeout = config.launchTimeoutMs ?? LAUNCH_TIMEOUT;
|
|
293
304
|
const deadline = Date.now() + launchTimeout;
|
|
305
|
+
let launched = false;
|
|
294
306
|
try {
|
|
295
307
|
while (Date.now() < deadline) {
|
|
296
308
|
if (earlyFailure) throw earlyFailure;
|
|
@@ -302,6 +314,7 @@ async function launchOnFreePort(config: BrowserConfig, port: number): Promise<nu
|
|
|
302
314
|
if (identity === 'chrome') {
|
|
303
315
|
if (await isOwnEndpoint(boundPort)) {
|
|
304
316
|
track(boundPort);
|
|
317
|
+
launched = true;
|
|
305
318
|
return boundPort;
|
|
306
319
|
}
|
|
307
320
|
// Some Chrome answers on the port, but not the one we started: a
|
|
@@ -350,6 +363,10 @@ async function launchOnFreePort(config: BrowserConfig, port: number): Promise<nu
|
|
|
350
363
|
throw new Error(`Chrome failed to start on port ${port} within ${launchTimeout}ms`);
|
|
351
364
|
} finally {
|
|
352
365
|
child.stderr?.destroy();
|
|
366
|
+
// A failed launch (lost port race, refusal, timeout) is never tracked,
|
|
367
|
+
// so no close will ever clean its profile — and the port-scan retry in
|
|
368
|
+
// launchBrowser can make several of these per call.
|
|
369
|
+
if (!launched && ownsUserDataDir) await removeOwnedProfileDir(userDataDir, { pid: child.pid });
|
|
353
370
|
}
|
|
354
371
|
}
|
|
355
372
|
|