@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.
Files changed (65) hide show
  1. package/dist/src/__tests__/disconnect-session.test.d.ts +13 -0
  2. package/dist/src/__tests__/disconnect-session.test.d.ts.map +1 -0
  3. package/dist/src/__tests__/disconnect-session.test.js +37 -0
  4. package/dist/src/__tests__/disconnect-session.test.js.map +1 -0
  5. package/dist/src/__tests__/profile-cleanup-close.test.d.ts +16 -0
  6. package/dist/src/__tests__/profile-cleanup-close.test.d.ts.map +1 -0
  7. package/dist/src/__tests__/profile-cleanup-close.test.js +250 -0
  8. package/dist/src/__tests__/profile-cleanup-close.test.js.map +1 -0
  9. package/dist/src/__tests__/profile-dir.test.d.ts +10 -0
  10. package/dist/src/__tests__/profile-dir.test.d.ts.map +1 -0
  11. package/dist/src/__tests__/profile-dir.test.js +160 -0
  12. package/dist/src/__tests__/profile-dir.test.js.map +1 -0
  13. package/dist/src/__tests__/setup/isolated-tmpdir.global.d.ts +2 -0
  14. package/dist/src/__tests__/setup/isolated-tmpdir.global.d.ts.map +1 -0
  15. package/dist/src/__tests__/setup/isolated-tmpdir.global.js +30 -0
  16. package/dist/src/__tests__/setup/isolated-tmpdir.global.js.map +1 -0
  17. package/dist/src/browser/browser-launch.d.ts.map +1 -1
  18. package/dist/src/browser/browser-launch.js +21 -4
  19. package/dist/src/browser/browser-launch.js.map +1 -1
  20. package/dist/src/browser/browser-lifecycle.d.ts +5 -0
  21. package/dist/src/browser/browser-lifecycle.d.ts.map +1 -1
  22. package/dist/src/browser/browser-lifecycle.js +23 -11
  23. package/dist/src/browser/browser-lifecycle.js.map +1 -1
  24. package/dist/src/browser/browser-state.d.ts +4 -0
  25. package/dist/src/browser/browser-state.d.ts.map +1 -1
  26. package/dist/src/browser/browser-state.js +10 -2
  27. package/dist/src/browser/browser-state.js.map +1 -1
  28. package/dist/src/browser/browser.d.ts +1 -1
  29. package/dist/src/browser/browser.d.ts.map +1 -1
  30. package/dist/src/browser/browser.js +1 -1
  31. package/dist/src/browser/browser.js.map +1 -1
  32. package/dist/src/browser/profile-dir.d.ts +88 -0
  33. package/dist/src/browser/profile-dir.d.ts.map +1 -0
  34. package/dist/src/browser/profile-dir.js +242 -0
  35. package/dist/src/browser/profile-dir.js.map +1 -0
  36. package/dist/src/browser/ref-cache.d.ts +4 -0
  37. package/dist/src/browser/ref-cache.d.ts.map +1 -1
  38. package/dist/src/browser/ref-cache.js +2 -0
  39. package/dist/src/browser/ref-cache.js.map +1 -1
  40. package/dist/src/browser/types.d.ts +6 -0
  41. package/dist/src/browser/types.d.ts.map +1 -1
  42. package/dist/src/browser/types.js.map +1 -1
  43. package/dist/src/cli/commands.d.ts +1 -0
  44. package/dist/src/cli/commands.d.ts.map +1 -1
  45. package/dist/src/cli/commands.js +2 -0
  46. package/dist/src/cli/commands.js.map +1 -1
  47. package/dist/src/cli/session.d.ts +9 -0
  48. package/dist/src/cli/session.d.ts.map +1 -1
  49. package/dist/src/cli/session.js +21 -4
  50. package/dist/src/cli/session.js.map +1 -1
  51. package/dist/tsconfig.tsbuildinfo +1 -1
  52. package/package.json +1 -1
  53. package/src/__tests__/disconnect-session.test.ts +42 -0
  54. package/src/__tests__/profile-cleanup-close.test.ts +285 -0
  55. package/src/__tests__/profile-dir.test.ts +196 -0
  56. package/src/__tests__/setup/isolated-tmpdir.global.ts +28 -0
  57. package/src/browser/browser-launch.ts +22 -5
  58. package/src/browser/browser-lifecycle.ts +20 -10
  59. package/src/browser/browser-state.ts +11 -2
  60. package/src/browser/browser.ts +5 -1
  61. package/src/browser/profile-dir.ts +249 -0
  62. package/src/browser/ref-cache.ts +7 -0
  63. package/src/browser/types.ts +6 -0
  64. package/src/cli/commands.ts +2 -0
  65. package/src/cli/session.ts +22 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monoes/monobrowse",
3
- "version": "1.0.27",
3
+ "version": "1.0.28",
4
4
  "description": "Native browser automation via Chrome DevTools Protocol — the engine powering monomind browse",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,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
- config.userDataDir ??
162
- join(tmpdir(), `monomind-browser-${port}-${process.pid}-${randomUUID().slice(0, 8)}`);
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