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,262 @@
|
|
|
1
|
+
import { BrokerError } from "../errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* Every refusal code this build can produce, and the §7 rule behind each.
|
|
4
|
+
*
|
|
5
|
+
* Written as an object rather than a list so the union below is the set of
|
|
6
|
+
* its keys — which means a code that is not in this table does not type-check
|
|
7
|
+
* at the throw site, and a table entry nothing throws shows up as an unused
|
|
8
|
+
* key rather than hiding inside an array.
|
|
9
|
+
*/
|
|
10
|
+
export const REFUSALS = {
|
|
11
|
+
/**
|
|
12
|
+
* §7.1 `key.present`. Separate from `unrecognised_key` on purpose: a caller
|
|
13
|
+
* that forgot the key entirely and a caller whose key is wrong need
|
|
14
|
+
* different sentences, and merging them sends the first one hunting for a
|
|
15
|
+
* lease that was never the problem.
|
|
16
|
+
*/
|
|
17
|
+
key_missing: {
|
|
18
|
+
rule: 'key.present',
|
|
19
|
+
summary: 'Every operation except requesting a lease carries a key, written out by the caller.',
|
|
20
|
+
retryable: false,
|
|
21
|
+
},
|
|
22
|
+
/**
|
|
23
|
+
* §7.1 `key.valid`. What a caller gets for a key this store has never seen.
|
|
24
|
+
*/
|
|
25
|
+
unrecognised_key: {
|
|
26
|
+
rule: 'key.valid',
|
|
27
|
+
summary: 'The key matches a lease.',
|
|
28
|
+
retryable: false,
|
|
29
|
+
},
|
|
30
|
+
/**
|
|
31
|
+
* §7.1 `claim.live`. Names the state it ended in and when, per §2.2 — a
|
|
32
|
+
* caller told only "no" cannot tell a revoke it should escalate from an
|
|
33
|
+
* expiry it should simply retry with a fresh lease.
|
|
34
|
+
*/
|
|
35
|
+
lease_ended: {
|
|
36
|
+
rule: 'claim.live',
|
|
37
|
+
summary: 'That lease is queued or active.',
|
|
38
|
+
retryable: false,
|
|
39
|
+
},
|
|
40
|
+
/**
|
|
41
|
+
* §7.1 `claim.browser_known`, and §2.2's first outright refusal: "nothing
|
|
42
|
+
* will ever make it valid", which is why waiting is not offered.
|
|
43
|
+
*/
|
|
44
|
+
unknown_browser: {
|
|
45
|
+
rule: 'claim.browser_known',
|
|
46
|
+
summary: 'The browser named is one of the two.',
|
|
47
|
+
retryable: false,
|
|
48
|
+
},
|
|
49
|
+
/**
|
|
50
|
+
* §7.1 `claim.browser_kind_agrees`. `DECISIONS.md` §13i names two
|
|
51
|
+
* processes on one machine holding **different configurations** as a real
|
|
52
|
+
* scenario, and rules that "every disagreement is a nameable refusal
|
|
53
|
+
* rather than a silently broken invariant" — this is that rule for the one
|
|
54
|
+
* disagreement `INSERT OR IGNORE` cannot detect on its own: the insert is a
|
|
55
|
+
* no-op when a row already exists, whatever kind it was created with, so a
|
|
56
|
+
* name configured `regular` on this process and `private` on the one that
|
|
57
|
+
* created the row would otherwise be handed a browser of the wrong kind in
|
|
58
|
+
* silence. Checked immediately after the insert, inside the same
|
|
59
|
+
* transaction, against the row as it now stands.
|
|
60
|
+
*
|
|
61
|
+
* Not retryable: the row's kind does not change by waiting, only by one of
|
|
62
|
+
* the two processes reconfiguring.
|
|
63
|
+
*/
|
|
64
|
+
browser_kind_mismatch: {
|
|
65
|
+
rule: 'claim.browser_kind_agrees',
|
|
66
|
+
summary: "A browser's stored kind agrees with the kind this process configured it as.",
|
|
67
|
+
retryable: false,
|
|
68
|
+
},
|
|
69
|
+
/**
|
|
70
|
+
* §7.1 `claim.purpose_bounded`. The bound §1.3 states — three to two
|
|
71
|
+
* hundred characters, mandatory — checked before a row is written.
|
|
72
|
+
*
|
|
73
|
+
* ── Why this row exists at all ──────────────────────────────────────────
|
|
74
|
+
*
|
|
75
|
+
* Because until it did, the bound was enforced **only** by the column's
|
|
76
|
+
* `CHECK (length(purpose) BETWEEN 3 AND 200)`, which is not a refusal: it
|
|
77
|
+
* is a driver error raised after the statement is handed to the store. On
|
|
78
|
+
* the command line it left the process with an unhandled `SqliteError` and
|
|
79
|
+
* exit 1, and on the tool surface it came back as `unexpected_failure`
|
|
80
|
+
* carrying the constraint text verbatim. Both told a caller the name of a
|
|
81
|
+
* database constraint instead of the name of the argument they got wrong,
|
|
82
|
+
* and the first of the two is indistinguishable from the service being
|
|
83
|
+
* broken — on the first command anybody runs.
|
|
84
|
+
*
|
|
85
|
+
* **This is the rule §7.1 was missing rather than a new policy.** §1.3
|
|
86
|
+
* already made the field mandatory and already fixed the bound; every other
|
|
87
|
+
* bounded free-text field in the service had its refusal
|
|
88
|
+
* (`feedback.note_bounded`, `evaluate.expression_bounded`,
|
|
89
|
+
* `capture.max_tier_reason`) and this one did not. The naming follows those
|
|
90
|
+
* — `<operation>.<field>_bounded` — rather than starting a second
|
|
91
|
+
* convention beside them.
|
|
92
|
+
*
|
|
93
|
+
* Not retryable: the identical call, with the identical argument, fails
|
|
94
|
+
* identically forever. What changes it is the caller writing a purpose, not
|
|
95
|
+
* the caller waiting.
|
|
96
|
+
*/
|
|
97
|
+
purpose_out_of_bounds: {
|
|
98
|
+
rule: 'claim.purpose_bounded',
|
|
99
|
+
summary: 'A claim carries a purpose, three to two hundred characters.',
|
|
100
|
+
retryable: false,
|
|
101
|
+
},
|
|
102
|
+
/**
|
|
103
|
+
* §1.3's attribution key, absent.
|
|
104
|
+
*
|
|
105
|
+
* ── Why this needs a code of its own rather than reusing the purpose's ──
|
|
106
|
+
*
|
|
107
|
+
* They are the same *shape* of defect — a surface coerced a missing
|
|
108
|
+
* argument to the empty string and the service took it — but not the same
|
|
109
|
+
* refusal, and the rule is looked up here precisely so a code and a rule
|
|
110
|
+
* cannot drift apart. A caller branching on `purpose_out_of_bounds` would
|
|
111
|
+
* go and rewrite a purpose that was never wrong.
|
|
112
|
+
*
|
|
113
|
+
* ── Why it is not retryable ─────────────────────────────────────────────
|
|
114
|
+
*
|
|
115
|
+
* Nothing about waiting supplies an identity the caller did not send
|
|
116
|
+
* (§2.2). The same reasoning that makes `unknown_browser` permanent applies
|
|
117
|
+
* unchanged: the request has to be re-made differently, not re-made later.
|
|
118
|
+
*/
|
|
119
|
+
session_id_missing: {
|
|
120
|
+
rule: 'claim.session_bounded',
|
|
121
|
+
summary: 'A claim carries the identity of the session asking for it.',
|
|
122
|
+
retryable: false,
|
|
123
|
+
},
|
|
124
|
+
/**
|
|
125
|
+
* §5.5.1's last line: the private browser cannot be signed into.
|
|
126
|
+
*
|
|
127
|
+
* ── Why this is not `unknown_browser`, which it was at first ────────────
|
|
128
|
+
*
|
|
129
|
+
* The private browser **is** one of the two. Refusing it with the code for
|
|
130
|
+
* a name that does not exist made the command report the rule
|
|
131
|
+
* `claim.browser_known` — telling a person their browser name was wrong
|
|
132
|
+
* when it was correct, and pointing them at the one rule that was not the
|
|
133
|
+
* reason. The message said the right thing and the machine-readable fields
|
|
134
|
+
* contradicted it, which is worse than either alone: a caller branching on
|
|
135
|
+
* the rule would conclude it had a typo and retry the same word.
|
|
136
|
+
*
|
|
137
|
+
* The rule is looked up here rather than passed by the caller precisely so
|
|
138
|
+
* that the pair cannot drift — so a refusal that needs a different rule
|
|
139
|
+
* needs a different code, which is this one.
|
|
140
|
+
*
|
|
141
|
+
* Not retryable: the private browser's profile is ephemeral by design, so
|
|
142
|
+
* no amount of waiting makes signing into it produce anything.
|
|
143
|
+
*/
|
|
144
|
+
cannot_sign_in: {
|
|
145
|
+
// §7.1 `browser.serving`, whose entry covers the signing-in cases. The
|
|
146
|
+
// private browser is not available *to be signed into* — permanently,
|
|
147
|
+
// which is why this code and `browser_unavailable` share a rule and
|
|
148
|
+
// differ on the one field a caller acts on.
|
|
149
|
+
rule: 'browser.serving',
|
|
150
|
+
summary: 'The browser named keeps a profile that a sign-in can persist in.',
|
|
151
|
+
retryable: false,
|
|
152
|
+
},
|
|
153
|
+
/**
|
|
154
|
+
* §7.1 `browser.serving`, and §2.2's second: a browser being down is an
|
|
155
|
+
* availability problem rather than a capacity one, so it is refused rather
|
|
156
|
+
* than queued — the queue's promise is that capacity frees up, and nothing
|
|
157
|
+
* about a failed browser promises that.
|
|
158
|
+
*/
|
|
159
|
+
browser_unavailable: {
|
|
160
|
+
rule: 'browser.serving',
|
|
161
|
+
summary: 'The browser is available.',
|
|
162
|
+
retryable: true,
|
|
163
|
+
},
|
|
164
|
+
/**
|
|
165
|
+
* §7.1 `tab.owned` **and** `tab.open` produce this one code, and the
|
|
166
|
+
* sharing is the point rather than an economy: §7.1 says outright that an
|
|
167
|
+
* unowned tab gets "the same refusal as an unknown tab, so probing cannot
|
|
168
|
+
* discover another lease's tabs". Two rules, two ledger rows, one code — a
|
|
169
|
+
* caller able to tell them apart is a caller able to enumerate tabs it does
|
|
170
|
+
* not own.
|
|
171
|
+
*/
|
|
172
|
+
tab_not_found: {
|
|
173
|
+
rule: 'tab.owned',
|
|
174
|
+
summary: 'The tab belongs to this lease and is open.',
|
|
175
|
+
retryable: false,
|
|
176
|
+
},
|
|
177
|
+
/** §7.1 `revoke.reason_required`. An operator taking capacity owes a sentence. */
|
|
178
|
+
reason_required: {
|
|
179
|
+
rule: 'revoke.reason_required',
|
|
180
|
+
summary: 'A revoke carries a reason.',
|
|
181
|
+
retryable: false,
|
|
182
|
+
},
|
|
183
|
+
/**
|
|
184
|
+
* §7.1 `signin.what_bounded`. What a caller gets for a sign-in request that
|
|
185
|
+
* does not say what is being signed into.
|
|
186
|
+
*
|
|
187
|
+
* ── Why this is not `purpose_out_of_bounds` ─────────────────────────────
|
|
188
|
+
*
|
|
189
|
+
* Same shape of defect, different field, and the table's own rule is that a
|
|
190
|
+
* code and a rule are looked up together precisely so they cannot drift. A
|
|
191
|
+
* caller branching on `purpose_out_of_bounds` would go and rewrite the
|
|
192
|
+
* purpose it gave its *claim*, which was never wrong and is not what the
|
|
193
|
+
* refusal is about.
|
|
194
|
+
*
|
|
195
|
+
* ── Why it matters more than an ordinary bound ──────────────────────────
|
|
196
|
+
*
|
|
197
|
+
* This is the one free-text field in the service that is **relayed to a
|
|
198
|
+
* person verbatim by a third party**. Everything else a caller writes is
|
|
199
|
+
* read by an operator with the ledger in front of them; this is read by
|
|
200
|
+
* somebody who has been interrupted and told to go and type a password. An
|
|
201
|
+
* empty one produces a request that reaches a person saying nothing about
|
|
202
|
+
* what they are signing into, which is a request they cannot act on.
|
|
203
|
+
*
|
|
204
|
+
* Not retryable: the identical call with the identical argument fails
|
|
205
|
+
* identically forever. What changes it is the caller writing a sentence.
|
|
206
|
+
*/
|
|
207
|
+
sign_in_what_out_of_bounds: {
|
|
208
|
+
rule: 'signin.what_bounded',
|
|
209
|
+
summary: 'A sign-in request says what is being signed into, three to two hundred characters.',
|
|
210
|
+
retryable: false,
|
|
211
|
+
},
|
|
212
|
+
/**
|
|
213
|
+
* Not a §7.1 row, and it is here because the arbitration runner raises it
|
|
214
|
+
* (`arbitration.ts`). An operation named on a surface that this build does
|
|
215
|
+
* not register is a caller mistake rather than a crash, and it is the shape
|
|
216
|
+
* a version mismatch between a caller and this service arrives in.
|
|
217
|
+
*/
|
|
218
|
+
unknown_operation: {
|
|
219
|
+
rule: 'arbitration.registered',
|
|
220
|
+
summary: 'The operation named is one this build registers.',
|
|
221
|
+
retryable: false,
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
/** The codes as data, for a test that walks them. */
|
|
225
|
+
export const REFUSAL_CODES = Object.keys(REFUSALS);
|
|
226
|
+
/**
|
|
227
|
+
* A refusal from §7.1 — checked on a call, and the call does not happen.
|
|
228
|
+
*
|
|
229
|
+
* Distinct from `StartupRefusal` because the two are acted on differently and
|
|
230
|
+
* by different code: a startup refusal means this process does not run at
|
|
231
|
+
* all, and a call refusal means this one call did not, on a process that is
|
|
232
|
+
* otherwise serving fine. An entry point that could not tell them apart would
|
|
233
|
+
* either exit the process over a bad argument or carry on over a bad
|
|
234
|
+
* configuration.
|
|
235
|
+
*
|
|
236
|
+
* **The rule is looked up rather than passed.** A caller supplies the code
|
|
237
|
+
* and the table supplies the rule, so the pair cannot drift and a surface
|
|
238
|
+
* cannot quietly attribute a refusal to a rule that did not make it.
|
|
239
|
+
*/
|
|
240
|
+
export class CallRefusal extends BrokerError {
|
|
241
|
+
/** The stable code the caller matches on (§3.14). */
|
|
242
|
+
code;
|
|
243
|
+
/** Whether the identical call could succeed later. Read from the table. */
|
|
244
|
+
retryable;
|
|
245
|
+
/**
|
|
246
|
+
* The rest, shaped per refusal — §3.14's "any details". The state a lease
|
|
247
|
+
* ended in, the position in the queue, the two numbers that disagreed.
|
|
248
|
+
*
|
|
249
|
+
* **Not the sentence and not a substitute for it.** The sentence is for a
|
|
250
|
+
* person and is deliberately worded differently per surface (§3.14); this
|
|
251
|
+
* is for a caller that has already branched on the code and now needs the
|
|
252
|
+
* number.
|
|
253
|
+
*/
|
|
254
|
+
detail;
|
|
255
|
+
constructor(code, message, options = {}) {
|
|
256
|
+
super(REFUSALS[code].rule, message, { cause: options.cause });
|
|
257
|
+
this.name = 'CallRefusal';
|
|
258
|
+
this.code = code;
|
|
259
|
+
this.retryable = REFUSALS[code].retryable;
|
|
260
|
+
this.detail = options.detail ?? {};
|
|
261
|
+
}
|
|
262
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { readEnvironment } from "../config/environment.js";
|
|
2
|
+
import { prepareStore } from "../store/open.js";
|
|
3
|
+
import { ArtifactStore } from "../artifacts/store.js";
|
|
4
|
+
import { browserSessionProvider } from "./browser-session.js";
|
|
5
|
+
import { serviceFor } from "./bridge.js";
|
|
6
|
+
import { createBroker } from "./broker.js";
|
|
7
|
+
/**
|
|
8
|
+
* Build the service a shipped binary serves, **with a real browser behind it**.
|
|
9
|
+
*
|
|
10
|
+
* ── What changed here, and why it is now correct to do it ───────────────
|
|
11
|
+
*
|
|
12
|
+
* This function used to supply no session source, and said so at length: a
|
|
13
|
+
* page verb decided, renewed, checked ownership and wrote its ledger row, and
|
|
14
|
+
* then moved no page, because there was no page. That was honest and it is now
|
|
15
|
+
* obsolete. The two reasons it gave have both been answered:
|
|
16
|
+
*
|
|
17
|
+
* 1. `act` and `read` are implemented on the real driver, and the directory
|
|
18
|
+
* they write into is supplied here from the artifact store rather than
|
|
19
|
+
* left to the driver's temporary default.
|
|
20
|
+
* 2. Attach-against-launch is not decided here at all. It is decided in the
|
|
21
|
+
* store, by `decideAdoption`, in the same transaction that arbitrates
|
|
22
|
+
* claims — which is where §1.2a puts it. `service/browser-session.ts`
|
|
23
|
+
* composes that decision with the driver that performs it.
|
|
24
|
+
*
|
|
25
|
+
* **Row #55, settled.** The launch-race loser polls with a ceiling *in this
|
|
26
|
+
* process* and refuses when it is reached, rather than proceeding as if it
|
|
27
|
+
* had a browser; see `browser-session.ts` for the argument in full.
|
|
28
|
+
*
|
|
29
|
+
* ── Nothing is acquired here, and that is the load-bearing part ─────────
|
|
30
|
+
*
|
|
31
|
+
* `browserSessionProvider` returns a **function**, and this hands that
|
|
32
|
+
* function to the broker. No browser is launched, attached to, or looked for
|
|
33
|
+
* until a page verb actually needs one, inside an after-commit closure.
|
|
34
|
+
*
|
|
35
|
+
* That is what keeps every other path working on a machine with no browser at
|
|
36
|
+
* all: `claim`, `status`, `release`, `feedback`, `doctor`, every refusal, and
|
|
37
|
+
* the continuous-integration job that spawns these executables on a runner
|
|
38
|
+
* where no browser is installed. A build that connected eagerly would make all
|
|
39
|
+
* of them depend on something most of them never use.
|
|
40
|
+
*
|
|
41
|
+
* ── And a browser that cannot be reached is reported, not hidden ────────
|
|
42
|
+
*
|
|
43
|
+
* After-commit failures are swallowed by design (§2.4b), so a browser that
|
|
44
|
+
* fails to launch or dies mid-operation produces no error a caller can see.
|
|
45
|
+
* What stops that becoming a lie is that `pageDriven` is settled **after** the
|
|
46
|
+
* work has run rather than predicted before it: the arbitration half is
|
|
47
|
+
* reported as the `accepted` fact it genuinely is, and the page half is
|
|
48
|
+
* reported as `false`. See `operations/pages.ts` for why that is a field on an
|
|
49
|
+
* accepted result rather than a refusal.
|
|
50
|
+
*/
|
|
51
|
+
export async function createRuntime(options) {
|
|
52
|
+
const environment = readEnvironment({ env: options.env });
|
|
53
|
+
// **The spawn path, not a hand-assembled equivalent of it.** This used to
|
|
54
|
+
// open and step inline, which silently omitted the third thing a spawn owes:
|
|
55
|
+
// `budget.agrees_with_store` (§1.10, §7.2). Both shipped binaries build
|
|
56
|
+
// their service here, so that omission meant the one value several processes
|
|
57
|
+
// must agree on was never recorded and never compared in anything that
|
|
58
|
+
// shipped. `prepareStore` closes the handle itself if any of the three
|
|
59
|
+
// refuses, so there is no partially-opened store to clean up here.
|
|
60
|
+
const store = await prepareStore(environment);
|
|
61
|
+
const artifacts = new ArtifactStore(environment.artifactsRoot);
|
|
62
|
+
const browsers = browserSessionProvider({
|
|
63
|
+
store,
|
|
64
|
+
environment,
|
|
65
|
+
artifacts,
|
|
66
|
+
...(options.driver === undefined ? {} : { driver: options.driver }),
|
|
67
|
+
});
|
|
68
|
+
const broker = createBroker({
|
|
69
|
+
store,
|
|
70
|
+
environment,
|
|
71
|
+
adapter: options.adapter,
|
|
72
|
+
session: browsers.session,
|
|
73
|
+
artifacts,
|
|
74
|
+
// The same provider closes the tabs the sweep orphaned. Without one,
|
|
75
|
+
// `SCHEMA.md` §2.4b's "a leaked tab is not a leaked lease" describes a
|
|
76
|
+
// permanent state rather than a failure mode: an expired lease's page
|
|
77
|
+
// stays open for the life of the browser. Reclaiming capacity does not
|
|
78
|
+
// depend on a browser; reclaiming the page does.
|
|
79
|
+
closeTab: async (tab) => {
|
|
80
|
+
const session = await browsers.session(tab.browserId);
|
|
81
|
+
const opened = await resolveDriverTab(store.db, tab.tabId);
|
|
82
|
+
if (opened !== undefined) {
|
|
83
|
+
await session.closeTab({ browser: tab.browserId, driverTabId: opened });
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
let closed = false;
|
|
88
|
+
return {
|
|
89
|
+
service: serviceFor({ broker, db: store.db }),
|
|
90
|
+
broker,
|
|
91
|
+
store,
|
|
92
|
+
environment,
|
|
93
|
+
session: browsers.session,
|
|
94
|
+
close: () => {
|
|
95
|
+
if (closed) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
closed = true;
|
|
99
|
+
// **Detaching is deliberately not awaited, and the store is closed
|
|
100
|
+
// regardless.** `close` is synchronous because its callers are: the
|
|
101
|
+
// command line closes in a `finally` after one command, and the tool
|
|
102
|
+
// shim closes when its input stream ends.
|
|
103
|
+
//
|
|
104
|
+
// Letting go of a connection is not closing a browser — `real.ts`
|
|
105
|
+
// measures that attaching and detaching are non-destructive, and a
|
|
106
|
+
// browser is adopted rather than owned, so a process that exits without
|
|
107
|
+
// having finished detaching leaves the browser exactly where it was.
|
|
108
|
+
// The connection dies with the process either way. What must not happen
|
|
109
|
+
// is the store staying open, and it does not.
|
|
110
|
+
void browsers.close().catch(() => {
|
|
111
|
+
// Nothing can act on a failure to let go of a connection.
|
|
112
|
+
});
|
|
113
|
+
store.close();
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The driver's name for a tab, or nothing if it never had one.
|
|
119
|
+
*
|
|
120
|
+
* Read here rather than carried on {@link OrphanedTab} because that type is
|
|
121
|
+
* the arbitration transaction's, and the driver name is only wanted **after**
|
|
122
|
+
* the commit, by the one caller that has a browser to ask. A tab that was
|
|
123
|
+
* never opened has no page, so there is nothing to close and this returns
|
|
124
|
+
* nothing rather than asking the browser about a page that does not exist.
|
|
125
|
+
*/
|
|
126
|
+
function resolveDriverTab(db, tabId) {
|
|
127
|
+
const row = db
|
|
128
|
+
.prepare('SELECT driver_tab_id AS driverTabId FROM tabs WHERE id = ?')
|
|
129
|
+
.get(tabId);
|
|
130
|
+
return Promise.resolve(row?.driverTabId ?? undefined);
|
|
131
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reclaiming a sign-in whose owner is gone (`SCHEMA.md` §5.5.1).
|
|
3
|
+
*
|
|
4
|
+
* ══════════════════════════════════════════════════════════════════════════
|
|
5
|
+
* WHY THIS EXISTS EVEN THOUGH A SIGNAL HANDLER ALSO EXISTS
|
|
6
|
+
* ══════════════════════════════════════════════════════════════════════════
|
|
7
|
+
*
|
|
8
|
+
* `broker login` installs a handler that gives the browser back on `SIGINT`
|
|
9
|
+
* and `SIGTERM`, which covers Ctrl-C — the way a person actually stops a
|
|
10
|
+
* command that is sitting there waiting. **That handler is not sufficient and
|
|
11
|
+
* this module is not a belt on top of it.**
|
|
12
|
+
*
|
|
13
|
+
* A handler is code the dying process runs, so it covers exactly the deaths
|
|
14
|
+
* that let a process run code. It does not cover `SIGKILL`, which cannot be
|
|
15
|
+
* handled by design. It does not cover a power cut, a battery running out, or
|
|
16
|
+
* a machine being reset. It does not cover the process being killed by an
|
|
17
|
+
* out-of-memory reaper, and it does not cover a crash in the runtime itself.
|
|
18
|
+
* In every one of those the browser stays `signing-in` and — before this
|
|
19
|
+
* module — **stayed that way forever**, refusing every caller and refusing the
|
|
20
|
+
* second `broker login` that would have ended it, so the only exit was editing
|
|
21
|
+
* the database by hand.
|
|
22
|
+
*
|
|
23
|
+
* So the guarantee is split deliberately, and each half is honest about its
|
|
24
|
+
* reach:
|
|
25
|
+
*
|
|
26
|
+
* - **The handler** returns the browser promptly on an interruption, which is
|
|
27
|
+
* the common case and the one where a person is watching.
|
|
28
|
+
* - **This module** makes the state recoverable when nothing ran at all. It
|
|
29
|
+
* asks a question about the world rather than trusting that some earlier
|
|
30
|
+
* process did its job.
|
|
31
|
+
*
|
|
32
|
+
* ══════════════════════════════════════════════════════════════════════════
|
|
33
|
+
* WHY LIVENESS IS ASKED OF THE OWNER, AND WHAT THE ANSWER IS WORTH
|
|
34
|
+
* ══════════════════════════════════════════════════════════════════════════
|
|
35
|
+
*
|
|
36
|
+
* A sign-in has no expiry (§5.5.1) — a person takes as long as they take, and
|
|
37
|
+
* a timeout would end a sign-in that was going fine. So there is no lapse for
|
|
38
|
+
* the lease sweep to notice, and the sweep is not extended to invent one.
|
|
39
|
+
* The question is settled by evidence instead: **the process that began the
|
|
40
|
+
* sign-in is either running or it is not.**
|
|
41
|
+
*
|
|
42
|
+
* ── The one inference this module draws, and the one it refuses to ──────
|
|
43
|
+
*
|
|
44
|
+
* The same discipline `login-command.ts` states for browsers applies here, and
|
|
45
|
+
* it points the other way round, so it is worth being exact.
|
|
46
|
+
*
|
|
47
|
+
* - **A process that does not exist is not signing anybody in.** That is a
|
|
48
|
+
* sound negative: `ESRCH` from a zero-signal means no process holds that
|
|
49
|
+
* identifier now, and a command that is not running cannot be waiting for a
|
|
50
|
+
* person. This is the only conclusion used to reclaim.
|
|
51
|
+
* - **A process that exists is not evidence the sign-in is live**, because
|
|
52
|
+
* process identifiers are reused. This module therefore never reports a
|
|
53
|
+
* live owner as proof of anything — it simply declines to reclaim, which is
|
|
54
|
+
* the safe direction: the cost of declining is that a person runs one more
|
|
55
|
+
* command, and the cost of reclaiming wrongly is a browser taken out from
|
|
56
|
+
* under somebody mid-sign-in.
|
|
57
|
+
*
|
|
58
|
+
* **Identifier reuse is why the live answer is worth so much less than the
|
|
59
|
+
* dead one.** A reused identifier can only make this module *more* cautious,
|
|
60
|
+
* never less: it can turn a dead owner into an apparently-live one and cost a
|
|
61
|
+
* reclamation, and it cannot turn a live owner into a dead one.
|
|
62
|
+
*
|
|
63
|
+
* ── An unknown owner is not a dead owner ────────────────────────────────
|
|
64
|
+
*
|
|
65
|
+
* A store written before the owner column existed carries `signing-in` rows
|
|
66
|
+
* with no owner recorded. Those are reported as **unknown**, never reclaimed:
|
|
67
|
+
* reclaiming on the strength of a missing record would mean ending a sign-in
|
|
68
|
+
* because an old build did not write down who started it, which is a guess
|
|
69
|
+
* dressed as a fact. A person is told what was found and what to do.
|
|
70
|
+
*/
|
|
71
|
+
/**
|
|
72
|
+
* The real answer: signal zero.
|
|
73
|
+
*
|
|
74
|
+
* Signal zero performs the error checking a real signal would and **sends
|
|
75
|
+
* nothing**, which is the standard way to ask whether an identifier is live
|
|
76
|
+
* without disturbing what holds it.
|
|
77
|
+
*
|
|
78
|
+
* `EPERM` is treated as **alive**, and that is the load-bearing case rather
|
|
79
|
+
* than a detail: it means a process with that identifier exists and belongs to
|
|
80
|
+
* somebody this user may not signal. Reading a permission error as "gone"
|
|
81
|
+
* would reclaim a browser out from under a sign-in that is running perfectly
|
|
82
|
+
* well under another account — the exact failure this module must not cause,
|
|
83
|
+
* and the one the caution above is spent on.
|
|
84
|
+
*/
|
|
85
|
+
export function livenessFromSignalError(error) {
|
|
86
|
+
const code = error?.code;
|
|
87
|
+
if (code === 'ESRCH') {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
// `EPERM` — and anything else this cannot interpret — is read as alive.
|
|
91
|
+
// **The unsafe direction is concluding "gone"**, so an unrecognised error
|
|
92
|
+
// never reaches it: a permission error means a process with that identifier
|
|
93
|
+
// exists and belongs to somebody this user may not signal, and reading that
|
|
94
|
+
// as gone would reclaim a browser out from under a sign-in running perfectly
|
|
95
|
+
// well under another account.
|
|
96
|
+
//
|
|
97
|
+
// Split out of {@link processIsRunning} so this decision is reachable from a
|
|
98
|
+
// test. It is the branch a real process cannot be made to produce on demand
|
|
99
|
+
// — you cannot conjure an `EPERM` from a process you own — and it is the one
|
|
100
|
+
// whose failure is silent and severe.
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
export const processIsRunning = (pid) => {
|
|
104
|
+
// A non-positive identifier is not a process. Guarded rather than passed
|
|
105
|
+
// through, because zero and the negatives address process *groups* on a
|
|
106
|
+
// POSIX system: `kill(0, 0)` asks about the caller's own group and would
|
|
107
|
+
// answer "alive" about whatever is asking, turning a nonsense value into a
|
|
108
|
+
// confident wrong answer.
|
|
109
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
process.kill(pid, 0);
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
return livenessFromSignalError(error);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* Classify a browser row against the world.
|
|
122
|
+
*
|
|
123
|
+
* Pure given its liveness function, so every branch is reachable from a test
|
|
124
|
+
* — including the two that matter most, a dead owner and an unrecorded one,
|
|
125
|
+
* neither of which can be produced on demand by killing something real.
|
|
126
|
+
*/
|
|
127
|
+
export function classifySignIn(row, isRunning = processIsRunning) {
|
|
128
|
+
if (row === undefined || row.state !== 'signing-in') {
|
|
129
|
+
return { kind: 'not-signing-in' };
|
|
130
|
+
}
|
|
131
|
+
const pid = row.signin_owner_pid;
|
|
132
|
+
if (pid === null) {
|
|
133
|
+
return { kind: 'owner-unknown' };
|
|
134
|
+
}
|
|
135
|
+
return isRunning(pid) ? { kind: 'owner-running', pid } : { kind: 'owner-gone', pid };
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* What a person is told about a sign-in that cannot be recovered
|
|
139
|
+
* automatically, and what to do about it.
|
|
140
|
+
*
|
|
141
|
+
* Written here rather than at each surface so `doctor` and the refusal a
|
|
142
|
+
* second `login` produces say the same thing. Two spellings of one remedy is
|
|
143
|
+
* how they come to disagree, and the disagreement is discovered by somebody
|
|
144
|
+
* who is already stuck.
|
|
145
|
+
*/
|
|
146
|
+
export const SIGN_IN_OWNER_UNKNOWN_REMEDY = 'This store does not record which process began that sign-in, so it cannot be confirmed abandoned. ' +
|
|
147
|
+
'If nobody is signing in, run `broker login` — it reclaims a sign-in whose process is gone, and ' +
|
|
148
|
+
'a sign-in recorded without an owner is left alone rather than ended on a guess.';
|