@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
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Split out of browser.ts (file-size sweep). Pure move: no behaviour change.
|
|
2
2
|
|
|
3
3
|
import { fetchBrowserWebSocketUrl } from './browser-discovery.js';
|
|
4
|
-
import { launchedPids, launchedUserDataDirs } from './browser-state.js';
|
|
4
|
+
import { launchedPids, launchedUserDataDirs, ownedUserDataDirPorts } from './browser-state.js';
|
|
5
5
|
import { CdpClient, fetchTargets } from './cdp.js';
|
|
6
|
+
import { removeOwnedProfileDir } from './profile-dir.js';
|
|
6
7
|
import {
|
|
7
8
|
listSessionRecords,
|
|
8
9
|
loadSessionRecord,
|
|
@@ -41,6 +42,11 @@ const REAP_CONNECT_TIMEOUT_MS = 3000;
|
|
|
41
42
|
* singleton lock, and if the caller's process exited first the kill never
|
|
42
43
|
* happened at all. Callers reasonably read a resolved close() as "that
|
|
43
44
|
* browser is gone"; on the graceful path it now means that.
|
|
45
|
+
*
|
|
46
|
+
* A profile directory monobrowse created for that browser (the default temp
|
|
47
|
+
* profile, or a CLI session's — never a caller-supplied one) is deleted once
|
|
48
|
+
* the process is gone, on both paths (#395). In a fresh process the dir and
|
|
49
|
+
* its ownership come from the persisted session record.
|
|
44
50
|
*/
|
|
45
51
|
export async function closeBrowser(client: CdpClient, port: number): Promise<void> {
|
|
46
52
|
let gracefullyClosed = false;
|
|
@@ -71,6 +77,7 @@ export async function closeBrowser(client: CdpClient, port: number): Promise<voi
|
|
|
71
77
|
}
|
|
72
78
|
|
|
73
79
|
let pid = launchedPids.get(port);
|
|
80
|
+
let profileDir = ownedUserDataDirPorts.has(port) ? launchedUserDataDirs.get(port) : undefined;
|
|
74
81
|
if (pid === undefined) {
|
|
75
82
|
// Fresh process (each CLI invocation is its own node process — see
|
|
76
83
|
// module header) — launchedPids is per-process and empty here even
|
|
@@ -89,6 +96,7 @@ export async function closeBrowser(client: CdpClient, port: number): Promise<voi
|
|
|
89
96
|
const age = persisted.savedAt !== undefined ? Date.now() - persisted.savedAt : undefined;
|
|
90
97
|
if (age === undefined || age <= PERSISTED_PID_MAX_AGE_MS) {
|
|
91
98
|
pid = persisted.pid;
|
|
99
|
+
if (persisted.ownsUserDataDir) profileDir = persisted.userDataDir;
|
|
92
100
|
}
|
|
93
101
|
}
|
|
94
102
|
} catch {
|
|
@@ -97,6 +105,7 @@ export async function closeBrowser(client: CdpClient, port: number): Promise<voi
|
|
|
97
105
|
}
|
|
98
106
|
if (pid === undefined) return; // not a process we launched, or too stale to trust — nothing to kill
|
|
99
107
|
launchedPids.delete(port);
|
|
108
|
+
ownedUserDataDirPorts.delete(port);
|
|
100
109
|
|
|
101
110
|
// Liveness-check before every SIGKILL (not just the safety-net path below)
|
|
102
111
|
// — process.kill(pid, 0) throws ESRCH if the PID is no longer running,
|
|
@@ -109,17 +118,18 @@ export async function closeBrowser(client: CdpClient, port: number): Promise<voi
|
|
|
109
118
|
} catch {
|
|
110
119
|
/* already exited, or never was — nothing to do */
|
|
111
120
|
}
|
|
112
|
-
|
|
121
|
+
} else if (!(await waitForProcessExit(pid))) {
|
|
122
|
+
// Browser.close was acknowledged — wait for the process to actually go
|
|
123
|
+
// away, then force-kill as a safety net in case it hung.
|
|
124
|
+
try {
|
|
125
|
+
process.kill(pid, 'SIGKILL');
|
|
126
|
+
} catch {
|
|
127
|
+
/* raced us to exit — expected */
|
|
128
|
+
}
|
|
113
129
|
}
|
|
114
130
|
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
if (await waitForProcessExit(pid)) return;
|
|
118
|
-
try {
|
|
119
|
-
process.kill(pid, 'SIGKILL');
|
|
120
|
-
} catch {
|
|
121
|
-
/* raced us to exit — expected */
|
|
122
|
-
}
|
|
131
|
+
// Waits (bounded) for the killed process to be gone before deleting.
|
|
132
|
+
if (profileDir) await removeOwnedProfileDir(profileDir, { pid });
|
|
123
133
|
}
|
|
124
134
|
|
|
125
135
|
/** Poll until `pid` is gone or PROCESS_EXIT_TIMEOUT_MS elapses. Returns
|
|
@@ -6,9 +6,12 @@
|
|
|
6
6
|
// to (already-running browser) are never recorded here — we only ever kill
|
|
7
7
|
// processes we launched ourselves.
|
|
8
8
|
export const launchedPids = new Map<number, number>();
|
|
9
|
-
// userDataDir per launched port
|
|
10
|
-
//
|
|
9
|
+
// userDataDir per launched port, persisted alongside the pid so a later
|
|
10
|
+
// process can clean up the temp profile dir too (#395).
|
|
11
11
|
export const launchedUserDataDirs = new Map<number, string>();
|
|
12
|
+
// Launched ports whose userDataDir monobrowse created itself (not one the
|
|
13
|
+
// caller supplied). Only these profile dirs are ever deleted on close.
|
|
14
|
+
export const ownedUserDataDirPorts = new Set<number>();
|
|
12
15
|
|
|
13
16
|
/** PID of the Chrome instance *this process* launched on `port`, if any. */
|
|
14
17
|
export function getLaunchedPid(port: number): number | undefined {
|
|
@@ -19,3 +22,9 @@ export function getLaunchedPid(port: number): number | undefined {
|
|
|
19
22
|
export function getLaunchedUserDataDir(port: number): string | undefined {
|
|
20
23
|
return launchedUserDataDirs.get(port);
|
|
21
24
|
}
|
|
25
|
+
|
|
26
|
+
/** Whether the userDataDir of the Chrome *this process* launched on `port`
|
|
27
|
+
* was created by monobrowse, and so is deleted when that browser closes. */
|
|
28
|
+
export function ownsLaunchedUserDataDir(port: number): boolean {
|
|
29
|
+
return ownedUserDataDirPorts.has(port);
|
|
30
|
+
}
|
package/src/browser/browser.ts
CHANGED
|
@@ -14,4 +14,8 @@ export {
|
|
|
14
14
|
openUrl,
|
|
15
15
|
waitForLoad,
|
|
16
16
|
} from './browser-page.js';
|
|
17
|
-
export {
|
|
17
|
+
export {
|
|
18
|
+
getLaunchedPid,
|
|
19
|
+
getLaunchedUserDataDir,
|
|
20
|
+
ownsLaunchedUserDataDir,
|
|
21
|
+
} from './browser-state.js';
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Temporary Chrome profile directories monobrowse creates, and their removal
|
|
3
|
+
* (#395).
|
|
4
|
+
*
|
|
5
|
+
* Every launch without a caller-supplied `userDataDir` (and every CLI
|
|
6
|
+
* session) gets a fresh `--user-data-dir` directly under `os.tmpdir()`.
|
|
7
|
+
* Nothing used to delete them, so they piled up until /tmp ran out of space
|
|
8
|
+
* (81 profiles, 4.4 GB on one machine). Two paths remove them now:
|
|
9
|
+
*
|
|
10
|
+
* - removeOwnedProfileDir(): when monobrowse closes or kills a browser it
|
|
11
|
+
* launched in a directory it created — after Chrome has exited, since
|
|
12
|
+
* Chrome writes into its profile until then.
|
|
13
|
+
* - sweepStaleProfileDirs(): at the next launch, for directories whose
|
|
14
|
+
* owner crashed or was killed before it could close its browser.
|
|
15
|
+
*
|
|
16
|
+
* Both touch only real directories directly inside the tmp root whose name
|
|
17
|
+
* matches one of the exact patterns below. A caller-supplied `userDataDir`
|
|
18
|
+
* never matches them unless the caller copied our naming, and even then the
|
|
19
|
+
* close path also needs the explicit ownership flag launchBrowser records.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { lstat, readdir, readFile, readlink, realpath, rm } from 'node:fs/promises';
|
|
23
|
+
import { tmpdir } from 'node:os';
|
|
24
|
+
import { basename, dirname, join } from 'node:path';
|
|
25
|
+
|
|
26
|
+
/** `monomind-browser-<port>-<pid>-<uuid8>` (launchBrowser's default profile). */
|
|
27
|
+
const LAUNCH_PROFILE_RE = /^monomind-browser-\d+-(\d+)-[0-9a-f]{8}$/;
|
|
28
|
+
/** `monomind-browse-<pid>-<uuid8>` (a CLI browse session's profile). */
|
|
29
|
+
const SESSION_PROFILE_RE = /^monomind-browse-(\d+)-[0-9a-f]{8}$/;
|
|
30
|
+
|
|
31
|
+
/** A directory younger than this is never swept: its launch may still be
|
|
32
|
+
* starting (Chrome has not yet put its pid anywhere we can see). */
|
|
33
|
+
export const PROFILE_SWEEP_GRACE_MS = 10 * 60 * 1000;
|
|
34
|
+
// Bounded waits for removeOwnedProfileDir(): for the browser pid to go, then
|
|
35
|
+
// for any other process still holding the profile (Chrome's helpers).
|
|
36
|
+
const EXIT_WAIT_MS = 5000;
|
|
37
|
+
const HOLDER_WAIT_MS = 2000;
|
|
38
|
+
const POLL_MS = 50;
|
|
39
|
+
const RM_RETRY_DELAY_MS = 250;
|
|
40
|
+
|
|
41
|
+
/** Name for a new default launch profile, directly under `root`. */
|
|
42
|
+
export function launchProfileDirPath(port: number, id: string, root = tmpdir()): string {
|
|
43
|
+
return join(root, `monomind-browser-${port}-${process.pid}-${id}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Name for a new CLI session profile, directly under `root`. */
|
|
47
|
+
export function sessionProfileDirPath(id: string, root = tmpdir()): string {
|
|
48
|
+
return join(root, `monomind-browse-${process.pid}-${id}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The pid encoded in a profile directory name we create, or null when
|
|
52
|
+
* `name` is not one of ours. */
|
|
53
|
+
export function profileDirOwnerPid(name: string): number | null {
|
|
54
|
+
const m = LAUNCH_PROFILE_RE.exec(name) ?? SESSION_PROFILE_RE.exec(name);
|
|
55
|
+
return m ? Number(m[1]) : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Whether `dir` is a directory monobrowse created: its name matches our
|
|
60
|
+
* pattern, it is a real directory (not a symlink), and its resolved parent
|
|
61
|
+
* is `root`. Anything else is never deleted.
|
|
62
|
+
*/
|
|
63
|
+
export async function isOwnedProfileDir(dir: string, root = tmpdir()): Promise<boolean> {
|
|
64
|
+
try {
|
|
65
|
+
if (profileDirOwnerPid(basename(dir)) === null) return false;
|
|
66
|
+
const st = await lstat(dir);
|
|
67
|
+
if (!st.isDirectory() || st.isSymbolicLink()) return false;
|
|
68
|
+
return dirname(await realpath(dir)) === (await realpath(root));
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Whether `pid` is a live process. EPERM means it exists but is not ours. */
|
|
75
|
+
export function isPidAlive(pid: number): boolean {
|
|
76
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
77
|
+
try {
|
|
78
|
+
process.kill(pid, 0);
|
|
79
|
+
return true;
|
|
80
|
+
} catch (err) {
|
|
81
|
+
return (err as NodeJS.ErrnoException).code === 'EPERM';
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Pids of live processes whose command line passes `--user-data-dir=<dir>`,
|
|
87
|
+
* read from `procDir` (Linux /proc). Null when it cannot be read at all, so
|
|
88
|
+
* the caller falls back to other evidence instead of reading "nobody".
|
|
89
|
+
*/
|
|
90
|
+
export async function profileDirHolders(dir: string, procDir = '/proc'): Promise<number[] | null> {
|
|
91
|
+
let entries: string[];
|
|
92
|
+
try {
|
|
93
|
+
entries = await readdir(procDir);
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
const wanted = new Set([`--user-data-dir=${dir}`]);
|
|
98
|
+
try {
|
|
99
|
+
wanted.add(`--user-data-dir=${await realpath(dir)}`);
|
|
100
|
+
} catch {
|
|
101
|
+
/* gone already — the literal path is all there is to match */
|
|
102
|
+
}
|
|
103
|
+
const holders: number[] = [];
|
|
104
|
+
for (const entry of entries) {
|
|
105
|
+
if (!/^\d+$/.test(entry)) continue;
|
|
106
|
+
try {
|
|
107
|
+
const args = (await readFile(join(procDir, entry, 'cmdline'), 'utf8')).split('\0');
|
|
108
|
+
if (args.some((a) => wanted.has(a))) holders.push(Number(entry));
|
|
109
|
+
} catch {
|
|
110
|
+
/* exited while we looked, or not readable */
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return holders;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The live pid Chrome's `SingletonLock` (a `<host>-<pid>` symlink) names,
|
|
117
|
+
* or null when there is no lock or its pid is gone. */
|
|
118
|
+
async function singletonLockHolder(dir: string): Promise<number | null> {
|
|
119
|
+
try {
|
|
120
|
+
const m = /-(\d+)$/.exec(await readlink(join(dir, 'SingletonLock')));
|
|
121
|
+
const pid = m ? Number(m[1]) : Number.NaN;
|
|
122
|
+
return isPidAlive(pid) ? pid : null;
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Whether any live process still uses `dir` as its Chrome profile. `procDir`
|
|
129
|
+
* null skips the /proc scan (non-Linux), leaving only the SingletonLock. */
|
|
130
|
+
export async function isProfileDirInUse(
|
|
131
|
+
dir: string,
|
|
132
|
+
opts: { procDir?: string | null } = {},
|
|
133
|
+
): Promise<boolean> {
|
|
134
|
+
const procDir = opts.procDir === undefined ? defaultProcDir() : opts.procDir;
|
|
135
|
+
if (procDir !== null) {
|
|
136
|
+
const holders = await profileDirHolders(dir, procDir);
|
|
137
|
+
if (holders?.some(isPidAlive)) return true;
|
|
138
|
+
}
|
|
139
|
+
return (await singletonLockHolder(dir)) !== null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function defaultProcDir(): string | null {
|
|
143
|
+
return process.platform === 'linux' ? '/proc' : null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Delete a profile directory monobrowse created, once the browser `pid` that
|
|
148
|
+
* used it has exited (bounded wait) and no other process still holds it.
|
|
149
|
+
* Removal is retried once — Chrome's helpers can still be flushing a file
|
|
150
|
+
* as the first attempt runs. Returns whether the directory is gone. Never
|
|
151
|
+
* throws: a directory left behind is swept at a later launch.
|
|
152
|
+
*/
|
|
153
|
+
export async function removeOwnedProfileDir(
|
|
154
|
+
dir: string,
|
|
155
|
+
opts: { pid?: number; root?: string; procDir?: string | null } = {},
|
|
156
|
+
): Promise<boolean> {
|
|
157
|
+
try {
|
|
158
|
+
if (!(await isOwnedProfileDir(dir, opts.root))) return false;
|
|
159
|
+
if (opts.pid !== undefined && !(await waitUntil(() => !isPidAlive(opts.pid!), EXIT_WAIT_MS))) {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
if (!(await waitUntil(async () => !(await isProfileDirInUse(dir, opts)), HOLDER_WAIT_MS))) {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
166
|
+
try {
|
|
167
|
+
await rm(dir, { recursive: true, force: true });
|
|
168
|
+
return true;
|
|
169
|
+
} catch {
|
|
170
|
+
await delay(RM_RETRY_DELAY_MS);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
} catch {
|
|
174
|
+
/* best-effort */
|
|
175
|
+
}
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export interface ProfileSweepOptions {
|
|
180
|
+
/** Directory to sweep. Default os.tmpdir(); tests inject their own. */
|
|
181
|
+
root?: string;
|
|
182
|
+
/** Minimum age (by mtime) before a directory may be swept. */
|
|
183
|
+
graceMs?: number;
|
|
184
|
+
/** See isProfileDirInUse. */
|
|
185
|
+
procDir?: string | null;
|
|
186
|
+
now?: number;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Delete profile directories left behind by a monobrowse process that died
|
|
191
|
+
* without closing its browser. A directory directly in `root` is removed
|
|
192
|
+
* only when ALL of these hold:
|
|
193
|
+
* - its name matches one of our exact patterns and it is a real directory
|
|
194
|
+
* (symlinks, files and anything else are never touched);
|
|
195
|
+
* - its mtime is older than the grace period, so a concurrent launch that
|
|
196
|
+
* is still starting is left alone;
|
|
197
|
+
* - the pid in its name is not a live process;
|
|
198
|
+
* - no live Chrome uses it (/proc command lines on Linux, plus the
|
|
199
|
+
* profile's SingletonLock everywhere). A CLI `open` process exits right
|
|
200
|
+
* after launching, so this check is what keeps a live session's profile.
|
|
201
|
+
*
|
|
202
|
+
* Best-effort: any error skips that entry, and the sweep never throws.
|
|
203
|
+
* Returns the directories removed.
|
|
204
|
+
*/
|
|
205
|
+
export async function sweepStaleProfileDirs(opts: ProfileSweepOptions = {}): Promise<string[]> {
|
|
206
|
+
const root = opts.root ?? tmpdir();
|
|
207
|
+
const cutoff = (opts.now ?? Date.now()) - (opts.graceMs ?? PROFILE_SWEEP_GRACE_MS);
|
|
208
|
+
const removed: string[] = [];
|
|
209
|
+
let entries: string[];
|
|
210
|
+
try {
|
|
211
|
+
entries = await readdir(root);
|
|
212
|
+
} catch {
|
|
213
|
+
return removed;
|
|
214
|
+
}
|
|
215
|
+
for (const name of entries) {
|
|
216
|
+
try {
|
|
217
|
+
const pid = profileDirOwnerPid(name);
|
|
218
|
+
if (pid === null) continue;
|
|
219
|
+
const dir = join(root, name);
|
|
220
|
+
const st = await lstat(dir);
|
|
221
|
+
if (!st.isDirectory() || st.mtimeMs >= cutoff) continue;
|
|
222
|
+
if (isPidAlive(pid)) continue;
|
|
223
|
+
if (!(await isOwnedProfileDir(dir, root))) continue;
|
|
224
|
+
if (await isProfileDirInUse(dir, opts)) continue;
|
|
225
|
+
await rm(dir, { recursive: true, force: true });
|
|
226
|
+
removed.push(dir);
|
|
227
|
+
} catch {
|
|
228
|
+
/* skip this entry */
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return removed;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Poll `cond` until it holds or `ms` elapses. The timer is ref'd on purpose
|
|
235
|
+
* (see waitForProcessExit in browser-lifecycle.ts): callers await it. */
|
|
236
|
+
async function waitUntil(cond: () => boolean | Promise<boolean>, ms: number): Promise<boolean> {
|
|
237
|
+
const deadline = Date.now() + ms;
|
|
238
|
+
for (;;) {
|
|
239
|
+
if (await cond()) return true;
|
|
240
|
+
if (Date.now() >= deadline) return false;
|
|
241
|
+
await delay(POLL_MS);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function delay(ms: number): Promise<void> {
|
|
246
|
+
return new Promise((resolve) => {
|
|
247
|
+
setTimeout(resolve, ms);
|
|
248
|
+
});
|
|
249
|
+
}
|
package/src/browser/ref-cache.ts
CHANGED
|
@@ -118,6 +118,9 @@ export interface SessionRecord {
|
|
|
118
118
|
launched: boolean;
|
|
119
119
|
pid?: number;
|
|
120
120
|
userDataDir?: string;
|
|
121
|
+
/** true = monobrowse created `userDataDir` itself, so closing this session
|
|
122
|
+
* deletes it (#395). Absent for a caller-supplied or older record. */
|
|
123
|
+
ownsUserDataDir?: boolean;
|
|
121
124
|
savedAt?: number;
|
|
122
125
|
/** Set by `--session <name>`: only commands naming it resolve this session,
|
|
123
126
|
* and commands naming none never land on it. */
|
|
@@ -142,6 +145,7 @@ export async function saveSessionRecord(
|
|
|
142
145
|
launched?: boolean;
|
|
143
146
|
pid?: number;
|
|
144
147
|
userDataDir?: string;
|
|
148
|
+
ownsUserDataDir?: boolean;
|
|
145
149
|
savedAt?: number;
|
|
146
150
|
name?: string;
|
|
147
151
|
},
|
|
@@ -161,6 +165,7 @@ export async function saveSessionRecord(
|
|
|
161
165
|
launched: opts?.launched !== false,
|
|
162
166
|
pid: opts?.pid,
|
|
163
167
|
userDataDir: opts?.userDataDir,
|
|
168
|
+
ownsUserDataDir: opts?.ownsUserDataDir === true || undefined,
|
|
164
169
|
savedAt: opts?.savedAt ?? Date.now(),
|
|
165
170
|
name: opts?.name,
|
|
166
171
|
}),
|
|
@@ -247,6 +252,7 @@ function parseSessionRecord(raw: string): SessionRecord | null {
|
|
|
247
252
|
launched?: unknown;
|
|
248
253
|
pid?: unknown;
|
|
249
254
|
userDataDir?: unknown;
|
|
255
|
+
ownsUserDataDir?: unknown;
|
|
250
256
|
savedAt?: unknown;
|
|
251
257
|
name?: unknown;
|
|
252
258
|
};
|
|
@@ -266,6 +272,7 @@ function parseSessionRecord(raw: string): SessionRecord | null {
|
|
|
266
272
|
? data.pid
|
|
267
273
|
: undefined,
|
|
268
274
|
userDataDir: typeof data.userDataDir === 'string' ? data.userDataDir : undefined,
|
|
275
|
+
ownsUserDataDir: data.ownsUserDataDir === true || undefined,
|
|
269
276
|
savedAt: typeof data.savedAt === 'number' ? data.savedAt : undefined,
|
|
270
277
|
name: typeof data.name === 'string' && SESSION_NAME_RE.test(data.name) ? data.name : undefined,
|
|
271
278
|
};
|
package/src/browser/types.ts
CHANGED
|
@@ -10,6 +10,12 @@ export interface BrowserConfig {
|
|
|
10
10
|
headless?: boolean;
|
|
11
11
|
executablePath?: string;
|
|
12
12
|
userDataDir?: string;
|
|
13
|
+
/** The caller created `userDataDir` for this launch alone (the CLI's
|
|
14
|
+
* session profile), so monobrowse deletes it when the browser closes,
|
|
15
|
+
* like the default profile. Only honoured for a directory that matches
|
|
16
|
+
* monobrowse's own tmpdir naming; any other `userDataDir` is never
|
|
17
|
+
* deleted. */
|
|
18
|
+
ownsUserDataDir?: boolean;
|
|
13
19
|
args?: string[];
|
|
14
20
|
/** Milliseconds to wait for the launched Chrome to open its CDP port
|
|
15
21
|
* before giving up. Default 10000 — raise this for environments where
|
package/src/cli/commands.ts
CHANGED
|
@@ -97,6 +97,8 @@ import type { Command, CommandContext, CommandResult } from './types.js';
|
|
|
97
97
|
// before the subcommands were split out, and the tests import them from here.
|
|
98
98
|
export { deriveBoxOutput } from './commands-page.js';
|
|
99
99
|
export { parseBatchCommandLine } from './commands-script.js';
|
|
100
|
+
// For embedders that do not process.exit() after a subcommand (#408).
|
|
101
|
+
export { disconnectSession } from './session.js';
|
|
100
102
|
|
|
101
103
|
// ---------------------------------------------------------------------------
|
|
102
104
|
// batch — dispatches over this file's own subcommand catalogue, which is why
|
package/src/cli/session.ts
CHANGED
|
@@ -34,8 +34,7 @@
|
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
36
|
import { randomUUID } from 'node:crypto';
|
|
37
|
-
import {
|
|
38
|
-
import { join } from 'node:path';
|
|
37
|
+
import { sessionProfileDirPath } from '../browser/profile-dir.js';
|
|
39
38
|
import { SESSION_NAME_RE } from '../browser/ref-cache.js';
|
|
40
39
|
import type { CdpClient, ElementRef, SessionRecord } from '../index.js';
|
|
41
40
|
import { output } from './output.js';
|
|
@@ -254,6 +253,7 @@ export async function adoptLegacySession(
|
|
|
254
253
|
launched: legacy.launched,
|
|
255
254
|
pid: legacy.pid,
|
|
256
255
|
userDataDir: legacy.userDataDir,
|
|
256
|
+
ownsUserDataDir: legacy.ownsUserDataDir,
|
|
257
257
|
savedAt: legacy.savedAt,
|
|
258
258
|
});
|
|
259
259
|
await browser.removeLegacySessionRecord();
|
|
@@ -290,8 +290,10 @@ export async function launchSessionBrowser(
|
|
|
290
290
|
port: 0,
|
|
291
291
|
headless: opts.headless,
|
|
292
292
|
// Chrome reports the port it bound inside this directory, so it must
|
|
293
|
-
// belong to this session alone
|
|
294
|
-
|
|
293
|
+
// belong to this session alone — and it is deleted when the session
|
|
294
|
+
// closes (#395).
|
|
295
|
+
userDataDir: sessionProfileDirPath(randomUUID().slice(0, 8)),
|
|
296
|
+
ownsUserDataDir: true,
|
|
295
297
|
});
|
|
296
298
|
await recordSession(browser, port, opts.name);
|
|
297
299
|
ensureSignalCleanupHandlers();
|
|
@@ -324,6 +326,7 @@ async function recordSession(
|
|
|
324
326
|
await browser.saveSessionRecord(port, {
|
|
325
327
|
pid,
|
|
326
328
|
userDataDir: browser.getLaunchedUserDataDir(port),
|
|
329
|
+
ownsUserDataDir: browser.ownsLaunchedUserDataDir(port),
|
|
327
330
|
name: name ?? (await browser.loadSessionRecord(port))?.name,
|
|
328
331
|
});
|
|
329
332
|
return;
|
|
@@ -337,6 +340,7 @@ async function recordSession(
|
|
|
337
340
|
launched: existing?.launched,
|
|
338
341
|
pid: existing?.pid,
|
|
339
342
|
userDataDir: existing?.userDataDir,
|
|
343
|
+
ownsUserDataDir: existing?.ownsUserDataDir,
|
|
340
344
|
name: name ?? existing?.name,
|
|
341
345
|
});
|
|
342
346
|
}
|
|
@@ -373,6 +377,20 @@ export async function ensureConnected(port: number, targetId?: string) {
|
|
|
373
377
|
return { client: session.client!, sessionId: session.sessionId, targetId: session.targetId };
|
|
374
378
|
}
|
|
375
379
|
|
|
380
|
+
/**
|
|
381
|
+
* Drop this process's CDP connection and nothing else: the browser, its
|
|
382
|
+
* session record and the port pin all stay, so the next command reconnects to
|
|
383
|
+
* the same session. An embedder that lets the event loop drain instead of
|
|
384
|
+
* calling process.exit() (monomind's CLI, ADR-R001) calls this once a
|
|
385
|
+
* subcommand is done — an open websocket otherwise holds the process until
|
|
386
|
+
* its force-exit watchdog (#408).
|
|
387
|
+
*/
|
|
388
|
+
export function disconnectSession(): void {
|
|
389
|
+
session.client?.close();
|
|
390
|
+
session.client = null;
|
|
391
|
+
session.sessionId = '';
|
|
392
|
+
}
|
|
393
|
+
|
|
376
394
|
// Each CLI invocation is a fresh process, so the in-memory session.refs Map built by
|
|
377
395
|
// a prior `snapshot` command is gone by the time a later `find`/`click`/etc.
|
|
378
396
|
// command runs. Rehydrate it from the on-disk ref cache (written by
|