@ultimat3/testing 23.0.0 → 25.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 (60) hide show
  1. package/CLAUDE.md +20 -20
  2. package/README.md +95 -13
  3. package/package.json +16 -16
  4. package/src/auth-request.ts +1 -1
  5. package/src/budget-store-conformance.ts +81 -0
  6. package/src/cdp-browser-lease.ts +112 -0
  7. package/src/cdp-browser.ts +47 -11
  8. package/src/cdp-connection.ts +41 -10
  9. package/src/cdp-e2e-session.ts +130 -13
  10. package/src/cdp-errors.ts +129 -8
  11. package/src/cdp-launch-attempt.ts +224 -0
  12. package/src/cdp-launch-candidates.ts +62 -0
  13. package/src/cdp-launch-profile.ts +106 -0
  14. package/src/cdp-launch-reap.ts +68 -0
  15. package/src/cdp-launch-wire.ts +171 -0
  16. package/src/cdp-launch.ts +92 -140
  17. package/src/cdp-offline-script.ts +33 -3
  18. package/src/cdp-wire-watch.ts +113 -0
  19. package/src/define-island-states.ts +47 -8
  20. package/src/determinism.ts +35 -6
  21. package/src/e2e-app.ts +17 -1
  22. package/src/e2e-errors.ts +14 -3
  23. package/src/e2e-locale.ts +12 -21
  24. package/src/e2e-page.ts +12 -26
  25. package/src/e2e-run.ts +22 -12
  26. package/src/e2e-service-worker.ts +92 -0
  27. package/src/e2e-spawn.ts +58 -17
  28. package/src/errors.ts +34 -66
  29. package/src/factories.ts +4 -6
  30. package/src/file-boundary.ts +12 -7
  31. package/src/fixture-clock.ts +1 -1
  32. package/src/fixture-island.ts +50 -98
  33. package/src/fixture-jobs.ts +16 -12
  34. package/src/fixture-mail.ts +3 -3
  35. package/src/fixture-network.ts +1 -1
  36. package/src/fixture-statements.ts +1 -1
  37. package/src/fixture-subscribe.ts +3 -3
  38. package/src/framework-fixtures.ts +13 -13
  39. package/src/free-port.ts +46 -0
  40. package/src/harness.ts +2 -1
  41. package/src/index.ts +29 -10
  42. package/src/island-dom.ts +57 -13
  43. package/src/island-globals.ts +73 -0
  44. package/src/island-html-entities.ts +27 -0
  45. package/src/island-scratch.ts +105 -0
  46. package/src/island-state-errors.ts +16 -0
  47. package/src/island-states.ts +23 -0
  48. package/src/job-driver-checks.ts +177 -0
  49. package/src/job-driver-conformance.ts +21 -0
  50. package/src/live-node.ts +6 -4
  51. package/src/preload.ts +2 -8
  52. package/src/quiet-logs.ts +1 -1
  53. package/src/registry-leak-error.ts +81 -0
  54. package/src/registry-leak-guard.ts +7 -4
  55. package/src/registry-snapshot.ts +41 -2
  56. package/src/render-view.ts +3 -3
  57. package/src/sealed-network.ts +50 -5
  58. package/src/sealed-sockets.ts +59 -0
  59. package/src/template-db.ts +8 -5
  60. package/src/test-types.ts +6 -2
@@ -0,0 +1,224 @@
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
+
52
+ export type LaunchAttemptResult =
53
+ | { readonly ok: true; readonly browser: LaunchedBrowser }
54
+ /** `reaped: false` is a process that outlived SIGKILL's grace — never start another beside it. */
55
+ | { readonly ok: false; readonly failure: CdpLaunchAttempt; readonly reaped: boolean };
56
+
57
+ const STDERR_TAIL_CHARS = 4_000;
58
+ /** How much of the tail one attempt hands a cause: the last lines, each cut to a readable width. */
59
+ const TAIL_LINES = 3;
60
+ const TAIL_LINE_CHARS = 160;
61
+
62
+ /**
63
+ * Read stderr to its end for the life of the process, keeping only a bounded tail. A pipe nobody
64
+ * reads fills, and Chrome's next stderr write then blocks the thread making it — a browser that
65
+ * stops answering mid-run for a reason no log shows. The tail is the launch-failure diagnostics:
66
+ * a missing library, a sandbox refusal and a bad flag are all named there and nowhere else.
67
+ */
68
+ function stderrTail(stream: ReadableStream<Uint8Array>): {
69
+ readonly lastLines: () => string;
70
+ /** Settles once the stream has ended — every byte the process wrote has been read. */
71
+ readonly drained: Promise<void>;
72
+ } {
73
+ let text = '';
74
+ const drained = (async () => {
75
+ const decoder = new TextDecoder();
76
+ for await (const chunk of stream) {
77
+ text = (text + decoder.decode(chunk, { stream: true })).slice(-STDERR_TAIL_CHARS);
78
+ }
79
+ })().catch(() => undefined);
80
+ const lastLines = (): string =>
81
+ text
82
+ .trim()
83
+ .split('\n')
84
+ .slice(-TAIL_LINES)
85
+ .map((line) => line.slice(0, TAIL_LINE_CHARS))
86
+ .join(' | ');
87
+ return { lastLines, drained };
88
+ }
89
+
90
+ /**
91
+ * How long a browser that CLOSED ITS PIPE gets to finish dying, and any browser's stderr to finish
92
+ * draining, before the tail is read. The pipe ending and the stderr reader reaching the last line
93
+ * are two unordered events; read at the first, the reason a browser died was reported as "printed
94
+ * nothing". Bounded, because a WEDGED browser neither exits nor closes stderr.
95
+ */
96
+ const FAILURE_DRAIN_MS = 1_000;
97
+
98
+ /**
99
+ * The longest a FAILED attempt spends after its deadline before the next can start: the drain a
100
+ * browser that hung up gets, the bounded close (`LAUNCHED_CLOSE_MS`), then the stderr drain. A
101
+ * caller's hook deadline adds this per attempt.
102
+ */
103
+ export const LAUNCH_REAP_MS = FAILURE_DRAIN_MS + LAUNCHED_CLOSE_MS + FAILURE_DRAIN_MS;
104
+
105
+ const within = (ms: number, work: Promise<unknown>): Promise<unknown> =>
106
+ Promise.race([work, Bun.sleep(ms)]);
107
+
108
+ /**
109
+ * Start the browser once and answer when it has answered one CDP call. With a pipe there is no
110
+ * "DevTools listening" line to wait for — the first reply IS the readiness signal.
111
+ */
112
+ export async function launchAttempt(options: LaunchAttemptOptions): Promise<LaunchAttemptResult> {
113
+ const platform = options.platform ?? process.platform;
114
+ const profile = throwawayProfile();
115
+ const profileDir = profile.path;
116
+ const began = performance.now();
117
+ let child: ReturnType<typeof spawnBrowser>;
118
+ try {
119
+ child = spawnBrowser(options, profileDir, platform);
120
+ } catch (error) {
121
+ // A binary that cannot be started at all — not there, not executable, not an executable FORMAT
122
+ // this OS runs (a `#!` script on Windows). Bun throws from `spawn`, synchronously, and that
123
+ // throw escaped as a bare ENOENT where every other way a launch fails is X_CDP_LAUNCH_FAILED.
124
+ await profile.remove(undefined, CLOSE_GRACE_MS);
125
+ return {
126
+ ok: false,
127
+ failure: {
128
+ why: 'spawn',
129
+ waitedMs: 0,
130
+ exitCode: null,
131
+ stderr: '',
132
+ detail: renderThrowable(error),
133
+ },
134
+ reaped: true,
135
+ };
136
+ }
137
+ const tail = stderrTail(child.stderr as ReadableStream<Uint8Array>);
138
+ let connection: CdpConnection | undefined =
139
+ options.wire === 'pipe' ? pipeConnection(child.stdio, options.timeoutMs) : undefined;
140
+ let exiting: Promise<void> | undefined;
141
+ const close = (): Promise<void> => {
142
+ exiting ??= (async () => {
143
+ // Read while the browser still runs: a clean exit unlinks it, a killed one never does.
144
+ const singleton = profile.singletonDir();
145
+ connection?.close();
146
+ if (platform === 'win32') {
147
+ // No SIGTERM to grant grace to: a kill on Windows is TerminateProcess, and taking the
148
+ // browser alone would orphan the children that hold the profile's files open.
149
+ await killTree(child.pid, platform);
150
+ await within(CLOSE_GRACE_MS, child.exited);
151
+ } else {
152
+ child.kill();
153
+ const exited =
154
+ (await within(
155
+ CLOSE_GRACE_MS,
156
+ child.exited.then(() => true),
157
+ )) === true;
158
+ if (!exited) {
159
+ child.kill('SIGKILL');
160
+ await within(CLOSE_GRACE_MS, child.exited);
161
+ }
162
+ await killTree(child.pid, platform);
163
+ }
164
+ // Only now, with the tree gone: removed under a live browser, the profile is re-created by
165
+ // its shutdown — the network process flushes its cache, the browser writes its prefs — and
166
+ // a close cut short there leaves exactly that tree. Measured on Chrome 150, 30 launches
167
+ // each: left behind 6 and 12 times without the group reap, 0 with.
168
+ await profile.remove(singleton, CLOSE_GRACE_MS);
169
+ })();
170
+ return exiting;
171
+ };
172
+ try {
173
+ // The pipe needs no wait to exist; the port is not known until the browser writes it down, and
174
+ // finding it spends the same launch deadline the first answer does.
175
+ connection ??= await portConnection({
176
+ profileDir,
177
+ exited: () => child.exitCode !== null || child.signalCode !== null,
178
+ deadlineMs: options.launchTimeoutMs,
179
+ timeoutMs: options.timeoutMs,
180
+ });
181
+ const left = Math.max(1, options.launchTimeoutMs - (performance.now() - began));
182
+ await connection.send('Browser.getVersion', {}, undefined, left);
183
+ return { ok: true, browser: { connection, close } };
184
+ } catch (error) {
185
+ const why = error instanceof CdpTimeoutError ? 'deadline' : 'closed';
186
+ // The deadline itself when that is what ended it: the number the reader can raise.
187
+ const waitedMs =
188
+ why === 'deadline' ? options.launchTimeoutMs : Math.round(performance.now() - began);
189
+ // Only a browser that hung up may be on its way out with the reason still unwritten. One that
190
+ // was silent for the whole deadline has had its time.
191
+ if (why === 'closed') await within(FAILURE_DRAIN_MS, child.exited);
192
+ // Read BEFORE the kill: after it, an exit code is ours and says nothing about the browser.
193
+ const exitCode = child.exitCode;
194
+ await close();
195
+ await within(FAILURE_DRAIN_MS, tail.drained);
196
+ return {
197
+ ok: false,
198
+ // `detail`: the error itself. Without it a refused dial after "DevTools listening" read as a
199
+ // browser that hung up (seen on windows), and nothing said which step had failed.
200
+ failure: {
201
+ why,
202
+ waitedMs,
203
+ exitCode,
204
+ stderr: tail.lastLines(),
205
+ detail: renderThrowable(error),
206
+ },
207
+ reaped: child.exitCode !== null || child.signalCode !== null,
208
+ };
209
+ }
210
+ }
211
+
212
+ /**
213
+ * The process, on its own process GROUP on POSIX so the zygote, GPU and network processes are
214
+ * reaped with it (`killTree`). It still ends with this process on the pipe wire: Chrome exits when
215
+ * its fd 3 closes. Windows has no group to make — `detached` there is a console flag — so not.
216
+ */
217
+ function spawnBrowser(options: LaunchAttemptOptions, profileDir: string, platform: string) {
218
+ // Spread into a fresh tuple: Bun's `stdio` type takes a mutable tuple, never a readonly one.
219
+ const [stdin, stdout, stderr, ...extra] = wireStdio(options.wire);
220
+ return Bun.spawn([options.executable, ...options.flags(profileDir)], {
221
+ stdio: [stdin, stdout, stderr, ...extra],
222
+ detached: platform !== 'win32',
223
+ });
224
+ }
@@ -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,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 `PROFILE_SETTLE_MS`, or at `deadlineMs`. Never throws:
45
+ * a directory that will not go is litter, never the close's verdict.
46
+ */
47
+ remove(singleton: string | undefined, deadlineMs: 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) {
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(PROFILE_SETTLE_MS / 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(PROFILE_SETTLE_MS);
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
+ }
@@ -0,0 +1,171 @@
1
+ // One responsibility: the WIRE a launched Chrome is driven over, per platform — its debugging pipe
2
+ // (fds 3 and 4) where the parent can hold those fds, else a loopback port the browser picks and
3
+ // announces in its profile's `DevToolsActivePort`. Spawning and reaping are `cdp-launch-attempt.ts`.
4
+
5
+ // why: Bun exposes no path-join primitive; the announcement file lives in the profile directory.
6
+ import { join } from 'node:path';
7
+ import { renderThrowable, stringField } from '@ultimat3/core';
8
+ import type { CdpConnection } from './cdp-connection';
9
+ import { cdpConnect, cdpConnectOver } from './cdp-connection';
10
+ import { CdpCallFailedError, CdpTimeoutError } from './cdp-errors';
11
+ import { pipeTransport } from './cdp-pipe';
12
+
13
+ /**
14
+ * `pipe` everywhere Bun hands the parent the child's extra fds as descriptors, which is POSIX: no
15
+ * port, so two suites on one machine never collide and nothing else can drive the browser.
16
+ * `port` on Windows, where those fds are not descriptors `Bun.file` can open: Chrome binds a
17
+ * loopback port of its own choosing (`=0`, so never a collision either) and writes it down.
18
+ */
19
+ export type CdpWire = 'pipe' | 'port';
20
+
21
+ export const defaultWire = (platform: string): CdpWire => (platform === 'win32' ? 'port' : 'pipe');
22
+
23
+ /** The one flag that differs between the two wires. */
24
+ export const wireFlag = (wire: CdpWire): string =>
25
+ wire === 'pipe' ? '--remote-debugging-pipe' : '--remote-debugging-port=0';
26
+
27
+ /** Chrome's fd 3 is where it READS commands and fd 4 where it WRITES replies — pipe wire only. */
28
+ const PIPE_STDIO = ['ignore', 'ignore', 'pipe', 'pipe', 'pipe'] as const;
29
+ const PORT_STDIO = ['ignore', 'ignore', 'pipe'] as const;
30
+ export const wireStdio = (wire: CdpWire): typeof PIPE_STDIO | typeof PORT_STDIO =>
31
+ wire === 'pipe' ? PIPE_STDIO : PORT_STDIO;
32
+
33
+ /** The pipe wire over a spawned child's fds 3 and 4. Answers nothing until the first `send`. */
34
+ export function pipeConnection(stdio: unknown, timeoutMs: number): CdpConnection {
35
+ const [, , , toBrowser, fromBrowser] = stdio as readonly number[];
36
+ const sink = Bun.file(toBrowser ?? -1).writer();
37
+ return cdpConnectOver(
38
+ pipeTransport({
39
+ write: (bytes) => {
40
+ sink.write(bytes);
41
+ void sink.flush();
42
+ },
43
+ read: Bun.file(fromBrowser ?? -1).stream(),
44
+ end: () => {
45
+ void Promise.resolve(sink.end()).catch(() => undefined);
46
+ },
47
+ }),
48
+ timeoutMs,
49
+ );
50
+ }
51
+
52
+ export const DEVTOOLS_ACTIVE_PORT = 'DevToolsActivePort';
53
+
54
+ /**
55
+ * `<port>\n/devtools/browser/<id>\n` → the browser's WebSocket url, or `undefined` while the file is
56
+ * still half-written. Loopback by address, never `localhost`, which may resolve to `::1` first
57
+ * while Chrome listens on IPv4.
58
+ */
59
+ export function devToolsEndpoint(text: string): string | undefined {
60
+ const [port, path] = text.split(/\r?\n/);
61
+ if (port === undefined || !/^\d{1,5}$/.test(port) || Number(port) < 1 || Number(port) > 65_535) {
62
+ return undefined;
63
+ }
64
+ if (path === undefined || !/^\/devtools\/browser\/[\w-]+$/.test(path)) return undefined;
65
+ return `ws://127.0.0.1:${port}${path}`;
66
+ }
67
+
68
+ const ANNOUNCE_POLL_MS = 25;
69
+
70
+ /**
71
+ * Dial, inside what is left of the LAUNCH deadline — `cdpConnect`'s own deadline is the per-call one
72
+ * the connection keeps afterwards, so it cannot also be the launch's. A dial that loses the race
73
+ * and connects later is closed, never left holding a socket nobody reads.
74
+ */
75
+ async function dialWithin(
76
+ endpoint: string,
77
+ leftMs: number,
78
+ timeoutMs: number,
79
+ ): Promise<CdpConnection> {
80
+ const dialing = cdpConnect({ endpoint, timeoutMs });
81
+ let timer: ReturnType<typeof setTimeout> | undefined;
82
+ const late = new Promise<never>((_, reject) => {
83
+ timer = setTimeout(
84
+ () => reject(new CdpTimeoutError({ method: 'connect', timeoutMs: leftMs })),
85
+ leftMs,
86
+ );
87
+ });
88
+ try {
89
+ return await Promise.race([dialing, late]);
90
+ } catch (error) {
91
+ void dialing.then((connection) => connection.close()).catch(() => undefined);
92
+ // A timeout stays one — the launcher reads it as the deadline. Any other refusal names the
93
+ // endpoint it was for: the browser announced it, so the dial is the step that failed.
94
+ if (error instanceof CdpTimeoutError) throw error;
95
+ throw new CdpCallFailedError({
96
+ method: `connect ${endpoint}`,
97
+ detail: renderThrowable(error),
98
+ });
99
+ } finally {
100
+ clearTimeout(timer);
101
+ }
102
+ }
103
+
104
+ export interface PortConnectionInput {
105
+ readonly profileDir: string;
106
+ /** True once the browser process has exited — it will never write the file then. */
107
+ readonly exited: () => boolean;
108
+ /** How long the browser has to announce itself AND accept the connection, from now. */
109
+ readonly deadlineMs: number;
110
+ /** Every call's deadline once connected. */
111
+ readonly timeoutMs: number;
112
+ /**
113
+ * How the announcement is read — `Bun.file(path).text()` unless a test hands a reader that
114
+ * refuses the way Windows does, which no Linux file system can be made to.
115
+ */
116
+ readonly readText?: ((path: string) => Promise<string>) | undefined;
117
+ }
118
+
119
+ /**
120
+ * The read errors that mean "not announced YET", never "the launch failed": `ENOENT`, the file
121
+ * gone between the existence check and the read, and `EBUSY`, which is how Bun reports a Windows
122
+ * sharing violation — Chrome writes `DevToolsActivePort` in place and holds it open without read
123
+ * sharing while it does. Seen on windows-latest (PR #668): "DevTools listening" on stderr, the file
124
+ * present, the read refused, and the launch reported `closed` with the browser alive.
125
+ */
126
+ const NOT_YET = new Set(['ENOENT', 'EBUSY']);
127
+
128
+ const readFile = (path: string): Promise<string> => Bun.file(path).text();
129
+
130
+ /** The announcement's text, or `undefined` while its writer still holds it. */
131
+ async function readAnnouncement(
132
+ path: string,
133
+ read: (path: string) => Promise<string>,
134
+ ): Promise<string | undefined> {
135
+ try {
136
+ return await read(path);
137
+ } catch (error) {
138
+ if (NOT_YET.has(stringField(error, 'code') ?? '')) return undefined;
139
+ throw error;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * The port wire: wait for the profile's `DevToolsActivePort`, then dial it. A browser that exits
145
+ * first is a CLOSED launch and one still silent at the deadline a TIMED-OUT one — the same two
146
+ * answers the pipe gives, so the launcher's failure report reads alike on either wire.
147
+ */
148
+ export async function portConnection(input: PortConnectionInput): Promise<CdpConnection> {
149
+ const path = join(input.profileDir, DEVTOOLS_ACTIVE_PORT);
150
+ const began = performance.now();
151
+ for (;;) {
152
+ // A FRESH handle per look: a `BunFile` that once answered "absent" keeps answering it (measured
153
+ // on Bun 1.4.2 — `exists()` false and `text()` empty after the file was written).
154
+ const text = (await Bun.file(path).exists())
155
+ ? await readAnnouncement(path, input.readText ?? readFile)
156
+ : undefined;
157
+ const endpoint = text === undefined ? undefined : devToolsEndpoint(text);
158
+ const left = input.deadlineMs - (performance.now() - began);
159
+ if (endpoint !== undefined) return dialWithin(endpoint, Math.max(1, left), input.timeoutMs);
160
+ if (input.exited()) {
161
+ throw new CdpCallFailedError({
162
+ method: 'the launch',
163
+ detail: `the browser exited before it wrote ${DEVTOOLS_ACTIVE_PORT}`,
164
+ });
165
+ }
166
+ if (left <= 0) {
167
+ throw new CdpTimeoutError({ method: DEVTOOLS_ACTIVE_PORT, timeoutMs: input.deadlineMs });
168
+ }
169
+ await Bun.sleep(Math.min(ANNOUNCE_POLL_MS, left));
170
+ }
171
+ }