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,120 @@
|
|
|
1
|
+
import { readDiffSettings } from "../diff/settings.js";
|
|
2
|
+
import { runArbitration } from "./arbitration.js";
|
|
3
|
+
import { createPendingSeeds } from "./pending-seeds.js";
|
|
4
|
+
/**
|
|
5
|
+
* Bind a service to one store, one environment snapshot and one adapter.
|
|
6
|
+
*
|
|
7
|
+
* The settings are read from the environment **here, once**, and handed to
|
|
8
|
+
* every operation on its input. §6.3: one snapshot per process, so every rule
|
|
9
|
+
* inside one operation sees one configuration — a handler that read the
|
|
10
|
+
* environment itself would be a second snapshot taken at a different instant,
|
|
11
|
+
* inside a transaction other callers are waiting behind.
|
|
12
|
+
*/
|
|
13
|
+
export function createBroker(options) {
|
|
14
|
+
const settings = {
|
|
15
|
+
tabBudget: options.environment.tabBudget,
|
|
16
|
+
leaseSeconds: options.environment.leaseSeconds,
|
|
17
|
+
queueSeconds: options.environment.queueSeconds,
|
|
18
|
+
regularBrowsers: options.environment.regularBrowsers,
|
|
19
|
+
privateBrowsers: options.environment.privateBrowsers,
|
|
20
|
+
};
|
|
21
|
+
// The same one-per-process snapshot the arbitration settings are, taken
|
|
22
|
+
// here for the same reason (§6.3). A bad value refuses the spawn rather
|
|
23
|
+
// than one capture, which is `readDiffSettings`'s own documented split.
|
|
24
|
+
const diffSettings = options.diffSettings ?? readDiffSettings();
|
|
25
|
+
const run = (name, input) => runArbitration({
|
|
26
|
+
store: options.store,
|
|
27
|
+
name,
|
|
28
|
+
adapter: options.adapter,
|
|
29
|
+
input,
|
|
30
|
+
...(options.closeTab === undefined ? {} : { closeTab: options.closeTab }),
|
|
31
|
+
});
|
|
32
|
+
/**
|
|
33
|
+
* The browser connection, added to the six inputs that can use one.
|
|
34
|
+
*
|
|
35
|
+
* **Added here rather than by each adapter**, which is the same rule the
|
|
36
|
+
* bridge holds itself to: a route shapes arguments and names an operation,
|
|
37
|
+
* and whether a browser is reached is not an argument a caller passes. A
|
|
38
|
+
* surface that could omit it would be a surface on which a page verb
|
|
39
|
+
* silently did nothing, and the two surfaces would differ in what they
|
|
40
|
+
* actually did while agreeing on what they returned — the exact failure
|
|
41
|
+
* §8's parity assertion exists to catch.
|
|
42
|
+
*/
|
|
43
|
+
/**
|
|
44
|
+
* Seeds a granted claim asked for, waiting for its tab's first open.
|
|
45
|
+
*
|
|
46
|
+
* **One store per broker, which is one per process** (§1.0 — the service is
|
|
47
|
+
* spawned by its caller and exits with it). The values are credentials and
|
|
48
|
+
* are deliberately never written to the store; `pending-seeds.ts` sets out
|
|
49
|
+
* why, and what a lease granted in one process and driven from another
|
|
50
|
+
* therefore does not get.
|
|
51
|
+
*/
|
|
52
|
+
const pendingSeeds = createPendingSeeds();
|
|
53
|
+
const withBrowser = (input) => ({
|
|
54
|
+
...input,
|
|
55
|
+
...(options.session === undefined ? {} : { session: options.session }),
|
|
56
|
+
// Handed to every tab verb rather than only to the one that opens the
|
|
57
|
+
// page, because **which verb opens the page is not knowable here**: a
|
|
58
|
+
// lease's first call may be a navigate, a read, a capture or any other,
|
|
59
|
+
// and `pageFor` is what discovers it is the first. Supplying it only to
|
|
60
|
+
// one would make seeding depend on which verb a caller happened to reach
|
|
61
|
+
// for — the same class of defect as a rule holding on one route.
|
|
62
|
+
pendingSeeds,
|
|
63
|
+
});
|
|
64
|
+
/**
|
|
65
|
+
* The browser connection **and** somewhere to write, for the two verbs that
|
|
66
|
+
* produce a file: a capture always, and an evaluation whose result is past
|
|
67
|
+
* the inline cap (§3.10).
|
|
68
|
+
*
|
|
69
|
+
* Added here for the same reason {@link withBrowser} is: which store an
|
|
70
|
+
* artefact lands in is not an argument a caller passes, and a surface that
|
|
71
|
+
* could omit it would be a surface on which a large evaluation silently
|
|
72
|
+
* had nowhere to go.
|
|
73
|
+
*/
|
|
74
|
+
const withBrowserAndArtifacts = (input) => ({
|
|
75
|
+
...withBrowser(input),
|
|
76
|
+
...(options.artifacts === undefined ? {} : { artifacts: options.artifacts }),
|
|
77
|
+
// Read once, above, and handed to every capture — never read inside the
|
|
78
|
+
// handler, for the reason this function's own header gives about the
|
|
79
|
+
// arbitration settings: a second snapshot taken at a different instant
|
|
80
|
+
// would let two rules inside one operation see two configurations.
|
|
81
|
+
diffSettings,
|
|
82
|
+
});
|
|
83
|
+
return {
|
|
84
|
+
claim: async (input) => {
|
|
85
|
+
const result = await run('claim', { ...input, settings });
|
|
86
|
+
// **After the call returns, so only a committed grant holds a seed.**
|
|
87
|
+
// A refused claim throws before reaching here and a queued one carries
|
|
88
|
+
// no entries (`claim.ts`: a queue placement has no tab to seed and the
|
|
89
|
+
// entries are deliberately dropped rather than held across an
|
|
90
|
+
// unbounded wait). So nothing is retained for a lease that does not
|
|
91
|
+
// exist, and a caller cannot leave values in this process by being
|
|
92
|
+
// refused repeatedly.
|
|
93
|
+
if (result.outcome === 'granted') {
|
|
94
|
+
pendingSeeds.put(result.claimId, result.storageSeed);
|
|
95
|
+
}
|
|
96
|
+
return result;
|
|
97
|
+
},
|
|
98
|
+
status: (input) => run('status', input),
|
|
99
|
+
release: (input) => run('release', {
|
|
100
|
+
...input,
|
|
101
|
+
settings,
|
|
102
|
+
}),
|
|
103
|
+
navigate: (input) => run('navigate', withBrowser(input)),
|
|
104
|
+
act: (input) => run('act', withBrowser(input)),
|
|
105
|
+
read: (input) => run('read', withBrowser(input)),
|
|
106
|
+
evaluate: (input) => run('evaluate', {
|
|
107
|
+
...withBrowser(input),
|
|
108
|
+
// The artifact store, and deliberately **not** `diffSettings`: an
|
|
109
|
+
// evaluation never diffs, and handing it a setting it cannot use
|
|
110
|
+
// would be the surface declaring an argument that does nothing.
|
|
111
|
+
...(options.artifacts === undefined ? {} : { artifacts: options.artifacts }),
|
|
112
|
+
}),
|
|
113
|
+
capture: (input) => run('capture', withBrowserAndArtifacts(input)),
|
|
114
|
+
tab_replace: (input) => run('tab_replace', withBrowser(input)),
|
|
115
|
+
begin_sign_in: (input) => run('begin_sign_in', input),
|
|
116
|
+
end_sign_in: (input) => run('end_sign_in', input),
|
|
117
|
+
sign_in: (input) => run('sign_in', input),
|
|
118
|
+
sign_in_done: (input) => run('sign_in_done', input),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { profileDirectory } from "../browser/discovery.js";
|
|
3
|
+
import { decideAdoption, recordLaunched, recordLaunchFailed, } from "../browser/adoption.js";
|
|
4
|
+
import { browserIsRunning, modeFor, RealBrowserDriver } from "../browser/real.js";
|
|
5
|
+
import { StartupRefusal } from "../errors.js";
|
|
6
|
+
/**
|
|
7
|
+
* **How a shipped binary gets a browser.** The join between the adoption
|
|
8
|
+
* arbitration and the driver that performs what it decides.
|
|
9
|
+
*
|
|
10
|
+
* ── Why this file exists, stated as the thing that was missing ───────────
|
|
11
|
+
*
|
|
12
|
+
* Every part of this was already built and tested. `decideAdoption` arbitrates
|
|
13
|
+
* the launch race in the same transaction that arbitrates claims;
|
|
14
|
+
* `RealBrowserDriver` attaches to a running browser or cold-starts a detached
|
|
15
|
+
* one; `recordLaunched` and `recordLaunchFailed` close the `starting` window
|
|
16
|
+
* either way. **Nothing in `src/` called any of them.** The adoption module
|
|
17
|
+
* had tests and no production caller, so the store's `browsers` table
|
|
18
|
+
* described a launch race that no shipped code ever ran.
|
|
19
|
+
*
|
|
20
|
+
* That is the gap this closes, and it is closed by *composing* those pieces
|
|
21
|
+
* rather than by writing a second launch path beside them.
|
|
22
|
+
*
|
|
23
|
+
* ── Lazy, and that is load-bearing rather than an optimisation ───────────
|
|
24
|
+
*
|
|
25
|
+
* **Nothing here touches a browser until a page verb actually needs one.** A
|
|
26
|
+
* spawn that acquired a browser eagerly would make every command that reaches
|
|
27
|
+
* the service — `claim`, `status`, `release`, `feedback`, a refusal — depend
|
|
28
|
+
* on a browser installation, and the two most common states of this service
|
|
29
|
+
* are *no browser installed* and *no page driven yet*. It would also make the
|
|
30
|
+
* continuous-integration `operations` job, which spawns the real executables
|
|
31
|
+
* on a runner with no browser at all, depend on one.
|
|
32
|
+
*
|
|
33
|
+
* So {@link browserSessionProvider} returns a function, and the function is
|
|
34
|
+
* what `operations/pages.ts` calls **inside an after-commit closure**. A
|
|
35
|
+
* caller that only ever claims and releases never causes a launch.
|
|
36
|
+
*
|
|
37
|
+
* ── Memoised per process, because adoption is not idempotent ─────────────
|
|
38
|
+
*
|
|
39
|
+
* The six page verbs each resolve a session, and a session is a live
|
|
40
|
+
* connection over the debugging protocol. Resolving one per call would open a
|
|
41
|
+
* new connection per verb and run the adoption transaction per verb — and the
|
|
42
|
+
* second of those is worse than wasteful: the race is decided in the store,
|
|
43
|
+
* so a process that re-entered it would keep re-answering a question it had
|
|
44
|
+
* already answered. One session per browser per process, held until the
|
|
45
|
+
* process exits.
|
|
46
|
+
*
|
|
47
|
+
* The memo holds the **promise**, not the resolved session, so two verbs
|
|
48
|
+
* racing in the same process await one acquisition rather than starting two.
|
|
49
|
+
*
|
|
50
|
+
* ── A failed acquisition is not cached ───────────────────────────────────
|
|
51
|
+
*
|
|
52
|
+
* If acquiring throws, the memo is cleared, so the next call tries again. The
|
|
53
|
+
* alternative — caching the rejection — turns one transient failure (a browser
|
|
54
|
+
* still finishing its start-up, a machine that was briefly out of memory) into
|
|
55
|
+
* a process that can never drive a page again, and the process serves a whole
|
|
56
|
+
* session.
|
|
57
|
+
*/
|
|
58
|
+
/**
|
|
59
|
+
* How long a launch-race loser waits before giving up on the winner —
|
|
60
|
+
* documented here, not consulted here.
|
|
61
|
+
*
|
|
62
|
+
* **Not read by `waitForWinner`.** `environment.launchReadinessTimeoutSeconds`
|
|
63
|
+
* (`BROKER_LAUNCH_READINESS_TIMEOUT_SECONDS`, `src/config/environment.ts`) is
|
|
64
|
+
* the number that actually governs a spawn — `environment` is a required
|
|
65
|
+
* field on {@link BrowserSessionProviderOptions}, so every caller that builds
|
|
66
|
+
* a provider supplies one. This constant has no production consumer; it is
|
|
67
|
+
* kept as a doc anchor equal to that variable's default (30s) so the two
|
|
68
|
+
* cannot silently drift apart, and a test may still use it as a readable
|
|
69
|
+
* stand-in for "the default" without hardcoding `30_000` a second time.
|
|
70
|
+
*/
|
|
71
|
+
export const WAIT_TIMEOUT_MS = 30_000;
|
|
72
|
+
/** How often a launch-race loser re-asks whether the winner's browser is up. */
|
|
73
|
+
export const WAIT_POLL_INTERVAL_MS = 100;
|
|
74
|
+
export function browserSessionProvider(options) {
|
|
75
|
+
const driver = options.driver ??
|
|
76
|
+
new RealBrowserDriver({
|
|
77
|
+
...(options.artifacts === undefined
|
|
78
|
+
? {}
|
|
79
|
+
: // The driver names a file; the store decided the directory. See
|
|
80
|
+
// `RealDriverOptions.outputDirectory` for why the split is the rule
|
|
81
|
+
// rather than a preference, and {@link
|
|
82
|
+
// BrowserSessionProviderOptions.artifacts} for why this is the
|
|
83
|
+
// shared tree rather than one lease's.
|
|
84
|
+
{ outputDirectory: path.join(options.artifacts.root, 'snapshots') }),
|
|
85
|
+
// **The signed-in engine, carried from this process's one environment
|
|
86
|
+
// snapshot** (§6.3, `DECISIONS.md` §13i). One driver serves every
|
|
87
|
+
// browser in this process, so the kind-specific engine cannot be chosen
|
|
88
|
+
// per session here — see the note on `RealDriverOptions.engine` for
|
|
89
|
+
// what the value does and does not do, which is what makes one engine
|
|
90
|
+
// per process the honest shape rather than a shortcut.
|
|
91
|
+
engine: options.environment.regularBrowserEngine,
|
|
92
|
+
});
|
|
93
|
+
const inFlight = new Map();
|
|
94
|
+
const settled = new Map();
|
|
95
|
+
const session = (browser) => {
|
|
96
|
+
const existing = inFlight.get(browser);
|
|
97
|
+
if (existing !== undefined) {
|
|
98
|
+
return existing;
|
|
99
|
+
}
|
|
100
|
+
const acquiring = acquire(driver, browser, options)
|
|
101
|
+
.then((acquired) => {
|
|
102
|
+
settled.set(browser, acquired);
|
|
103
|
+
return acquired;
|
|
104
|
+
})
|
|
105
|
+
.catch((error) => {
|
|
106
|
+
// Not cached. See the header: a cached rejection would end this
|
|
107
|
+
// process's ability to drive a page for the rest of its life.
|
|
108
|
+
inFlight.delete(browser);
|
|
109
|
+
throw error;
|
|
110
|
+
});
|
|
111
|
+
inFlight.set(browser, acquiring);
|
|
112
|
+
return acquiring;
|
|
113
|
+
};
|
|
114
|
+
return {
|
|
115
|
+
session,
|
|
116
|
+
close: async () => {
|
|
117
|
+
for (const open of settled.values()) {
|
|
118
|
+
try {
|
|
119
|
+
await open.detach();
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// The browser is adopted and outlives this process regardless.
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
settled.clear();
|
|
126
|
+
inFlight.clear();
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Perform one adoption: ask the store what to do, then do it.
|
|
132
|
+
*
|
|
133
|
+
* ── The order is the rule, and it is the reason this is not two lines ────
|
|
134
|
+
*
|
|
135
|
+
* `SCHEMA.md` §2.4b: no browser work inside the arbitration transaction. So
|
|
136
|
+
* the observation is made **before** the transaction opens, the transaction
|
|
137
|
+
* decides, and the launch happens **after** it commits. `adoption.ts` says the
|
|
138
|
+
* same thing from the other side — its `runningRecord` is a parameter *"for
|
|
139
|
+
* one reason: reading it means talking to a browser, and this runs inside the
|
|
140
|
+
* transaction"*.
|
|
141
|
+
*
|
|
142
|
+
* The consequence, which `decideAdoption` also states: the observation can be
|
|
143
|
+
* stale by the time the transaction opens, and the `starting` state is what
|
|
144
|
+
* covers the gap rather than the observation being made more carefully.
|
|
145
|
+
*/
|
|
146
|
+
async function acquire(driver, browser, options) {
|
|
147
|
+
const isRunning = options.isRunning ?? browserIsRunning;
|
|
148
|
+
const profileDir = profileDirectory(options.environment.profileRoot, browser);
|
|
149
|
+
const observed = await isRunning(profileDir);
|
|
150
|
+
const decision = await decideAdoption(options.store, browser, observed === undefined || observed.browserUuid === undefined
|
|
151
|
+
? undefined
|
|
152
|
+
: { endpoint: observed.endpoint, browserUuid: observed.browserUuid });
|
|
153
|
+
return performDecision(driver, decision, profileDir, options);
|
|
154
|
+
}
|
|
155
|
+
/** Do what the transaction decided, outside it. */
|
|
156
|
+
async function performDecision(driver, decision, profileDir, options) {
|
|
157
|
+
const { browser } = decision;
|
|
158
|
+
if (decision.action === 'attach') {
|
|
159
|
+
return driver.attach(browser, {
|
|
160
|
+
endpoint: decision.endpoint,
|
|
161
|
+
browserUuid: decision.browserUuid,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
if (decision.action === 'wait') {
|
|
165
|
+
return waitForWinner(driver, browser, profileDir, options);
|
|
166
|
+
}
|
|
167
|
+
// This caller won the race, and the row says `starting` until it says
|
|
168
|
+
// otherwise. **Both outcomes have to be recorded**: `adoption.ts` is
|
|
169
|
+
// explicit that a winner which fails and does not release the race leaves
|
|
170
|
+
// every later caller waiting for a launch that is never coming.
|
|
171
|
+
try {
|
|
172
|
+
const started = await driver.coldStart({
|
|
173
|
+
browser,
|
|
174
|
+
profileDirectory: profileDir,
|
|
175
|
+
mode: modeFor(browser),
|
|
176
|
+
});
|
|
177
|
+
const described = started.describe();
|
|
178
|
+
await recordLaunched(options.store, browser, {
|
|
179
|
+
pid: described.pid,
|
|
180
|
+
endpoint: described.discovery.endpoint,
|
|
181
|
+
// A session that connected has read the browser's own identifier, so
|
|
182
|
+
// this is the verified one rather than the one off disk.
|
|
183
|
+
browserUuid: described.discovery.browserUuid ?? '',
|
|
184
|
+
});
|
|
185
|
+
return started;
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
await recordLaunchFailed(options.store, browser);
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Wait for the caller that won the race, and attach to what it started.
|
|
194
|
+
*
|
|
195
|
+
* ── Row #55, settled ──────────────────────────────────────────────────────
|
|
196
|
+
*
|
|
197
|
+
* `SCHEMA.md` §1.2b named two candidate moments and refused to conflate
|
|
198
|
+
* them: the winner **recording** that it launched, and the winner's
|
|
199
|
+
* debugging endpoint **accepting a connection**. Only the second is what a
|
|
200
|
+
* loser can actually attach to, so only the second is what this waits for.
|
|
201
|
+
*
|
|
202
|
+
* **The signal is `browserIsRunning`, unchanged** — §1.2c's own liveness
|
|
203
|
+
* (the endpoint answers) plus identity (the browser's own identifier
|
|
204
|
+
* matches, because ports are reused) check, the one every attach in this
|
|
205
|
+
* file already trusts. This row does not add a second verification: it is
|
|
206
|
+
* `readDiscoveryRecord` + `verifyDiscoveryRecord` under `src/browser/real.ts`,
|
|
207
|
+
* called here exactly as `acquire`'s own observation calls it. A caller that
|
|
208
|
+
* wrote a second check here would be trusting a claim by two different
|
|
209
|
+
* routes that could disagree; there is one route.
|
|
210
|
+
*
|
|
211
|
+
* **The bound is `environment.launchReadinessTimeoutSeconds`**
|
|
212
|
+
* (`BROKER_LAUNCH_READINESS_TIMEOUT_SECONDS`, `.env.example`) — a poll
|
|
213
|
+
* ceiling, never a sleep. Somebody has to do something when the decision
|
|
214
|
+
* comes back `wait`, and there are exactly three options: return a session
|
|
215
|
+
* that is not one, block forever, or poll with a ceiling. The first is the
|
|
216
|
+
* dishonesty this whole row exists to remove and the second turns one failed
|
|
217
|
+
* launch elsewhere into a hung process here — so this polls, and when the
|
|
218
|
+
* ceiling is reached it **refuses, naming what it waited for**, rather than
|
|
219
|
+
* proceeding as if it had a browser. A `StartupRefusal` is the same shape
|
|
220
|
+
* every other refusal in this module takes, and its message says a caller is
|
|
221
|
+
* *still starting*, never that the browser *died* — those are different
|
|
222
|
+
* facts and this function has evidence for only one of them.
|
|
223
|
+
*
|
|
224
|
+
* It is deliberately the most minimal shape that can be correct:
|
|
225
|
+
*
|
|
226
|
+
* - **It launches nothing.** A loser that started its own browser would be the
|
|
227
|
+
* second process against one profile directory, which is the measured
|
|
228
|
+
* silent-collision failure the race exists to prevent.
|
|
229
|
+
* - **It concludes nothing from the ceiling.** Timing out is reported as *this
|
|
230
|
+
* caller stopped waiting*, not as *the winner failed*. Nothing here writes
|
|
231
|
+
* to the `browsers` row, so the winner keeps its race and the next caller
|
|
232
|
+
* asks the same question again.
|
|
233
|
+
* - **It is the ordinary positive test.** The loop asks `browserIsRunning`,
|
|
234
|
+
* which verifies the endpoint answers **and** that the browser identifies
|
|
235
|
+
* itself as the one on the record — the same check every other path here
|
|
236
|
+
* makes, and the reason a stale record plus a reused port cannot be mistaken
|
|
237
|
+
* for a live browser.
|
|
238
|
+
*
|
|
239
|
+
* `sleepImpl`/`nowImpl` are injected, following the seam convention
|
|
240
|
+
* `launch.ts` established for `fetchImpl`/`spawnImpl`/`killImpl`: a test that
|
|
241
|
+
* proves "refuses at the bound" by actually waiting out a 30-second default
|
|
242
|
+
* would be a slow, flaky proxy for the same assertion a fake clock makes
|
|
243
|
+
* instantly and exactly.
|
|
244
|
+
*/
|
|
245
|
+
async function waitForWinner(driver, browser, profileDir, options) {
|
|
246
|
+
const isRunning = options.isRunning ?? browserIsRunning;
|
|
247
|
+
const timeoutMs = options.waitTimeoutMs ?? options.environment.launchReadinessTimeoutSeconds * 1000;
|
|
248
|
+
const intervalMs = options.waitPollIntervalMs ?? WAIT_POLL_INTERVAL_MS;
|
|
249
|
+
const sleep = options.sleepImpl ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
250
|
+
const now = options.nowImpl ?? Date.now;
|
|
251
|
+
const deadline = now() + timeoutMs;
|
|
252
|
+
for (;;) {
|
|
253
|
+
// §1.2c's two checks, unchanged: liveness (the endpoint answers) and
|
|
254
|
+
// identity (the browser's own identifier matches the record, since ports
|
|
255
|
+
// are reused). A record that fails either is stale, and a stale record is
|
|
256
|
+
// reported here exactly as "not yet reachable" — indistinguishable from
|
|
257
|
+
// the winner still starting, which is the honest state: this function
|
|
258
|
+
// cannot tell "not up yet" from "up as something else" apart from "not up
|
|
259
|
+
// yet", and it does not pretend to.
|
|
260
|
+
const record = await isRunning(profileDir);
|
|
261
|
+
if (record !== undefined && record.browserUuid !== undefined) {
|
|
262
|
+
return driver.attach(browser, record);
|
|
263
|
+
}
|
|
264
|
+
if (now() >= deadline) {
|
|
265
|
+
throw new StartupRefusal('launch.explicit_profile_dir', `Another caller is starting the ${browser} browser and it did not become reachable within ${String(timeoutMs)}ms. This is a caller that is still starting, not a browser that has died: nothing here observed a failure, only the absence of a verified discovery record before the bound. Nothing was launched here either way: a second browser against one profile directory hands its address to the first and opens no endpoint of its own. Try again — the browser may still be starting.`);
|
|
266
|
+
}
|
|
267
|
+
await sleep(intervalMs);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admission: one integer against one integer.
|
|
3
|
+
*
|
|
4
|
+
* §2.3 reduces the whole capacity model to one sentence, and everything in
|
|
5
|
+
* this module is a consequence of it:
|
|
6
|
+
*
|
|
7
|
+
* > **Capacity, grants and tabs are the same integer.** The pool bound *is*
|
|
8
|
+
* > the tab budget *is* the count of live claims. **Need two tabs? Claim
|
|
9
|
+
* > twice.**
|
|
10
|
+
*
|
|
11
|
+
* ── The arithmetic a reader expects, and will not find ──────────────────
|
|
12
|
+
*
|
|
13
|
+
* Stated as an absence rather than left to be discovered, because each is a
|
|
14
|
+
* mechanism somebody may arrive looking for (§2.3, `MILESTONES.md` #12):
|
|
15
|
+
*
|
|
16
|
+
* - **No request size.** A claim is one tab. There is no requested-count term
|
|
17
|
+
* to add to the count, and anyone implementing a `tabs` argument on the
|
|
18
|
+
* claim call is implementing a design that was deleted rather than
|
|
19
|
+
* re-tuned.
|
|
20
|
+
* - **No per-lease allowance.** It would be the one number in this design
|
|
21
|
+
* answering to no prior decision, invented to stop one caller taking
|
|
22
|
+
* everything, and with a grant of one there is nothing for it to bound.
|
|
23
|
+
* - **No reservation.** Nothing is held for a tab that has not opened,
|
|
24
|
+
* because the claim row **is** the capacity. There is no window in which
|
|
25
|
+
* granted and existing could differ and therefore no pair of numbers that
|
|
26
|
+
* could disagree.
|
|
27
|
+
*
|
|
28
|
+
* **So there is one predicate and it is one comparison.** A function taking a
|
|
29
|
+
* count and a budget would be the natural place for a future size term to
|
|
30
|
+
* arrive; this one takes a database handle and reads the count itself, so the
|
|
31
|
+
* only thing a caller can vary is which store it asks.
|
|
32
|
+
*/
|
|
33
|
+
/**
|
|
34
|
+
* Count the claims that hold a tab, which is what capacity is a count of.
|
|
35
|
+
*
|
|
36
|
+
* Reads the index-only partial index over the live claims (§1.11), which is
|
|
37
|
+
* why the filter is spelled to match it: the answer comes out of the index
|
|
38
|
+
* without touching the table, and this count is read inside the transaction
|
|
39
|
+
* every arbitration call opens, with every other caller on the machine
|
|
40
|
+
* waiting behind it.
|
|
41
|
+
*/
|
|
42
|
+
export function countActiveClaims(db) {
|
|
43
|
+
const row = db.prepare("SELECT count(*) AS n FROM claims WHERE state = 'active'").get();
|
|
44
|
+
return row.n;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The admission predicate, entire: `count of live claims + 1 <= budget`.
|
|
48
|
+
*
|
|
49
|
+
* The `+ 1` is the claim being decided, and it is written out rather than
|
|
50
|
+
* folded into a `<` because §2.3 and `MILESTONES.md` #12 both spell it this
|
|
51
|
+
* way — the one place the arithmetic of this design appears, spelled as its
|
|
52
|
+
* specification spells it, so a reader comparing the two is comparing the
|
|
53
|
+
* same sentence.
|
|
54
|
+
*
|
|
55
|
+
* **This reads a count the same transaction has just reconciled** (§7.1,
|
|
56
|
+
* `capacity.admission`), so it can never admit against capacity held by a
|
|
57
|
+
* lease that has already lapsed. That property is the runner's — it sweeps
|
|
58
|
+
* before any handler runs — and not something this function can check.
|
|
59
|
+
*/
|
|
60
|
+
export function admits(activeClaims, budget) {
|
|
61
|
+
return activeClaims + 1 <= budget;
|
|
62
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The seam between the comparison feature and the capture pipeline.
|
|
3
|
+
*
|
|
4
|
+
* ══════════════════════════════════════════════════════════════════════════
|
|
5
|
+
* WHY A SEAM AT ALL, NOW THAT THE CAPTURE PIPELINE EXISTS
|
|
6
|
+
* ══════════════════════════════════════════════════════════════════════════
|
|
7
|
+
*
|
|
8
|
+
* The capture pipeline is built (`src/capture/pipeline.ts`), so this is no
|
|
9
|
+
* longer standing in for something absent. It stays because **the direction of
|
|
10
|
+
* the dependency is a rule the build enforces**, and an interface is what makes
|
|
11
|
+
* the rule expressible.
|
|
12
|
+
*
|
|
13
|
+
* `capture.no_diff_dependency` (§7.3): "**no capture path reads anything
|
|
14
|
+
* belonging to the diff feature.** This is what keeps the sequencing property
|
|
15
|
+
* real rather than intended: diffing is built last, so a capture that consulted
|
|
16
|
+
* it would make the earlier work depend on the later."
|
|
17
|
+
*
|
|
18
|
+
* That rule points one way, and this interface is the other way stated
|
|
19
|
+
* explicitly: **the diff reads capture, capture never reads the diff.** A
|
|
20
|
+
* capture path can hand a comparison what it needs without knowing a comparison
|
|
21
|
+
* exists, and `scripts/check-capture-isolation.mjs` fails the build if that
|
|
22
|
+
* ever inverts — this module is on its diff-owned list precisely so it does.
|
|
23
|
+
*
|
|
24
|
+
* ── What the comparison needs, and what it deliberately does not take ───
|
|
25
|
+
*
|
|
26
|
+
* Two operations: find a capture, read its bytes. **Not the pipeline itself**,
|
|
27
|
+
* and not its result type. A diff runs against a capture that was taken at some
|
|
28
|
+
* earlier moment, quite possibly by an earlier process — so what it needs is a
|
|
29
|
+
* way to look one up, not a way to take one.
|
|
30
|
+
*
|
|
31
|
+
* ── Why this shape and not a direct query ───────────────────────────────
|
|
32
|
+
*
|
|
33
|
+
* The direction of the dependency is the whole point. §7.3 carries
|
|
34
|
+
* `capture.no_diff_dependency`: "**no capture path reads anything belonging to
|
|
35
|
+
* the diff feature**. This is what keeps the sequencing property real rather
|
|
36
|
+
* than intended: diffing is built last, so a capture that consulted it would
|
|
37
|
+
* make the earlier work depend on the later."
|
|
38
|
+
*
|
|
39
|
+
* That rule points one way, and this interface is what makes the other way
|
|
40
|
+
* explicit: **the diff reads capture, capture never reads the diff.** A capture
|
|
41
|
+
* row can hand this a source and get a diff back without capture knowing what a
|
|
42
|
+
* comparison is.
|
|
43
|
+
*/
|
|
44
|
+
import fs from 'node:fs/promises';
|
|
45
|
+
const FIND_CAPTURE = `
|
|
46
|
+
SELECT id, claim_id, path, kind, width, height
|
|
47
|
+
FROM captures
|
|
48
|
+
WHERE id = @id
|
|
49
|
+
`;
|
|
50
|
+
/**
|
|
51
|
+
* The capture source backed by the store the capture pipeline writes to.
|
|
52
|
+
*
|
|
53
|
+
* **This is the join, and it lives here rather than being left to a caller.**
|
|
54
|
+
* The capture pipeline writes a `captures` row and a file through the artifact
|
|
55
|
+
* store; this reads that row back and resolves that file. Both halves use the
|
|
56
|
+
* same two facts — the table and the store — so there is no third place for
|
|
57
|
+
* them to disagree.
|
|
58
|
+
*
|
|
59
|
+
* The bytes are read through {@link ArtifactStore.resolve}, which is the only
|
|
60
|
+
* thing in this build that turns a recorded path into a location, and which
|
|
61
|
+
* refuses a path that escapes the root in **either** namespace. Reading the
|
|
62
|
+
* file directly would have meant a second resolver, and the second one is the
|
|
63
|
+
* one that would be missing a case.
|
|
64
|
+
*/
|
|
65
|
+
export function captureSource(db, artifacts) {
|
|
66
|
+
return {
|
|
67
|
+
find: (captureId) => {
|
|
68
|
+
const row = db.prepare(FIND_CAPTURE).get({ id: captureId });
|
|
69
|
+
if (row === undefined) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
id: row.id,
|
|
74
|
+
claimId: row.claim_id,
|
|
75
|
+
path: row.path,
|
|
76
|
+
kind: row.kind,
|
|
77
|
+
width: row.width,
|
|
78
|
+
height: row.height,
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
readBytes: async (capture) => new Uint8Array(await fs.readFile(artifacts.resolve(capture.path))),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `captures` row (§1.7), written by the layer that owns the transaction.
|
|
3
|
+
*
|
|
4
|
+
* ── Why this is a module and not two lines inside the capture handler ────
|
|
5
|
+
*
|
|
6
|
+
* `capture/pipeline.ts` says outright what it will not do: *"It does not write
|
|
7
|
+
* a database row"*, and gives the reason — keeping the split means the
|
|
8
|
+
* pipeline is testable against the fake driver with no store at all. So the
|
|
9
|
+
* pipeline computes {@link CaptureTelemetry} and somebody else writes it, and
|
|
10
|
+
* this is that somebody.
|
|
11
|
+
*
|
|
12
|
+
* ── The gap this closes, stated as the thing that was true ──────────────
|
|
13
|
+
*
|
|
14
|
+
* `takeCapture` had **no caller anywhere in `src/`**. `decideCapture` reached
|
|
15
|
+
* the browser directly and threw the picture away: no downscaling, no file,
|
|
16
|
+
* no row. The consequence was measurable and was measured — a caller driving
|
|
17
|
+
* the shipped binary got `capture -> {"outcome":"accepted",…}` while `SELECT
|
|
18
|
+
* count(*) FROM captures` returned **0**, which is the exact observation
|
|
19
|
+
* `TabOperationResult.pageDriven` was added to describe. `pageDriven` made the
|
|
20
|
+
* silence honest; this makes the picture exist.
|
|
21
|
+
*
|
|
22
|
+
* ── Written after the commit, and why that is correct rather than a compromise ──
|
|
23
|
+
*
|
|
24
|
+
* A capture is browser work: it settles the page, takes a picture and writes a
|
|
25
|
+
* file, none of which may happen inside the arbitration transaction (§2.4b).
|
|
26
|
+
* So the row is written on its own short statement afterwards, the same way
|
|
27
|
+
* `recordTabOpened` writes a driver name after a page is opened.
|
|
28
|
+
*
|
|
29
|
+
* **The row is the record that a file exists**, so writing it before the file
|
|
30
|
+
* would be the same class of error this whole area keeps producing: a store
|
|
31
|
+
* asserting something that did not happen. It is written last, from telemetry
|
|
32
|
+
* describing a file that is already on disk.
|
|
33
|
+
*/
|
|
34
|
+
/**
|
|
35
|
+
* Append one capture row.
|
|
36
|
+
*
|
|
37
|
+
* `taken_at`, `created_at` and `updated_at` are left to their defaults except
|
|
38
|
+
* `taken_at`, which carries the pipeline's own instant: the pipeline stamps
|
|
39
|
+
* the file name from it, and a row whose timestamp disagreed with the name of
|
|
40
|
+
* the file it points at would be unreadable as a pair.
|
|
41
|
+
*/
|
|
42
|
+
export function recordCapture(db, claimId, tabId, telemetry) {
|
|
43
|
+
db.prepare(`INSERT INTO captures (
|
|
44
|
+
id, claim_id, tab_id, taken_at, kind, tier, reason,
|
|
45
|
+
source_width, source_height, width, height, bytes,
|
|
46
|
+
path, selector, viewport_width, url, warned
|
|
47
|
+
) VALUES (
|
|
48
|
+
@id, @claimId, @tabId, @takenAt, @kind, @tier, @reason,
|
|
49
|
+
@sourceWidth, @sourceHeight, @width, @height, @bytes,
|
|
50
|
+
@path, @selector, @viewportWidth, @url, @warned
|
|
51
|
+
)`).run({
|
|
52
|
+
id: telemetry.id,
|
|
53
|
+
claimId,
|
|
54
|
+
tabId,
|
|
55
|
+
takenAt: telemetry.takenAt.toISOString(),
|
|
56
|
+
kind: telemetry.kind,
|
|
57
|
+
tier: telemetry.tier,
|
|
58
|
+
// `null` rather than `undefined`: the driver refuses a bound parameter
|
|
59
|
+
// that is undefined, and the column is nullable precisely because a reason
|
|
60
|
+
// is owed only on the top tier.
|
|
61
|
+
reason: telemetry.reason ?? null,
|
|
62
|
+
sourceWidth: telemetry.sourceWidth,
|
|
63
|
+
sourceHeight: telemetry.sourceHeight,
|
|
64
|
+
width: telemetry.width,
|
|
65
|
+
height: telemetry.height,
|
|
66
|
+
bytes: telemetry.bytes,
|
|
67
|
+
path: telemetry.path,
|
|
68
|
+
selector: telemetry.selector ?? null,
|
|
69
|
+
viewportWidth: telemetry.viewportWidth,
|
|
70
|
+
url: telemetry.url,
|
|
71
|
+
// The column is an integer with a check constraint on (0, 1); the store is
|
|
72
|
+
// STRICT, so a boolean would be refused rather than coerced.
|
|
73
|
+
warned: telemetry.warned ? 1 : 0,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* How many captures this lease has already taken.
|
|
78
|
+
*
|
|
79
|
+
* `takeCapture` takes this as an argument because *"counting is a query
|
|
80
|
+
* against the store and this module reaches no store"* — so the count is made
|
|
81
|
+
* here and handed in. It decides only the accounting warning, which is
|
|
82
|
+
* guidance and never a refusal (`capture/accounting.ts`), so a count read a
|
|
83
|
+
* moment before another process writes its own row costs a warning that fires
|
|
84
|
+
* one capture late. It is not a budget and nothing is denied on it.
|
|
85
|
+
*/
|
|
86
|
+
export function capturesTakenBy(db, claimId) {
|
|
87
|
+
const row = db
|
|
88
|
+
.prepare('SELECT count(*) AS taken FROM captures WHERE claim_id = ?')
|
|
89
|
+
.get(claimId);
|
|
90
|
+
return row?.taken ?? 0;
|
|
91
|
+
}
|