@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.
Files changed (80) hide show
  1. package/dist/src/__tests__/browser-launch-double-loss.test.d.ts +16 -0
  2. package/dist/src/__tests__/browser-launch-double-loss.test.d.ts.map +1 -0
  3. package/dist/src/__tests__/browser-launch-double-loss.test.js +119 -0
  4. package/dist/src/__tests__/browser-launch-double-loss.test.js.map +1 -0
  5. package/dist/src/__tests__/chrome-fallback.test.d.ts +2 -0
  6. package/dist/src/__tests__/chrome-fallback.test.d.ts.map +1 -0
  7. package/dist/src/__tests__/chrome-fallback.test.js +63 -0
  8. package/dist/src/__tests__/chrome-fallback.test.js.map +1 -0
  9. package/dist/src/__tests__/disconnect-session.test.d.ts +13 -0
  10. package/dist/src/__tests__/disconnect-session.test.d.ts.map +1 -0
  11. package/dist/src/__tests__/disconnect-session.test.js +37 -0
  12. package/dist/src/__tests__/disconnect-session.test.js.map +1 -0
  13. package/dist/src/__tests__/profile-cleanup-close.test.d.ts +16 -0
  14. package/dist/src/__tests__/profile-cleanup-close.test.d.ts.map +1 -0
  15. package/dist/src/__tests__/profile-cleanup-close.test.js +250 -0
  16. package/dist/src/__tests__/profile-cleanup-close.test.js.map +1 -0
  17. package/dist/src/__tests__/profile-dir.test.d.ts +10 -0
  18. package/dist/src/__tests__/profile-dir.test.d.ts.map +1 -0
  19. package/dist/src/__tests__/profile-dir.test.js +160 -0
  20. package/dist/src/__tests__/profile-dir.test.js.map +1 -0
  21. package/dist/src/__tests__/setup/isolated-tmpdir.global.d.ts +2 -0
  22. package/dist/src/__tests__/setup/isolated-tmpdir.global.d.ts.map +1 -0
  23. package/dist/src/__tests__/setup/isolated-tmpdir.global.js +30 -0
  24. package/dist/src/__tests__/setup/isolated-tmpdir.global.js.map +1 -0
  25. package/dist/src/browser/browser-discovery.d.ts +8 -0
  26. package/dist/src/browser/browser-discovery.d.ts.map +1 -1
  27. package/dist/src/browser/browser-discovery.js +25 -0
  28. package/dist/src/browser/browser-discovery.js.map +1 -1
  29. package/dist/src/browser/browser-launch.d.ts.map +1 -1
  30. package/dist/src/browser/browser-launch.js +48 -10
  31. package/dist/src/browser/browser-launch.js.map +1 -1
  32. package/dist/src/browser/browser-lifecycle.d.ts +5 -0
  33. package/dist/src/browser/browser-lifecycle.d.ts.map +1 -1
  34. package/dist/src/browser/browser-lifecycle.js +23 -11
  35. package/dist/src/browser/browser-lifecycle.js.map +1 -1
  36. package/dist/src/browser/browser-state.d.ts +4 -0
  37. package/dist/src/browser/browser-state.d.ts.map +1 -1
  38. package/dist/src/browser/browser-state.js +10 -2
  39. package/dist/src/browser/browser-state.js.map +1 -1
  40. package/dist/src/browser/browser.d.ts +2 -2
  41. package/dist/src/browser/browser.d.ts.map +1 -1
  42. package/dist/src/browser/browser.js +2 -2
  43. package/dist/src/browser/browser.js.map +1 -1
  44. package/dist/src/browser/profile-dir.d.ts +88 -0
  45. package/dist/src/browser/profile-dir.d.ts.map +1 -0
  46. package/dist/src/browser/profile-dir.js +242 -0
  47. package/dist/src/browser/profile-dir.js.map +1 -0
  48. package/dist/src/browser/ref-cache.d.ts +4 -0
  49. package/dist/src/browser/ref-cache.d.ts.map +1 -1
  50. package/dist/src/browser/ref-cache.js +2 -0
  51. package/dist/src/browser/ref-cache.js.map +1 -1
  52. package/dist/src/browser/types.d.ts +6 -0
  53. package/dist/src/browser/types.d.ts.map +1 -1
  54. package/dist/src/browser/types.js.map +1 -1
  55. package/dist/src/cli/commands.d.ts +1 -0
  56. package/dist/src/cli/commands.d.ts.map +1 -1
  57. package/dist/src/cli/commands.js +2 -0
  58. package/dist/src/cli/commands.js.map +1 -1
  59. package/dist/src/cli/session.d.ts +9 -0
  60. package/dist/src/cli/session.d.ts.map +1 -1
  61. package/dist/src/cli/session.js +21 -4
  62. package/dist/src/cli/session.js.map +1 -1
  63. package/dist/tsconfig.tsbuildinfo +1 -1
  64. package/package.json +1 -1
  65. package/src/__tests__/browser-launch-double-loss.test.ts +128 -0
  66. package/src/__tests__/chrome-fallback.test.ts +74 -0
  67. package/src/__tests__/disconnect-session.test.ts +42 -0
  68. package/src/__tests__/profile-cleanup-close.test.ts +285 -0
  69. package/src/__tests__/profile-dir.test.ts +196 -0
  70. package/src/__tests__/setup/isolated-tmpdir.global.ts +28 -0
  71. package/src/browser/browser-discovery.ts +25 -0
  72. package/src/browser/browser-launch.ts +49 -11
  73. package/src/browser/browser-lifecycle.ts +20 -10
  74. package/src/browser/browser-state.ts +11 -2
  75. package/src/browser/browser.ts +6 -2
  76. package/src/browser/profile-dir.ts +249 -0
  77. package/src/browser/ref-cache.ts +7 -0
  78. package/src/browser/types.ts +6 -0
  79. package/src/cli/commands.ts +2 -0
  80. 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 (a real launch failure — bad
114
- // executable, sandbox refusal, etc.) is not a race: retrying the
115
- // next candidate would only fail the same way, so surface it as-is.
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 = findChrome(config.executablePath);
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
- config.userDataDir ??
162
- join(tmpdir(), `monomind-browser-${port}-${process.pid}-${randomUUID().slice(0, 8)}`);
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 Error(
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
- return;
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
- // Browser.close was acknowledged — wait for the process to actually go away,
116
- // then force-kill as a safety net in case it hung.
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 — informational, persisted alongside the pid
10
- // so a later process could clean up the temp profile dir too if ever needed.
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
+ }
@@ -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 { getLaunchedPid, getLaunchedUserDataDir } from './browser-state.js';
17
+ export {
18
+ getLaunchedPid,
19
+ getLaunchedUserDataDir,
20
+ ownsLaunchedUserDataDir,
21
+ } from './browser-state.js';