@ultimat3/scraping 30.8.0 → 31.0.0

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 (47) hide show
  1. package/README.md +73 -8
  2. package/package.json +9 -6
  3. package/src/actionability.ts +2 -2
  4. package/src/adapter.ts +22 -0
  5. package/src/auth.ts +4 -5
  6. package/src/browser-dial.ts +55 -0
  7. package/src/cdp-arm.ts +81 -22
  8. package/src/cdp-connection.ts +290 -0
  9. package/src/cdp-error-codes.ts +28 -0
  10. package/src/cdp-errors.ts +179 -0
  11. package/src/cdp-launch-attempt.ts +234 -0
  12. package/src/cdp-launch-candidates.ts +62 -0
  13. package/src/cdp-launch-probe.ts +75 -0
  14. package/src/cdp-launch-profile.ts +106 -0
  15. package/src/cdp-launch-reap.ts +68 -0
  16. package/src/cdp-launch-wire.ts +171 -0
  17. package/src/cdp-launch.ts +216 -0
  18. package/src/cdp-pipe.ts +77 -0
  19. package/src/cdp-wire-watch.ts +113 -0
  20. package/src/cdp.ts +46 -0
  21. package/src/clock.ts +4 -111
  22. package/src/driver-cdp.ts +3 -0
  23. package/src/driver-fake.ts +4 -5
  24. package/src/driver.ts +16 -11
  25. package/src/error-throws-pdf.ts +45 -0
  26. package/src/error-throws-session.ts +1 -1
  27. package/src/error-throws.ts +4 -4
  28. package/src/errors.ts +17 -1
  29. package/src/event-prompt.ts +10 -4
  30. package/src/events.ts +2 -3
  31. package/src/html-target.ts +2 -2
  32. package/src/http-recorded.ts +2 -2
  33. package/src/http.ts +2 -2
  34. package/src/index.ts +21 -75
  35. package/src/launch-args.ts +10 -1
  36. package/src/page-over-target.ts +2 -3
  37. package/src/pdf-action.ts +116 -0
  38. package/src/pdf-cdp-params.ts +65 -0
  39. package/src/pdf-fence.ts +86 -0
  40. package/src/pdf-render.ts +241 -0
  41. package/src/pdf-slots.ts +68 -0
  42. package/src/rate.ts +2 -2
  43. package/src/scrape-run.ts +3 -4
  44. package/src/scrape.ts +5 -5
  45. package/src/target.ts +1 -1
  46. package/src/usage.ts +2 -2
  47. package/src/watchdog.ts +3 -3
@@ -0,0 +1,234 @@
1
+ // One responsibility: ONE start of a Chrome process — spawn it on a throwaway profile, wait for its
2
+ // first DevTools answer, and on no answer reap it (killed, awaited, profile removed) and say why.
3
+ // Which binary, which flags and how many starts is `cdp-launch.ts`.
4
+
5
+ import { renderThrowable } from '@ultimat3/core';
6
+ import type { CdpConnection } from './cdp-connection';
7
+ import type { CdpLaunchAttempt } from './cdp-errors';
8
+ import { CdpTimeoutError } from './cdp-errors';
9
+ import { throwawayProfile } from './cdp-launch-profile';
10
+ import { CLOSE_GRACE_MS, killTree } from './cdp-launch-reap';
11
+ import type { CdpWire } from './cdp-launch-wire';
12
+ import { pipeConnection, portConnection, wireStdio } from './cdp-launch-wire';
13
+
14
+ export { CLOSE_GRACE_MS } from './cdp-launch-reap';
15
+
16
+ /**
17
+ * The longest `LaunchedBrowser.close()` is DESIGNED to take: SIGTERM's grace, SIGKILL's, the group
18
+ * reap, then the profile's removal — four `CLOSE_GRACE_MS`, each step bounded by its own. A hook
19
+ * that closes a browser and gives it less is killed by Bun mid-close, and when that hook is the
20
+ * run's last the process exits with the profile half-removed: the `Default/Cache` tree the leaked
21
+ * `x-e2e-chrome-*` directories held.
22
+ */
23
+ export const LAUNCHED_CLOSE_MS = 4 * CLOSE_GRACE_MS;
24
+
25
+ export interface LaunchedBrowser {
26
+ /** The browser's own CDP connection, over its debugging pipe or port. Already answering. */
27
+ readonly connection: CdpConnection;
28
+ /**
29
+ * THE close, idempotent, and awaited by every caller: the connection closed, SIGTERM, SIGKILL
30
+ * after `CLOSE_GRACE_MS`, then the browser's whole process group killed and, only then, the
31
+ * profile removed — bounded at `LAUNCHED_CLOSE_MS`. On Windows, which has no SIGTERM to grant grace to, the process TREE
32
+ * is killed outright (`killTree`) and awaited. A promise because a Chrome still shutting down competes with the next
33
+ * launch on a 4-CPU runner, and its children re-create a profile removed before they are gone.
34
+ */
35
+ close(): Promise<void>;
36
+ }
37
+
38
+ export interface LaunchAttemptOptions {
39
+ readonly executable: string;
40
+ /** What the process is started with, given the profile directory this attempt made for it. */
41
+ readonly flags: (profileDir: string) => readonly string[];
42
+ /** Which wire the flags opened — `cdp-launch-wire.ts`. */
43
+ readonly wire: CdpWire;
44
+ /** Whose process rules the reap follows. Defaults to this process's platform. */
45
+ readonly platform?: string | undefined;
46
+ /** Every call's deadline AFTER the first. */
47
+ readonly timeoutMs: number;
48
+ /** The first call's deadline: how long a cold start may take. */
49
+ readonly launchTimeoutMs: number;
50
+ /**
51
+ * The longest the browser may live at all, from its spawn: past it the OS process is killed
52
+ * (`SIGKILL`) even if no `close()` ever ran — a caller that crashed, a hook Bun cut short. The
53
+ * normal end is still `close()`; this is the bound the spawn-timeout guard asks for.
54
+ */
55
+ readonly lifetimeMs: number;
56
+ }
57
+
58
+ export type LaunchAttemptResult =
59
+ | { readonly ok: true; readonly browser: LaunchedBrowser }
60
+ /** `reaped: false` is a process that outlived SIGKILL's grace — never start another beside it. */
61
+ | { readonly ok: false; readonly failure: CdpLaunchAttempt; readonly reaped: boolean };
62
+
63
+ const STDERR_TAIL_CHARS = 4_000;
64
+ /** How much of the tail one attempt hands a cause: the last lines, each cut to a readable width. */
65
+ const TAIL_LINES = 3;
66
+ const TAIL_LINE_CHARS = 160;
67
+
68
+ /**
69
+ * Read stderr to its end for the life of the process, keeping only a bounded tail. A pipe nobody
70
+ * reads fills, and Chrome's next stderr write then blocks the thread making it — a browser that
71
+ * stops answering mid-run for a reason no log shows. The tail is the launch-failure diagnostics:
72
+ * a missing library, a sandbox refusal and a bad flag are all named there and nowhere else.
73
+ */
74
+ function stderrTail(stream: ReadableStream<Uint8Array>): {
75
+ readonly lastLines: () => string;
76
+ /** Settles once the stream has ended — every byte the process wrote has been read. */
77
+ readonly drained: Promise<void>;
78
+ } {
79
+ let text = '';
80
+ const drained = (async () => {
81
+ const decoder = new TextDecoder();
82
+ for await (const chunk of stream) {
83
+ text = (text + decoder.decode(chunk, { stream: true })).slice(-STDERR_TAIL_CHARS);
84
+ }
85
+ })().catch(() => undefined);
86
+ const lastLines = (): string =>
87
+ text
88
+ .trim()
89
+ .split('\n')
90
+ .slice(-TAIL_LINES)
91
+ .map((line) => line.slice(0, TAIL_LINE_CHARS))
92
+ .join(' | ');
93
+ return { lastLines, drained };
94
+ }
95
+
96
+ /**
97
+ * How long a browser that CLOSED ITS PIPE gets to finish dying, and any browser's stderr to finish
98
+ * draining, before the tail is read. The pipe ending and the stderr reader reaching the last line
99
+ * are two unordered events; read at the first, the reason a browser died was reported as "printed
100
+ * nothing". Bounded, because a WEDGED browser neither exits nor closes stderr.
101
+ */
102
+ const FAILURE_DRAIN_MS = 1_000;
103
+
104
+ /**
105
+ * The longest a FAILED attempt spends after its deadline before the next can start: the drain a
106
+ * browser that hung up gets, the bounded close (`LAUNCHED_CLOSE_MS`), then the stderr drain. A
107
+ * caller's hook deadline adds this per attempt.
108
+ */
109
+ export const LAUNCH_REAP_MS = FAILURE_DRAIN_MS + LAUNCHED_CLOSE_MS + FAILURE_DRAIN_MS;
110
+
111
+ const within = (ms: number, work: Promise<unknown>): Promise<unknown> =>
112
+ Promise.race([work, Bun.sleep(ms)]);
113
+
114
+ /**
115
+ * Start the browser once and answer when it has answered one CDP call. With a pipe there is no
116
+ * "DevTools listening" line to wait for — the first reply IS the readiness signal.
117
+ */
118
+ export async function launchAttempt(options: LaunchAttemptOptions): Promise<LaunchAttemptResult> {
119
+ const platform = options.platform ?? process.platform;
120
+ const profile = throwawayProfile();
121
+ const profileDir = profile.path;
122
+ const began = performance.now();
123
+ let child: ReturnType<typeof spawnBrowser>;
124
+ try {
125
+ child = spawnBrowser(options, profileDir, platform);
126
+ } catch (error) {
127
+ // A binary that cannot be started at all — not there, not executable, not an executable FORMAT
128
+ // this OS runs (a `#!` script on Windows). Bun throws from `spawn`, synchronously, and that
129
+ // throw escaped as a bare ENOENT where every other way a launch fails is X_CDP_LAUNCH_FAILED.
130
+ await profile.remove(undefined, CLOSE_GRACE_MS);
131
+ return {
132
+ ok: false,
133
+ failure: {
134
+ why: 'spawn',
135
+ waitedMs: 0,
136
+ exitCode: null,
137
+ stderr: '',
138
+ detail: renderThrowable(error),
139
+ },
140
+ reaped: true,
141
+ };
142
+ }
143
+ const tail = stderrTail(child.stderr as ReadableStream<Uint8Array>);
144
+ let connection: CdpConnection | undefined =
145
+ options.wire === 'pipe' ? pipeConnection(child.stdio, options.timeoutMs) : undefined;
146
+ let exiting: Promise<void> | undefined;
147
+ const close = (): Promise<void> => {
148
+ exiting ??= (async () => {
149
+ // Read while the browser still runs: a clean exit unlinks it, a killed one never does.
150
+ const singleton = profile.singletonDir();
151
+ connection?.close();
152
+ if (platform === 'win32') {
153
+ // No SIGTERM to grant grace to: a kill on Windows is TerminateProcess, and taking the
154
+ // browser alone would orphan the children that hold the profile's files open.
155
+ await killTree(child.pid, platform);
156
+ await within(CLOSE_GRACE_MS, child.exited);
157
+ } else {
158
+ child.kill();
159
+ const exited =
160
+ (await within(
161
+ CLOSE_GRACE_MS,
162
+ child.exited.then(() => true),
163
+ )) === true;
164
+ if (!exited) {
165
+ child.kill('SIGKILL');
166
+ await within(CLOSE_GRACE_MS, child.exited);
167
+ }
168
+ await killTree(child.pid, platform);
169
+ }
170
+ // Only now, with the tree gone: removed under a live browser, the profile is re-created by
171
+ // its shutdown — the network process flushes its cache, the browser writes its prefs — and
172
+ // a close cut short there leaves exactly that tree. Measured on Chrome 150, 30 launches
173
+ // each: left behind 6 and 12 times without the group reap, 0 with.
174
+ await profile.remove(singleton, CLOSE_GRACE_MS);
175
+ })();
176
+ return exiting;
177
+ };
178
+ try {
179
+ // The pipe needs no wait to exist; the port is not known until the browser writes it down, and
180
+ // finding it spends the same launch deadline the first answer does.
181
+ connection ??= await portConnection({
182
+ profileDir,
183
+ exited: () => child.exitCode !== null || child.signalCode !== null,
184
+ deadlineMs: options.launchTimeoutMs,
185
+ timeoutMs: options.timeoutMs,
186
+ });
187
+ const left = Math.max(1, options.launchTimeoutMs - (performance.now() - began));
188
+ await connection.send('Browser.getVersion', {}, undefined, left);
189
+ return { ok: true, browser: { connection, close } };
190
+ } catch (error) {
191
+ const why = error instanceof CdpTimeoutError ? 'deadline' : 'closed';
192
+ // The deadline itself when that is what ended it: the number the reader can raise.
193
+ const waitedMs =
194
+ why === 'deadline' ? options.launchTimeoutMs : Math.round(performance.now() - began);
195
+ // Only a browser that hung up may be on its way out with the reason still unwritten. One that
196
+ // was silent for the whole deadline has had its time.
197
+ if (why === 'closed') await within(FAILURE_DRAIN_MS, child.exited);
198
+ // Read BEFORE the kill: after it, an exit code is ours and says nothing about the browser.
199
+ const exitCode = child.exitCode;
200
+ await close();
201
+ await within(FAILURE_DRAIN_MS, tail.drained);
202
+ return {
203
+ ok: false,
204
+ // `detail`: the error itself. Without it a refused dial after "DevTools listening" read as a
205
+ // browser that hung up (seen on windows), and nothing said which step had failed.
206
+ failure: {
207
+ why,
208
+ waitedMs,
209
+ exitCode,
210
+ stderr: tail.lastLines(),
211
+ detail: renderThrowable(error),
212
+ },
213
+ reaped: child.exitCode !== null || child.signalCode !== null,
214
+ };
215
+ }
216
+ }
217
+
218
+ /**
219
+ * The process, on its own process GROUP on POSIX so the zygote, GPU and network processes are
220
+ * reaped with it (`killTree`). It still ends with this process on the pipe wire: Chrome exits when
221
+ * its fd 3 closes. Windows has no group to make — `detached` there is a console flag — so not.
222
+ */
223
+ function spawnBrowser(options: LaunchAttemptOptions, profileDir: string, platform: string) {
224
+ // Spread into a fresh tuple: Bun's `stdio` type takes a mutable tuple, never a readonly one.
225
+ const [stdin, stdout, stderr, ...extra] = wireStdio(options.wire);
226
+ return Bun.spawn([options.executable, ...options.flags(profileDir)], {
227
+ stdio: [stdin, stdout, stderr, ...extra],
228
+ detached: platform !== 'win32',
229
+ // A backstop, never the close: `close()` ends a browser its caller is done with. This ends one
230
+ // whose caller vanished, so no Chrome outlives every reason it was started for.
231
+ timeout: options.lifetimeMs,
232
+ killSignal: 'SIGKILL',
233
+ });
234
+ }
@@ -0,0 +1,62 @@
1
+ // One responsibility: WHERE a Chrome is installed, per platform — the ordered list `findChrome`
2
+ // probes when `CHROME_PATH` names none. Which binary wins and how it starts is `cdp-launch.ts`.
3
+
4
+ // why: a Windows candidate is spelled with `\` whatever OS computes the list (a test, or the
5
+ // Linux runner asserting it), and only node:path's win32 half joins that way. Bun has no path API.
6
+ import { win32 } from 'node:path';
7
+
8
+ type Env = Readonly<Record<string, string | undefined>>;
9
+
10
+ /** What GitHub-hosted `ubuntu-latest` ships, which lets CI run the browser suite with no download. */
11
+ const LINUX: readonly string[] = [
12
+ '/usr/bin/google-chrome',
13
+ '/usr/bin/google-chrome-stable',
14
+ '/usr/bin/chromium',
15
+ '/usr/bin/chromium-browser',
16
+ ];
17
+
18
+ const set = (value: string | undefined): string | undefined =>
19
+ value === undefined || value === '' ? undefined : value;
20
+
21
+ /**
22
+ * Chrome under each root an installer uses — machine-wide 64- and 32-bit, then per-user — then
23
+ * Edge, which is Chromium, speaks the same DevTools protocol and is on every Windows 10 and 11. A
24
+ * root the environment does not name falls back to its fixed default; a per-user root has none, so
25
+ * it is not guessed at.
26
+ */
27
+ const windows = (env: Env): readonly string[] => {
28
+ const programFiles = set(env['ProgramFiles']) ?? 'C:\\Program Files';
29
+ const programFilesX86 = set(env['ProgramFiles(x86)']) ?? 'C:\\Program Files (x86)';
30
+ const localAppData = set(env['LOCALAPPDATA']);
31
+ const chromeRoots = [programFiles, programFilesX86, ...(localAppData ? [localAppData] : [])];
32
+ return [
33
+ ...chromeRoots.map((root) => win32.join(root, 'Google', 'Chrome', 'Application', 'chrome.exe')),
34
+ // x86 first for Edge: it is where the Windows image installs it, 64-bit or not.
35
+ ...[programFilesX86, programFiles].map((root) =>
36
+ win32.join(root, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
37
+ ),
38
+ ];
39
+ };
40
+
41
+ const MAC_CHROME = 'Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
42
+
43
+ /** The app bundles' executables: system-wide, then the per-user `~/Applications` an admin-less install uses. */
44
+ const mac = (env: Env): readonly string[] => {
45
+ const home = set(env['HOME']);
46
+ return [
47
+ `/${MAC_CHROME}`,
48
+ ...(home ? [`${home}/${MAC_CHROME}`] : []),
49
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
50
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
51
+ ];
52
+ };
53
+
54
+ /**
55
+ * Where a Chrome is on `platform`, in the order worth trying. Any platform that is neither Windows
56
+ * nor macOS gets the Linux paths — the BSDs install to the same names when they install at all.
57
+ */
58
+ export function chromeCandidates(platform: string, env: Env): readonly string[] {
59
+ if (platform === 'win32') return windows(env);
60
+ if (platform === 'darwin') return mac(env);
61
+ return LINUX;
62
+ }
@@ -0,0 +1,75 @@
1
+ // One responsibility: whether a candidate Chrome is one this machine can START — `--version`, under
2
+ // a deadline of its own. Which candidates are tried, and in what order, is `cdp-launch.ts`.
3
+
4
+ /**
5
+ * The longest a candidate gets to answer `--version`. A warm Chrome answers in ~40 ms; a cold binary
6
+ * on a loaded runner is the slow case, and one that has not answered by then is not a browser this
7
+ * run can drive. Its own number, never the launch deadline: this is spent BEFORE one.
8
+ */
9
+ export const CHROME_PROBE_TIMEOUT_MS = 15_000;
10
+
11
+ /**
12
+ * A browser banner: a product this launcher can drive, then a dotted version.
13
+ *
14
+ * Read only when the candidate PRINTED something. `--version` is not a browser's alone — `bun
15
+ * --version` exits 0 and prints `1.4.4` — so output that names no browser is output from something
16
+ * that is not one. Silence is the other case, and it is accepted: a browser that starts and stops
17
+ * cleanly saying nothing judges nothing it was not told.
18
+ */
19
+ const BROWSER_BANNER = /(?:chrome|chromium|edge)[^\n]*?\d+\.\d+/i;
20
+
21
+ /**
22
+ * Whether `candidate` is a browser this machine can actually START — not merely a file that exists.
23
+ *
24
+ * The two are not the same, and the difference is a whole red gate: Ubuntu 24.04 ships
25
+ * `/usr/bin/chromium-browser` as a shell wrapper around `snap`, which is present and executable on
26
+ * every install and which exits 1 having started nothing where no snap cgroup exists (`is not a snap
27
+ * cgroup for tag snap.chromium.chromium`, measured 2026-11). A candidate that cannot start is an
28
+ * ABSENT candidate: the browser-backed suites' headers promise a SKIP, not `X_CDP_LAUNCH_FAILED`.
29
+ *
30
+ * NOT RUN on `win32`, where existence is the answer: `chrome.exe` is a GUI binary, and `--version`
31
+ * there opens a browser and never exits — #1033's Windows job spent 15 s on each of two candidates
32
+ * and timed out the test that asked. The snap wrapper this probe exists for is Linux-only.
33
+ *
34
+ * TOTAL: a candidate that cannot be spawned, dies, misses the deadline, or PRINTS something that
35
+ * names no browser is `false`, never a throw. One deadline covers the exit AND the banner: a
36
+ * grandchild that inherits stdout holds the pipe open past the child's own exit.
37
+ */
38
+ export async function startsChrome(
39
+ candidate: string,
40
+ platform: string,
41
+ timeoutMs: number = CHROME_PROBE_TIMEOUT_MS,
42
+ ): Promise<boolean> {
43
+ if (platform === 'win32') return true;
44
+ let child: ReturnType<typeof Bun.spawn>;
45
+ try {
46
+ // `timeout:` is Bun's own kill at the deadline, so a probe this function stopped waiting on
47
+ // is never left running.
48
+ child = Bun.spawn([candidate, '--version'], {
49
+ stdin: 'ignore',
50
+ stdout: 'pipe',
51
+ stderr: 'ignore',
52
+ timeout: timeoutMs,
53
+ });
54
+ } catch {
55
+ return false;
56
+ }
57
+ const banner = new Response(child.stdout as ReadableStream<Uint8Array>).text().catch(() => '');
58
+ const answered = child.exited.then(async (code) =>
59
+ code === 0 ? (await banner).trim() : undefined,
60
+ );
61
+ let timer: ReturnType<typeof setTimeout> | undefined;
62
+ const deadline = new Promise<undefined>((resolve) => {
63
+ // real-clock: bounds a spawned process, not a timer a test arms.
64
+ timer = setTimeout(() => resolve(undefined), timeoutMs);
65
+ });
66
+ try {
67
+ const printed = await Promise.race([answered, deadline]);
68
+ if (printed === undefined) return false;
69
+ return printed === '' || BROWSER_BANNER.test(printed);
70
+ } finally {
71
+ clearTimeout(timer);
72
+ // A probe that timed out is still a live process; one that exited ignores this.
73
+ child.kill();
74
+ }
75
+ }
@@ -0,0 +1,106 @@
1
+ // One responsibility: the throwaway profile a launch hands Chrome, and its removal — after every
2
+ // process of the browser is gone, retried while something re-creates it, bounded — together with
3
+ // the singleton-socket directory Chrome makes BESIDE it in the temp root.
4
+
5
+ // why: Bun exposes no temp-root, recursive-remove or readlink primitive; the profile needs all three.
6
+ import { existsSync, mkdtempSync, readdirSync, readlinkSync, rmdirSync, rmSync } from 'node:fs';
7
+ // why: Bun exposes no tmpdir(), so only node:os answers the platform temp root.
8
+ import { tmpdir } from 'node:os';
9
+ // why: Bun exposes no path-join primitive.
10
+ import { basename, dirname, join } from 'node:path';
11
+
12
+ /**
13
+ * How long a removed profile is watched for coming back before it counts as gone. Chrome's late
14
+ * writers are the network service flushing its disk cache and the browser's atomic preference
15
+ * write (`Default/.com.google.Chrome.XXXXXX`) — the exact tree the leaked profiles held. Removal
16
+ * waits for the process tree first, so the look is for what the tree kill cannot see: a process
17
+ * outside the group (Chrome's crashpad handler calls `setsid`), and on Windows a child `taskkill`
18
+ * has not finished ending when it returns.
19
+ */
20
+ export const PROFILE_SETTLE_MS = 50;
21
+
22
+ /**
23
+ * What Chrome's ProcessSingleton puts in its socket directory, and nothing else. The directory is
24
+ * `<temp>/com.google.Chrome.XXXXXX` (Chromium: `org.chromium.Chromium.…`), linked from the profile's
25
+ * `SingletonSocket`, and a browser that does not shut down cleanly never removes it: measured, one
26
+ * left in the temp root per launch — 444 of them beside 146 leaked profiles.
27
+ */
28
+ const SINGLETON_FILES: ReadonlySet<string> = new Set([
29
+ 'SingletonSocket',
30
+ 'SingletonCookie',
31
+ 'SingletonLock',
32
+ ]);
33
+
34
+ export interface ThrowawayProfile {
35
+ readonly path: string;
36
+ /**
37
+ * Where the browser's singleton socket lives, read off the profile's own link. Asked BEFORE the
38
+ * browser is signalled: a browser that exits cleanly removes the link first, and one killed
39
+ * leaves the directory with nothing in the profile pointing at it.
40
+ */
41
+ singletonDir(): string | undefined;
42
+ /**
43
+ * Remove the profile — and `singleton`, when it holds Chrome's singleton files and nothing else —
44
+ * and answer once neither has come back for `settleMs` (`PROFILE_SETTLE_MS`), or at `deadlineMs`.
45
+ * Never throws: a directory that will not go is litter, never the close's verdict.
46
+ */
47
+ remove(singleton: string | undefined, deadlineMs: number, settleMs?: number): Promise<void>;
48
+ }
49
+
50
+ const quietly = (work: () => void): void => {
51
+ try {
52
+ work();
53
+ } catch {
54
+ // Already gone, or held open past the deadline: the loop's next look decides which.
55
+ }
56
+ };
57
+
58
+ /**
59
+ * Only a directory whose every entry is a singleton file is Chrome's to have made: a link that
60
+ * points anywhere else is somebody's directory, and is left alone.
61
+ */
62
+ function isSingletonDir(dir: string): boolean {
63
+ try {
64
+ return readdirSync(dir).every((entry) => SINGLETON_FILES.has(entry));
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
70
+ /** File by file, then a plain `rmdir` — never a recursive remove of a path read off a link. */
71
+ function removeSingleton(dir: string): void {
72
+ for (const entry of SINGLETON_FILES) quietly(() => rmSync(join(dir, entry), { force: true }));
73
+ quietly(() => rmdirSync(dir));
74
+ }
75
+
76
+ /** A fresh profile directory under the temp root, named so a leak is recognisable as ours. */
77
+ export function throwawayProfile(root: string = tmpdir()): ThrowawayProfile {
78
+ const path = mkdtempSync(join(root, 'x-e2e-chrome-'));
79
+ return {
80
+ path,
81
+ singletonDir() {
82
+ try {
83
+ const target = readlinkSync(join(path, 'SingletonSocket'));
84
+ return basename(target) === 'SingletonSocket' ? dirname(target) : undefined;
85
+ } catch {
86
+ return undefined;
87
+ }
88
+ },
89
+ async remove(singleton, deadlineMs, settleMs = PROFILE_SETTLE_MS) {
90
+ const until = performance.now() + deadlineMs;
91
+ const owned = singleton !== undefined && isSingletonDir(singleton) ? singleton : undefined;
92
+ const present = (): boolean => existsSync(path) || (owned !== undefined && existsSync(owned));
93
+ do {
94
+ quietly(() => rmSync(path, { recursive: true, force: true }));
95
+ if (owned !== undefined) removeSingleton(owned);
96
+ if (present()) {
97
+ await Bun.sleep(settleMs / 5);
98
+ continue;
99
+ }
100
+ // Gone NOW is not gone: the look after the settle is what a late writer fails.
101
+ await Bun.sleep(settleMs);
102
+ if (!present()) return;
103
+ } while (performance.now() < until);
104
+ },
105
+ };
106
+ }
@@ -0,0 +1,68 @@
1
+ // One responsibility: nothing of a launched browser outlives its close — on POSIX its process GROUP,
2
+ // on Windows its process TREE — and the close waits until that is so, bounded.
3
+
4
+ /** How long a closed Chrome gets to exit on SIGTERM before it is killed outright. */
5
+ export const CLOSE_GRACE_MS = 5_000;
6
+
7
+ const GROUP_POLL_MS = 10;
8
+
9
+ /** Runs a command to its exit; the seam the Windows branch is asserted through on any runner. */
10
+ export type RunCommand = (argv: readonly string[]) => Promise<number>;
11
+
12
+ const runQuiet: RunCommand = (argv) =>
13
+ // Bounded: taskkill ends a tree in well under a second; a stuck one must not stall `close()`.
14
+ Bun.spawn([...argv], { stdio: ['ignore', 'ignore', 'ignore'], timeout: 10_000 }).exited;
15
+
16
+ /**
17
+ * `/T` takes every process the browser started — renderer, GPU, network, crashpad — and `/F` does
18
+ * not ask: a tree kill is the last step of a close, after the graceful one had its chance.
19
+ */
20
+ export const taskkillArgv = (pid: number): readonly string[] => [
21
+ 'taskkill',
22
+ '/T',
23
+ '/F',
24
+ '/PID',
25
+ String(pid),
26
+ ];
27
+
28
+ const within = (ms: number, work: Promise<unknown>): Promise<unknown> =>
29
+ Promise.race([work, Bun.sleep(ms)]);
30
+
31
+ /**
32
+ * Kill whatever is left of the browser's process group and wait until the group is EMPTY — the
33
+ * condition, asked of the kernel, not a pause: signal 0 to a group with no member throws. Bounded
34
+ * by `CLOSE_GRACE_MS` for a container whose init never reaps a zombie — on the monotonic clock,
35
+ * not a count of sleeps: a loaded runner oversleeps every 10 ms, and the close's designed bound
36
+ * (`LAUNCHED_CLOSE_MS`) is what a hook deadline is derived from.
37
+ */
38
+ async function reapGroup(pgid: number): Promise<void> {
39
+ const until = performance.now() + CLOSE_GRACE_MS;
40
+ try {
41
+ process.kill(-pgid, 'SIGKILL');
42
+ while (performance.now() < until) {
43
+ await Bun.sleep(GROUP_POLL_MS);
44
+ process.kill(-pgid, 0);
45
+ }
46
+ } catch {
47
+ // ESRCH: nothing is left in the group, which is the answer being waited for.
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Everything the browser `pid` started, gone. On Windows `process.kill(-pgid)` is no call at all —
53
+ * there is no process group, and the browser's children hold the profile's files open — so the
54
+ * tree is walked by `taskkill`, which must run while the browser is still ALIVE: its children are
55
+ * found by parent pid, and a dead parent's children are nobody's.
56
+ */
57
+ export async function killTree(
58
+ pid: number,
59
+ platform: string = process.platform,
60
+ run: RunCommand = runQuiet,
61
+ ): Promise<void> {
62
+ if (platform !== 'win32') return reapGroup(pid);
63
+ try {
64
+ await within(CLOSE_GRACE_MS, run(taskkillArgv(pid)));
65
+ } catch {
66
+ // No taskkill, or a tree already gone (exit 128): litter at worst, never the close's verdict.
67
+ }
68
+ }