browser-broker 0.1.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 (123) hide show
  1. package/.env.example +173 -0
  2. package/LICENSE +21 -0
  3. package/README.md +374 -0
  4. package/RELEASES.md +97 -0
  5. package/dist/package.json +58 -0
  6. package/dist/src/adapter/conformance/case.js +1 -0
  7. package/dist/src/adapter/conformance/cases.js +429 -0
  8. package/dist/src/adapter/conformance/discovery.js +156 -0
  9. package/dist/src/adapter/conformance/driver.js +1 -0
  10. package/dist/src/adapter/conformance/drivers.js +36 -0
  11. package/dist/src/adapter/conformance/run.js +224 -0
  12. package/dist/src/adapter/conformance/service-subject.js +165 -0
  13. package/dist/src/adapter/contract.js +24 -0
  14. package/dist/src/adapter/operations.js +114 -0
  15. package/dist/src/adapter/service-seam.js +1 -0
  16. package/dist/src/artifacts/names.js +229 -0
  17. package/dist/src/artifacts/store.js +174 -0
  18. package/dist/src/bin/broker-tool.js +63 -0
  19. package/dist/src/bin/broker.js +111 -0
  20. package/dist/src/browser/adoption.js +143 -0
  21. package/dist/src/browser/automation-probe.js +113 -0
  22. package/dist/src/browser/conformance/case.js +1 -0
  23. package/dist/src/browser/conformance/cases.js +192 -0
  24. package/dist/src/browser/conformance/run.js +102 -0
  25. package/dist/src/browser/conformance/subjects.js +19 -0
  26. package/dist/src/browser/discovery.js +226 -0
  27. package/dist/src/browser/driver.js +195 -0
  28. package/dist/src/browser/fake.js +585 -0
  29. package/dist/src/browser/launch.js +504 -0
  30. package/dist/src/browser/real.js +1425 -0
  31. package/dist/src/browser/setup.js +161 -0
  32. package/dist/src/capture/accounting.js +59 -0
  33. package/dist/src/capture/image.js +112 -0
  34. package/dist/src/capture/ladder.js +72 -0
  35. package/dist/src/capture/legibility.js +195 -0
  36. package/dist/src/capture/pipeline.js +153 -0
  37. package/dist/src/capture/tiers.js +166 -0
  38. package/dist/src/cli/adapter.js +233 -0
  39. package/dist/src/cli/commands.js +270 -0
  40. package/dist/src/cli/conformance-driver.js +119 -0
  41. package/dist/src/cli/diffs.js +122 -0
  42. package/dist/src/cli/image.js +274 -0
  43. package/dist/src/cli/index.js +895 -0
  44. package/dist/src/cli/login-command.js +401 -0
  45. package/dist/src/cli/operations-commands.js +186 -0
  46. package/dist/src/cli/reconcile-command.js +137 -0
  47. package/dist/src/cli/sign-in.js +134 -0
  48. package/dist/src/cli/telemetry.js +222 -0
  49. package/dist/src/config/environment.js +446 -0
  50. package/dist/src/diff/artifact-path.js +77 -0
  51. package/dist/src/diff/crops.js +102 -0
  52. package/dist/src/diff/geometry.js +122 -0
  53. package/dist/src/diff/image.js +132 -0
  54. package/dist/src/diff/mask.js +46 -0
  55. package/dist/src/diff/regions.js +263 -0
  56. package/dist/src/diff/settings.js +135 -0
  57. package/dist/src/doctor/checks.js +588 -0
  58. package/dist/src/doctor/report.js +152 -0
  59. package/dist/src/doctor/session.js +161 -0
  60. package/dist/src/errors.js +36 -0
  61. package/dist/src/feedback/read.js +119 -0
  62. package/dist/src/feedback/record.js +199 -0
  63. package/dist/src/operations/addresses.js +175 -0
  64. package/dist/src/operations/derive.js +109 -0
  65. package/dist/src/operations/ledger.js +194 -0
  66. package/dist/src/operations/status.js +197 -0
  67. package/dist/src/operations/telemetry.js +280 -0
  68. package/dist/src/report/document.js +419 -0
  69. package/dist/src/report/escape.js +68 -0
  70. package/dist/src/report/snapshot.js +97 -0
  71. package/dist/src/service/arbitration.js +537 -0
  72. package/dist/src/service/artifacts.js +85 -0
  73. package/dist/src/service/bridge.js +577 -0
  74. package/dist/src/service/broker.js +120 -0
  75. package/dist/src/service/browser-session.js +269 -0
  76. package/dist/src/service/capacity.js +62 -0
  77. package/dist/src/service/capture-seam.js +83 -0
  78. package/dist/src/service/capture-store.js +91 -0
  79. package/dist/src/service/comparison-store.js +101 -0
  80. package/dist/src/service/comparison.js +173 -0
  81. package/dist/src/service/events.js +93 -0
  82. package/dist/src/service/keys.js +68 -0
  83. package/dist/src/service/leases.js +147 -0
  84. package/dist/src/service/nudge.js +66 -0
  85. package/dist/src/service/operations/claim.js +692 -0
  86. package/dist/src/service/operations/give-back.js +131 -0
  87. package/dist/src/service/operations/pages.js +771 -0
  88. package/dist/src/service/operations/sign-in.js +915 -0
  89. package/dist/src/service/operations/status.js +62 -0
  90. package/dist/src/service/ownership.js +93 -0
  91. package/dist/src/service/pages.js +616 -0
  92. package/dist/src/service/pending-seeds.js +20 -0
  93. package/dist/src/service/queue.js +233 -0
  94. package/dist/src/service/reconcile.js +220 -0
  95. package/dist/src/service/refusals.js +262 -0
  96. package/dist/src/service/runtime.js +131 -0
  97. package/dist/src/service/signin-recovery.js +148 -0
  98. package/dist/src/service/storage-seed.js +239 -0
  99. package/dist/src/service/tabs.js +123 -0
  100. package/dist/src/store/budget.js +99 -0
  101. package/dist/src/store/location.js +42 -0
  102. package/dist/src/store/network-path.js +182 -0
  103. package/dist/src/store/network-volume.js +92 -0
  104. package/dist/src/store/open.js +226 -0
  105. package/dist/src/store/schema/step-001-initial.js +523 -0
  106. package/dist/src/store/schema/step-002-tab-budget.js +53 -0
  107. package/dist/src/store/schema/step-003-queue-order.js +110 -0
  108. package/dist/src/store/schema/step-004-tab-never-opened.js +100 -0
  109. package/dist/src/store/schema/step-005-storage-seed-event.js +90 -0
  110. package/dist/src/store/schema/step-006-signin-events.js +104 -0
  111. package/dist/src/store/schema/step-007-signin-without-process.js +92 -0
  112. package/dist/src/store/schema/step-008-signin-owner.js +76 -0
  113. package/dist/src/store/schema/step-009-named-browsers.js +138 -0
  114. package/dist/src/store/schema/step-010-signin-request.js +135 -0
  115. package/dist/src/store/schema/step.js +172 -0
  116. package/dist/src/store/schema/steps.js +58 -0
  117. package/dist/src/store/transaction.js +37 -0
  118. package/dist/src/tool/adapter.js +90 -0
  119. package/dist/src/tool/conformance-driver.js +184 -0
  120. package/dist/src/tool/protocol.js +310 -0
  121. package/dist/src/tool/session.js +351 -0
  122. package/dist/src/tool/tools.js +310 -0
  123. package/package.json +58 -0
@@ -0,0 +1,504 @@
1
+ import { spawn } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import { StartupRefusal } from "../errors.js";
4
+ import { readDiscoveryRecord, verifyDiscoveryRecord } from "./discovery.js";
5
+ /**
6
+ * Starting a browser that nothing owns, and proving it actually started.
7
+ *
8
+ * ── Why the binary is spawned directly ──────────────────────────────────
9
+ *
10
+ * `SCHEMA.md` §1.2a, and it is measured rather than preferred. Three things
11
+ * were tried:
12
+ *
13
+ * | What was tried | What happened |
14
+ * |---|---|
15
+ * | Launch through the automation library's own launcher, then close that client | **The browser was killed with it.** The launching call owns what it starts — correct for a test, fatal for a shared browser |
16
+ * | Attach to an already-running browser, then close that client | **The browser was unaffected.** An attaching caller does not own what it did not start |
17
+ * | Spawn the binary **detached**, then kill the spawning process uncleanly | **The browser survived**, healthy and re-attachable for around 90 minutes, pages intact |
18
+ *
19
+ * No process here lives long enough to be a browser's parent — the service is
20
+ * spawned by a caller and exits with it — so a browser that belonged to a
21
+ * process would die with the first caller that finished, taking every other
22
+ * caller's tabs with it. The launcher's ownership is the whole problem and it
23
+ * is not configurable away, so this module reaches for the process module
24
+ * rather than the automation library. That is the `launch.detached` build
25
+ * rule (§7.2), and it is why this file spawns rather than delegates.
26
+ *
27
+ * ── Why success is asserted and never inferred ──────────────────────────
28
+ *
29
+ * **Measured, and worse than a bad error message.** A second browser started
30
+ * against a profile directory already in use does **not** report a lock
31
+ * error. It hands its address to the browser already holding the profile,
32
+ * with nothing on the error stream and **no debugging endpoint of its own
33
+ * ever opened**. A launcher waiting on its own endpoint therefore waits for
34
+ * something that will never appear.
35
+ *
36
+ * **What it does NOT do is exit.** This file asserted for some time that the
37
+ * losing process "exits zero on its own", and that claim was false on the
38
+ * platform this runs on: measured on Windows, the losing `chrome.exe` stays
39
+ * alive indefinitely — `HasExited=False`, `Responding=True`, long after the
40
+ * process that spawned it has gone. See the kill-condition table below for
41
+ * what is done about it, and §13k for why the correction did not reverse the
42
+ * rule it was attached to.
43
+ *
44
+ * And the obvious cross-platform check does not work: the single-instance
45
+ * lock file a POSIX system leaves in a profile directory **does not exist on
46
+ * Windows**, so a check looking for it does not report *no lock* there — it
47
+ * **always passes**. A guard that cannot fail on one platform is worse than
48
+ * no guard, because it is trusted equally on both. So there is no lock-file
49
+ * check here, deliberately.
50
+ *
51
+ * What establishes success instead is a **positive assertion**: an endpoint
52
+ * exists, answers, and identifies itself as the browser this launch produced.
53
+ * Anything else is a failure, including a launch whose command exited
54
+ * zero.
55
+ *
56
+ * ── What happens to the process when the assertion fails ────────────────
57
+ *
58
+ * **Measured, 1,637 browser processes across 164 launches, and a frozen
59
+ * machine.** A detached spawn whose endpoint never answered was thrown away
60
+ * without being killed, on every failure path, and each one stayed alive: the
61
+ * table at the top of this file records that a detached browser survives the
62
+ * death of the process that spawned it by design, which is exactly why it
63
+ * cannot be left behind by accident.
64
+ *
65
+ * The rule that made this easy to miss is a real one and it is kept:
66
+ * **browsers are adopted, not owned, and a browser outlives every process
67
+ * that touched it.** There is deliberately no close-browser operation on the
68
+ * seam (`browser_scoped.never`, §7.3).
69
+ *
70
+ * That rule governs a browser that was **successfully launched and adopted**.
71
+ * A browser whose endpoint never answered **was adopted by nobody**: nothing
72
+ * holds a reference to it, no row names it, and the lazy global sweep
73
+ * reconciles claims and tabs rather than orphaned operating-system processes.
74
+ * It is unreachable by every reclamation path this design has, so if this
75
+ * function does not end it here, nothing ever will. Cleaning up a launch that
76
+ * failed is not owning a browser — there is no browser, only a process that
77
+ * never became one.
78
+ *
79
+ * ── The kill-condition table: which failure paths kill, and which must not ──
80
+ *
81
+ * | Failure path | Kills? | Why |
82
+ * |---|---|---|
83
+ * | **No process identifier assigned** (the spawn was refused synchronously) | **No** | There is no process. Nothing was started, so there is nothing to end and no identifier to aim at |
84
+ * | **Profile collision** — an endpoint answered and it is the *previous* record | **Yes — the spawned identifier, and NEVER what holds the profile** | Two different processes, and the distinction is the whole of this row. The browser **behind the answering endpoint** belongs to **somebody else** and is quite possibly the signed-in one carrying the shared sign-in: killing *that* destroys another caller's browser and the very thing the keeper tab exists to protect, so it is never done, on any path. But the process **this call spawned** is not that browser — it lost the race, opened no endpoint, was adopted by nobody, and (measured on Windows) **does not exit on its own**. It is reached only through the identifier the spawn returned, so ending it is the same narrow act as the rows below, not the forbidden one |
85
+ * | **Spawn failure reported *during* the readiness loop** (the early-report race) | **Yes** | Same process, same reasoning — it just leaves the function from the race rather than from a `throw`. Called out separately because it is the **fast** path: a machine whose browser binary is broken takes this one every time, so a version that only cleaned up the two written `throw`s would still leak on the most common failure |
86
+ * | **Spawn failure reported after the readiness loop** | **Yes** | An identifier was assigned before the failure arrived, so a process may exist. The failure says the browser could not start, so whatever is under that identifier never became an adoptable browser |
87
+ * | **Readiness timeout** — spawned, but no endpoint of its own ever answered | **Yes** | This is the path that produced the incident. The process is alive and is the one this call started, and it is reachable by nothing else |
88
+ *
89
+ * The kill is therefore **narrow by construction**: only a process that (a)
90
+ * this call spawned, and (b) never became adoptable. It is aimed at the
91
+ * identifier the spawn returned and never at a profile directory, a browser
92
+ * that answered, or an image name.
93
+ *
94
+ * **That construction is what makes the collision row safe, and it is the
95
+ * only thing that does.** "Kill the browser on this profile" and "kill the
96
+ * identifier this call spawned" read as near-synonyms on the collision path
97
+ * and are opposite acts: on that path the profile is held by somebody else's
98
+ * browser, so the first is the exact damage the keeper tab exists to prevent.
99
+ * Measured: with the losing identifier signalled, the browser behind the
100
+ * answering endpoint keeps serving `/json/version`. An implementation that
101
+ * matched by profile directory or by image name would pass the same test
102
+ * suite and do that damage in production.
103
+ *
104
+ * **The residual risk, named rather than left implicit:** an identifier can in
105
+ * principle be reused by the operating system between the spawn and the
106
+ * signal, in which case this signals an unrelated process. That risk is real,
107
+ * is identical on all four killing rows, and is not specific to the collision
108
+ * path — it is the price of holding an identifier at all, and it is bounded by
109
+ * the swallow below.
110
+ *
111
+ * And it is **best effort**, because it is cleanup on a path that is already
112
+ * reporting a refusal. An identifier that has already exited, one the
113
+ * operating system will not let this process signal, and one that was never
114
+ * assigned are all ordinary states here, and none of them is worse than the
115
+ * refusal already being raised. So a failure to kill is swallowed and the
116
+ * original refusal travels unchanged: turning a clean, explanatory refusal
117
+ * into a crash from its own cleanup would be a worse bug than the leak.
118
+ */
119
+ /** The refusals this module raises, spelled as `SCHEMA.md` §7.2 spells them. */
120
+ export const LAUNCH_RULES = {
121
+ explicitProfileDir: 'launch.explicit_profile_dir',
122
+ detached: 'launch.detached',
123
+ defaultArgsIntact: 'launch.default_args_intact',
124
+ captureSurface: 'launch.capture_surface',
125
+ };
126
+ /**
127
+ * The switches that keep a background tab rendering, which is what
128
+ * `launch.capture_surface` (§7.2) is actually about.
129
+ *
130
+ * ── What was measured, because it corrects the obvious reading ──────────
131
+ *
132
+ * The rule says the browser must be launched "with the setting that makes
133
+ * screenshots capture the right tab", and the obvious reading is that some
134
+ * single flag selects a capture surface. **Measured, on this browser and this
135
+ * library: that is not where the property comes from.** A background tab was
136
+ * screenshotted in both a headed and a headless browser while a *different*
137
+ * tab was in front, and both returned the background tab's own pixels
138
+ * correctly. The library captures per target over the debugging protocol
139
+ * rather than reading the window's surface, so it does not photograph
140
+ * whatever happens to be in front.
141
+ *
142
+ * **So the failure the rule describes is not prevented by a capture flag —
143
+ * it is prevented by the tab still rendering.** A browser is free to throttle
144
+ * or stop compositing a window it believes nobody is looking at, and a tab
145
+ * that has stopped compositing is the one that yields a stale or empty frame.
146
+ * These switches are what keep that from happening, and that is why they are
147
+ * additive settings rather than a mode.
148
+ *
149
+ * This is stated at length because the honest version matters more than the
150
+ * tidy one: a constant named for a capture surface, holding a headless flag,
151
+ * would have made `launch.capture_surface` look satisfied on the headed
152
+ * browser — the **only** browser where the failure it names can occur — while
153
+ * doing nothing about it. The rule is kept, and pointed at the thing that
154
+ * actually carries it.
155
+ */
156
+ export const CAPTURE_SURFACE_ARGUMENTS = [
157
+ // Keep rendering a window the browser thinks is covered.
158
+ '--disable-backgrounding-occluded-windows',
159
+ // Do not demote a background tab's renderer priority.
160
+ '--disable-renderer-backgrounding',
161
+ // Do not throttle timers and rasterisation in background tabs.
162
+ '--disable-background-timer-throttling',
163
+ ];
164
+ /**
165
+ * What this service adds to the automation library's defaults.
166
+ *
167
+ * `launch.default_args_intact` (§7.2): the launch settings are the library's
168
+ * defaults **plus** what this service adds, and **never its defaults minus
169
+ * anything**. Those defaults include what keeps background tabs running at
170
+ * full speed and what makes capturing them work; removing them is how a
171
+ * service becomes mysteriously slow and mysteriously wrong at once.
172
+ *
173
+ * So this list is strictly additive and {@link assertDefaultArgsIntact}
174
+ * refuses a launch that tries to subtract. Each entry earns its place:
175
+ *
176
+ * - The **profile directory**, which is mandatory and is the browser's
177
+ * identity (§1.2a). Written by the caller, never defaulted.
178
+ * - The **debugging port, unspecified**, so the operating system assigns a
179
+ * free one and the browser records it (§1.2c).
180
+ * - **No first-run and no default-browser check**, because a first-run
181
+ * interstitial is a page nobody asked for occupying the browser this
182
+ * service is about to count tabs in.
183
+ */
184
+ export const ADDED_ARGUMENT_PREFIXES = [
185
+ '--user-data-dir=',
186
+ '--remote-debugging-port=',
187
+ '--no-first-run',
188
+ '--no-default-browser-check',
189
+ ...CAPTURE_SURFACE_ARGUMENTS,
190
+ ];
191
+ /**
192
+ * Arguments that would remove a default, matched as **shapes**.
193
+ *
194
+ * `launch.default_args_intact` protects the library's defaults, and the way a
195
+ * caller subtracts from them is a switch that turns one off. This is a shape
196
+ * list rather than an exhaustive one, and the limit is stated rather than
197
+ * implied: it catches the disable-shaped and clear-the-features-shaped
198
+ * switches, which are the forms a subtraction actually takes, and it cannot
199
+ * catch a switch that subtracts a default without looking like either.
200
+ *
201
+ * **The awkward part, kept rather than hidden.** The capture-surface switches
202
+ * above are themselves disable-shaped, because the thing that has to be
203
+ * turned off is the browser's own backgrounding behaviour. So the shape alone
204
+ * cannot separate *this service adding a setting it needs* from *a caller
205
+ * stripping a default*, and a check that matched on shape alone would refuse
206
+ * the service's own launch.
207
+ *
208
+ * The distinction that does hold is **who is adding it**: the service's own
209
+ * additions are a fixed, reviewed list in this file, and the extras are
210
+ * whatever a caller passed. So the check runs against the caller's extras and
211
+ * exempts the service's own set by exact membership rather than by shape.
212
+ * That is a narrower claim than "no default is ever removed", and it is the
213
+ * one this code can actually make good on.
214
+ */
215
+ const SUBTRACTIVE_ARGUMENT_SHAPES = [/^--disable-/, /^--no-sandbox$/];
216
+ /**
217
+ * Refuse a profile directory that is absent, empty or not a real path.
218
+ *
219
+ * A value can be **present and still be wrong**, which is why this is a
220
+ * run-time refusal and not only a type. The type makes omitting it impossible;
221
+ * this makes an empty string impossible.
222
+ */
223
+ export function assertExplicitProfileDirectory(profileDirectory) {
224
+ if (profileDirectory.trim() === '') {
225
+ throw new StartupRefusal(LAUNCH_RULES.explicitProfileDir, 'A browser may not be launched without an explicit profile directory. A default profile location is shared with anything else that takes the default, and with browsers adopted rather than owned the directory is the only thing that says which browser this is.');
226
+ }
227
+ }
228
+ /**
229
+ * Refuse a launch whose extra arguments subtract from the library's defaults.
230
+ *
231
+ * This is `launch.default_args_intact` at the only place it can be enforced
232
+ * at run time: the point where arguments are assembled.
233
+ */
234
+ export function assertDefaultArgsIntact(extraArguments) {
235
+ for (const argument of extraArguments) {
236
+ // The service's own capture-surface switches are disable-shaped by
237
+ // necessity, so they are exempted by exact membership rather than by
238
+ // shape. See the note on the shape list for why that is the honest
239
+ // boundary and what it does not claim.
240
+ if (CAPTURE_SURFACE_ARGUMENTS.includes(argument)) {
241
+ continue;
242
+ }
243
+ for (const shape of SUBTRACTIVE_ARGUMENT_SHAPES) {
244
+ if (shape.test(argument)) {
245
+ throw new StartupRefusal(LAUNCH_RULES.defaultArgsIntact, `The launch argument ${argument} removes a default rather than adding to one. The launch settings are the automation library's defaults plus what this service adds, never its defaults minus anything: those defaults include what keeps background tabs running at full speed and what makes capturing them work.`);
246
+ }
247
+ }
248
+ }
249
+ }
250
+ /**
251
+ * Assemble the command line: the additive set, then the mode, then extras.
252
+ *
253
+ * Exported so a test can assert the shape of what is spawned by reading it,
254
+ * rather than by trusting a comment about it — the build rules this row ships
255
+ * are claims about arguments, and a claim about arguments is checkable only
256
+ * where the arguments are.
257
+ */
258
+ export function launchArguments(request) {
259
+ assertExplicitProfileDirectory(request.profileDirectory);
260
+ const extras = request.extraArguments ?? [];
261
+ assertDefaultArgsIntact(extras);
262
+ const args = [
263
+ `--user-data-dir=${request.profileDirectory}`,
264
+ // Unspecified, so the operating system assigns a free one and the browser
265
+ // records what it chose (§1.2c). A fixed port is a guess about what else
266
+ // is running on the host.
267
+ '--remote-debugging-port=0',
268
+ '--no-first-run',
269
+ '--no-default-browser-check',
270
+ // On every launch, in both modes. The failure `launch.capture_surface`
271
+ // names — a capture returning something other than the tab that was asked
272
+ // for — is a background tab that stopped rendering, and that is a risk in
273
+ // the headed browser specifically. Applied to both because a setting that
274
+ // is only present in the mode where it is not needed is a setting nobody
275
+ // has tested in the mode where it is.
276
+ ...CAPTURE_SURFACE_ARGUMENTS,
277
+ ];
278
+ if (request.mode === 'headless') {
279
+ args.push('--headless=new');
280
+ }
281
+ args.push(...extras);
282
+ // A blank page to start on, so the browser has a tab and the keeper tab
283
+ // (#56) has something to be established against.
284
+ args.push('about:blank');
285
+ return args;
286
+ }
287
+ /**
288
+ * End a process this launch spawned that never became an adoptable browser.
289
+ *
290
+ * **Best effort, and silent about its own failures on purpose.** Every caller
291
+ * is on a path that is already raising a refusal, and every plausible failure
292
+ * here — the process exited on its own between the check and the signal, the
293
+ * operating system refuses this process permission to signal it, no
294
+ * identifier was ever assigned — means either *there is nothing left to kill*
295
+ * or *this process cannot do anything about it*. Neither is news the caller
296
+ * can act on, and neither is worth losing the refusal over: it explains what
297
+ * actually went wrong, and a crash raised by the cleanup would not.
298
+ *
299
+ * See the kill-condition table in the module header for **which** paths call
300
+ * this and, more importantly, which one must not.
301
+ *
302
+ * **The identifier is a `number`, not an optional one, deliberately.** The
303
+ * no-identifier case is refused before the readiness loop begins, so every
304
+ * caller here has already been narrowed to a definite identifier. Accepting
305
+ * an optional one and returning early on `undefined` would look more careful
306
+ * and would in fact be *less* safe: the branch is unreachable, so nothing can
307
+ * test it, and an unreachable branch is exactly the shape that quietly starts
308
+ * swallowing a real identifier when a later caller is added. The type is what
309
+ * enforces the table's first row instead.
310
+ */
311
+ function killSpawnedProcess(pid, killImpl) {
312
+ try {
313
+ killImpl(pid);
314
+ }
315
+ catch {
316
+ // Already gone, or not ours to signal. Both are fine here: the refusal
317
+ // this cleanup runs alongside is the thing the caller needs, and it must
318
+ // reach them unchanged.
319
+ }
320
+ }
321
+ /** Signal a process by identifier, and only by identifier. */
322
+ function defaultKill(pid) {
323
+ // By process identifier, never by image name: this must end the one process
324
+ // this call spawned, and a name matches every browser on the machine
325
+ // including other callers' adopted ones.
326
+ process.kill(pid);
327
+ }
328
+ /**
329
+ * How long a cold start waits for its endpoint before declaring failure.
330
+ *
331
+ * **This is a bound, not the answer to §1.2b.** Row #55 owns what a caller
332
+ * that *lost* the launch race waits for and for how long, and that question
333
+ * is recorded as genuinely open. This number is the narrower one this row
334
+ * cannot ship without: the process this call itself spawned either produces
335
+ * an endpoint or it did not start, and without a bound the silent-collision
336
+ * case — no endpoint, ever — hangs forever rather than reporting.
337
+ */
338
+ export const READINESS_TIMEOUT_MS = 30_000;
339
+ export const POLL_INTERVAL_MS = 100;
340
+ /**
341
+ * Spawn the browser detached and **assert** that it came up.
342
+ *
343
+ * The spawn is detached and its streams are released so the browser is not
344
+ * tied to this process's lifetime in either direction: a detached child with
345
+ * inherited pipes still dies with its parent on some platforms, which would
346
+ * reintroduce the exact ownership this arrangement exists to remove.
347
+ */
348
+ export async function coldStartDetached(request, options = {}) {
349
+ assertExplicitProfileDirectory(request.profileDirectory);
350
+ const timeoutMs = options.readinessTimeoutMs ?? READINESS_TIMEOUT_MS;
351
+ const pollMs = options.pollIntervalMs ?? POLL_INTERVAL_MS;
352
+ const spawnImpl = options.spawnImpl ?? spawn;
353
+ const killImpl = options.killImpl ?? defaultKill;
354
+ const args = launchArguments(request);
355
+ // The profile directory has to exist for the browser to write its record
356
+ // into it. Created, never cleared — `setup.profile_never_destroyed` (§7.2).
357
+ fs.mkdirSync(request.profileDirectory, { recursive: true });
358
+ // Any record from a previous browser is a claim about a browser that is not
359
+ // this one. It is read and remembered rather than deleted, because deleting
360
+ // it would destroy the only evidence that something else may already hold
361
+ // this profile — which is the collision case below.
362
+ const previous = readDiscoveryRecord(request.profileDirectory);
363
+ const child = spawnImpl(request.executablePath, [...args], {
364
+ // The whole point of this module. See the header: the library's launcher
365
+ // owns what it starts, and this must not be owned.
366
+ detached: true,
367
+ // Released rather than inherited, so nothing ties the browser's lifetime
368
+ // to this process's streams.
369
+ stdio: 'ignore',
370
+ });
371
+ // ── A spawn that fails does so ASYNCHRONOUSLY, and unhandled it is fatal ──
372
+ //
373
+ // **Measured, on a machine with no browser installed:** spawning a path that
374
+ // does not exist **still returns a child and still assigns a process
375
+ // identifier**, and reports the failure moments later by emitting `error` on
376
+ // the child. With nothing listening, that is an unhandled error event, which
377
+ // does not reject this promise — it **ends the process**, escaping every
378
+ // `catch` between here and the caller.
379
+ //
380
+ // That is the worst available shape for this particular failure. A browser
381
+ // that is not installed is an ordinary state (§2.4b's after-commit work is
382
+ // best effort precisely so it can be), and the caller's own handling turns
383
+ // it into an honest `pageDriven: false`. An unhandled event instead takes
384
+ // the whole process down, so a page verb against a machine with no browser
385
+ // would kill the service rather than answer.
386
+ //
387
+ // A listener is therefore attached **before the first await**, and it turns
388
+ // the event into a rejection this function's caller can handle like any
389
+ // other refusal. `pid` being assigned is why the check below is not
390
+ // sufficient on its own.
391
+ let spawnFailure;
392
+ const failed = new Promise((_resolve, reject) => {
393
+ child.once('error', (error) => {
394
+ spawnFailure = error;
395
+ reject(new StartupRefusal(LAUNCH_RULES.detached, `The browser could not be started: ${error.message}. Nothing was launched, so there is nothing to attach to. This is the ordinary state of a machine with no browser installed.`, { cause: error }));
396
+ });
397
+ });
398
+ // Nothing waits on this promise unless it rejects, and an unobserved
399
+ // rejection is itself a process-level warning — so it is given a handler
400
+ // that does nothing, and the rejection is observed by the races below.
401
+ failed.catch(() => undefined);
402
+ // Let this process exit without waiting for the browser it started.
403
+ child.unref();
404
+ const pid = child.pid;
405
+ if (pid === undefined) {
406
+ throw new StartupRefusal(LAUNCH_RULES.detached, 'The browser process could not be spawned, so no process identifier was assigned. Nothing was started and nothing is attachable.');
407
+ }
408
+ const deadline = Date.now() + timeoutMs;
409
+ let lastDetail = 'the record never appeared';
410
+ while (Date.now() < deadline) {
411
+ const current = readDiscoveryRecord(request.profileDirectory);
412
+ if (current !== undefined) {
413
+ const isPrevious = previous !== undefined &&
414
+ current.record.endpoint === previous.record.endpoint &&
415
+ current.expectedUuid === previous.expectedUuid;
416
+ const outcome = await verifyDiscoveryRecord(current.record, current.expectedUuid, {
417
+ fetchImpl: options.fetchImpl,
418
+ });
419
+ if (outcome.ok) {
420
+ if (isPrevious) {
421
+ // The measured silent-collision case, and the reason this branch
422
+ // is not simply "an endpoint answered, so we are done": the record
423
+ // is the one that was already there, belonging to a browser this
424
+ // call did not start. The spawned process handed its address to
425
+ // the browser already holding the profile. Serving this would mean
426
+ // reporting a launch that did not happen and recording a process
427
+ // identifier that is not the browser's.
428
+ //
429
+ // ── TWO PROCESSES HERE, AND ONLY ONE OF THEM MAY BE ENDED ───────
430
+ //
431
+ // **This path is the one that matters most to get right**, because
432
+ // the two processes involved are easy to conflate and the wrong
433
+ // one is somebody else's browser.
434
+ //
435
+ // What is alive and **must never be touched** is the browser behind
436
+ // the endpoint that just answered: another caller's adopted
437
+ // browser, quite possibly the signed-in one whose keeper tab holds
438
+ // the shared sign-in open. Nothing on any path in this file signals
439
+ // it. It is reached only through the discovery record, and the
440
+ // record is read here, never acted on destructively.
441
+ //
442
+ // What is *also* alive is **the process this call spawned**. It
443
+ // does not exit on its own: measured on Windows it stays alive
444
+ // indefinitely (`HasExited=False`, `Responding=True`) long after
445
+ // this process is gone — a collision is the losing process handing
446
+ // its address over, not the losing process ending. It opened no
447
+ // endpoint of its own, no row names it, and the lazy global sweep
448
+ // reconciles claims and tabs rather than orphaned operating-system
449
+ // processes — so, exactly as on the readiness-timeout path below,
450
+ // if this function does not end it here nothing ever will.
451
+ //
452
+ // So it is ended, **by the identifier the spawn returned and by
453
+ // nothing else**. That is not the forbidden act: an implementation
454
+ // that killed "the browser on this profile" would read as
455
+ // symmetrical and would destroy the sign-in. Measured: with this
456
+ // identifier signalled, the browser behind the answering endpoint
457
+ // keeps serving. See §13k.
458
+ killSpawnedProcess(pid, killImpl);
459
+ throw new StartupRefusal(LAUNCH_RULES.explicitProfileDir, 'A browser is already running against this profile directory. The launch opened no endpoint of its own and reported no error, which is what a profile collision looks like: the second process hands its address to the first. That losing process has been ended; the browser already holding the profile was left untouched. Attach to the running browser instead of starting a second one.');
460
+ }
461
+ return { pid, record: outcome.record };
462
+ }
463
+ lastDetail = outcome.detail;
464
+ }
465
+ // Raced against the spawn failure, so a browser that could not start is
466
+ // reported the moment it says so rather than after the readiness timeout
467
+ // has elapsed. Waiting out the full timeout would turn *no browser
468
+ // installed* — an instant, knowable answer — into the slowest path here.
469
+ //
470
+ // The rejection is caught **only** to run the cleanup, and is then
471
+ // re-thrown exactly as it was. This is a fourth way out of this function
472
+ // and it is easy to miss when reading for `throw`, because the refusal is
473
+ // constructed far above and escapes from here without one: a spawn
474
+ // failure arriving *during* the loop leaves by this line rather than by
475
+ // the check below it. Without this, the early-report optimisation would
476
+ // be the one path that still leaked — and it is the fast path, so it is
477
+ // the one a machine with a broken browser binary would take every time.
478
+ try {
479
+ await Promise.race([new Promise((resolve) => setTimeout(resolve, pollMs)), failed]);
480
+ }
481
+ catch (error) {
482
+ killSpawnedProcess(pid, killImpl);
483
+ throw error;
484
+ }
485
+ }
486
+ if (spawnFailure !== undefined) {
487
+ // An identifier was assigned before the failure arrived — that is the
488
+ // measured shape of an asynchronous spawn failure — so a process may
489
+ // exist under it, and whatever it is, it never became a browser anything
490
+ // could adopt. Best effort, so the refusal below is what the caller sees.
491
+ killSpawnedProcess(pid, killImpl);
492
+ throw new StartupRefusal(LAUNCH_RULES.detached, `The browser could not be started: ${spawnFailure.message}. Nothing was launched, so there is nothing to attach to.`, { cause: spawnFailure });
493
+ }
494
+ // ── The path that produced the incident ────────────────────────────────
495
+ //
496
+ // Spawned, alive, and no endpoint of its own ever answered. Nothing holds a
497
+ // reference to this process, no row names it, and the sweep that reclaims
498
+ // abandoned work reconciles claims and tabs rather than orphaned processes
499
+ // — so this function is the only thing that will ever end it. Left alone it
500
+ // accumulates one browser per failed launch, which is how 164 launches
501
+ // became 1,637 processes and a machine that stopped responding.
502
+ killSpawnedProcess(pid, killImpl);
503
+ throw new StartupRefusal(LAUNCH_RULES.explicitProfileDir, `The browser was spawned but no debugging endpoint of its own ever answered (${lastDetail}). A launch is never inferred from the command exiting: a browser started against a profile directory already in use opens no endpoint and reports nothing, whether or not its process is still alive.`);
504
+ }