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.
- package/.env.example +173 -0
- package/LICENSE +21 -0
- package/README.md +374 -0
- package/RELEASES.md +97 -0
- package/dist/package.json +58 -0
- package/dist/src/adapter/conformance/case.js +1 -0
- package/dist/src/adapter/conformance/cases.js +429 -0
- package/dist/src/adapter/conformance/discovery.js +156 -0
- package/dist/src/adapter/conformance/driver.js +1 -0
- package/dist/src/adapter/conformance/drivers.js +36 -0
- package/dist/src/adapter/conformance/run.js +224 -0
- package/dist/src/adapter/conformance/service-subject.js +165 -0
- package/dist/src/adapter/contract.js +24 -0
- package/dist/src/adapter/operations.js +114 -0
- package/dist/src/adapter/service-seam.js +1 -0
- package/dist/src/artifacts/names.js +229 -0
- package/dist/src/artifacts/store.js +174 -0
- package/dist/src/bin/broker-tool.js +63 -0
- package/dist/src/bin/broker.js +111 -0
- package/dist/src/browser/adoption.js +143 -0
- package/dist/src/browser/automation-probe.js +113 -0
- package/dist/src/browser/conformance/case.js +1 -0
- package/dist/src/browser/conformance/cases.js +192 -0
- package/dist/src/browser/conformance/run.js +102 -0
- package/dist/src/browser/conformance/subjects.js +19 -0
- package/dist/src/browser/discovery.js +226 -0
- package/dist/src/browser/driver.js +195 -0
- package/dist/src/browser/fake.js +585 -0
- package/dist/src/browser/launch.js +504 -0
- package/dist/src/browser/real.js +1425 -0
- package/dist/src/browser/setup.js +161 -0
- package/dist/src/capture/accounting.js +59 -0
- package/dist/src/capture/image.js +112 -0
- package/dist/src/capture/ladder.js +72 -0
- package/dist/src/capture/legibility.js +195 -0
- package/dist/src/capture/pipeline.js +153 -0
- package/dist/src/capture/tiers.js +166 -0
- package/dist/src/cli/adapter.js +233 -0
- package/dist/src/cli/commands.js +270 -0
- package/dist/src/cli/conformance-driver.js +119 -0
- package/dist/src/cli/diffs.js +122 -0
- package/dist/src/cli/image.js +274 -0
- package/dist/src/cli/index.js +895 -0
- package/dist/src/cli/login-command.js +401 -0
- package/dist/src/cli/operations-commands.js +186 -0
- package/dist/src/cli/reconcile-command.js +137 -0
- package/dist/src/cli/sign-in.js +134 -0
- package/dist/src/cli/telemetry.js +222 -0
- package/dist/src/config/environment.js +446 -0
- package/dist/src/diff/artifact-path.js +77 -0
- package/dist/src/diff/crops.js +102 -0
- package/dist/src/diff/geometry.js +122 -0
- package/dist/src/diff/image.js +132 -0
- package/dist/src/diff/mask.js +46 -0
- package/dist/src/diff/regions.js +263 -0
- package/dist/src/diff/settings.js +135 -0
- package/dist/src/doctor/checks.js +588 -0
- package/dist/src/doctor/report.js +152 -0
- package/dist/src/doctor/session.js +161 -0
- package/dist/src/errors.js +36 -0
- package/dist/src/feedback/read.js +119 -0
- package/dist/src/feedback/record.js +199 -0
- package/dist/src/operations/addresses.js +175 -0
- package/dist/src/operations/derive.js +109 -0
- package/dist/src/operations/ledger.js +194 -0
- package/dist/src/operations/status.js +197 -0
- package/dist/src/operations/telemetry.js +280 -0
- package/dist/src/report/document.js +419 -0
- package/dist/src/report/escape.js +68 -0
- package/dist/src/report/snapshot.js +97 -0
- package/dist/src/service/arbitration.js +537 -0
- package/dist/src/service/artifacts.js +85 -0
- package/dist/src/service/bridge.js +577 -0
- package/dist/src/service/broker.js +120 -0
- package/dist/src/service/browser-session.js +269 -0
- package/dist/src/service/capacity.js +62 -0
- package/dist/src/service/capture-seam.js +83 -0
- package/dist/src/service/capture-store.js +91 -0
- package/dist/src/service/comparison-store.js +101 -0
- package/dist/src/service/comparison.js +173 -0
- package/dist/src/service/events.js +93 -0
- package/dist/src/service/keys.js +68 -0
- package/dist/src/service/leases.js +147 -0
- package/dist/src/service/nudge.js +66 -0
- package/dist/src/service/operations/claim.js +692 -0
- package/dist/src/service/operations/give-back.js +131 -0
- package/dist/src/service/operations/pages.js +771 -0
- package/dist/src/service/operations/sign-in.js +915 -0
- package/dist/src/service/operations/status.js +62 -0
- package/dist/src/service/ownership.js +93 -0
- package/dist/src/service/pages.js +616 -0
- package/dist/src/service/pending-seeds.js +20 -0
- package/dist/src/service/queue.js +233 -0
- package/dist/src/service/reconcile.js +220 -0
- package/dist/src/service/refusals.js +262 -0
- package/dist/src/service/runtime.js +131 -0
- package/dist/src/service/signin-recovery.js +148 -0
- package/dist/src/service/storage-seed.js +239 -0
- package/dist/src/service/tabs.js +123 -0
- package/dist/src/store/budget.js +99 -0
- package/dist/src/store/location.js +42 -0
- package/dist/src/store/network-path.js +182 -0
- package/dist/src/store/network-volume.js +92 -0
- package/dist/src/store/open.js +226 -0
- package/dist/src/store/schema/step-001-initial.js +523 -0
- package/dist/src/store/schema/step-002-tab-budget.js +53 -0
- package/dist/src/store/schema/step-003-queue-order.js +110 -0
- package/dist/src/store/schema/step-004-tab-never-opened.js +100 -0
- package/dist/src/store/schema/step-005-storage-seed-event.js +90 -0
- package/dist/src/store/schema/step-006-signin-events.js +104 -0
- package/dist/src/store/schema/step-007-signin-without-process.js +92 -0
- package/dist/src/store/schema/step-008-signin-owner.js +76 -0
- package/dist/src/store/schema/step-009-named-browsers.js +138 -0
- package/dist/src/store/schema/step-010-signin-request.js +135 -0
- package/dist/src/store/schema/step.js +172 -0
- package/dist/src/store/schema/steps.js +58 -0
- package/dist/src/store/transaction.js +37 -0
- package/dist/src/tool/adapter.js +90 -0
- package/dist/src/tool/conformance-driver.js +184 -0
- package/dist/src/tool/protocol.js +310 -0
- package/dist/src/tool/session.js +351 -0
- package/dist/src/tool/tools.js +310 -0
- 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
|
+
}
|