browser-broker 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/.env.example +173 -0
  2. package/LICENSE +21 -0
  3. package/README.md +374 -0
  4. package/RELEASES.md +97 -0
  5. package/dist/package.json +58 -0
  6. package/dist/src/adapter/conformance/case.js +1 -0
  7. package/dist/src/adapter/conformance/cases.js +429 -0
  8. package/dist/src/adapter/conformance/discovery.js +156 -0
  9. package/dist/src/adapter/conformance/driver.js +1 -0
  10. package/dist/src/adapter/conformance/drivers.js +36 -0
  11. package/dist/src/adapter/conformance/run.js +224 -0
  12. package/dist/src/adapter/conformance/service-subject.js +165 -0
  13. package/dist/src/adapter/contract.js +24 -0
  14. package/dist/src/adapter/operations.js +114 -0
  15. package/dist/src/adapter/service-seam.js +1 -0
  16. package/dist/src/artifacts/names.js +229 -0
  17. package/dist/src/artifacts/store.js +174 -0
  18. package/dist/src/bin/broker-tool.js +63 -0
  19. package/dist/src/bin/broker.js +111 -0
  20. package/dist/src/browser/adoption.js +143 -0
  21. package/dist/src/browser/automation-probe.js +113 -0
  22. package/dist/src/browser/conformance/case.js +1 -0
  23. package/dist/src/browser/conformance/cases.js +192 -0
  24. package/dist/src/browser/conformance/run.js +102 -0
  25. package/dist/src/browser/conformance/subjects.js +19 -0
  26. package/dist/src/browser/discovery.js +226 -0
  27. package/dist/src/browser/driver.js +195 -0
  28. package/dist/src/browser/fake.js +585 -0
  29. package/dist/src/browser/launch.js +504 -0
  30. package/dist/src/browser/real.js +1425 -0
  31. package/dist/src/browser/setup.js +161 -0
  32. package/dist/src/capture/accounting.js +59 -0
  33. package/dist/src/capture/image.js +112 -0
  34. package/dist/src/capture/ladder.js +72 -0
  35. package/dist/src/capture/legibility.js +195 -0
  36. package/dist/src/capture/pipeline.js +153 -0
  37. package/dist/src/capture/tiers.js +166 -0
  38. package/dist/src/cli/adapter.js +233 -0
  39. package/dist/src/cli/commands.js +270 -0
  40. package/dist/src/cli/conformance-driver.js +119 -0
  41. package/dist/src/cli/diffs.js +122 -0
  42. package/dist/src/cli/image.js +274 -0
  43. package/dist/src/cli/index.js +895 -0
  44. package/dist/src/cli/login-command.js +401 -0
  45. package/dist/src/cli/operations-commands.js +186 -0
  46. package/dist/src/cli/reconcile-command.js +137 -0
  47. package/dist/src/cli/sign-in.js +134 -0
  48. package/dist/src/cli/telemetry.js +222 -0
  49. package/dist/src/config/environment.js +446 -0
  50. package/dist/src/diff/artifact-path.js +77 -0
  51. package/dist/src/diff/crops.js +102 -0
  52. package/dist/src/diff/geometry.js +122 -0
  53. package/dist/src/diff/image.js +132 -0
  54. package/dist/src/diff/mask.js +46 -0
  55. package/dist/src/diff/regions.js +263 -0
  56. package/dist/src/diff/settings.js +135 -0
  57. package/dist/src/doctor/checks.js +588 -0
  58. package/dist/src/doctor/report.js +152 -0
  59. package/dist/src/doctor/session.js +161 -0
  60. package/dist/src/errors.js +36 -0
  61. package/dist/src/feedback/read.js +119 -0
  62. package/dist/src/feedback/record.js +199 -0
  63. package/dist/src/operations/addresses.js +175 -0
  64. package/dist/src/operations/derive.js +109 -0
  65. package/dist/src/operations/ledger.js +194 -0
  66. package/dist/src/operations/status.js +197 -0
  67. package/dist/src/operations/telemetry.js +280 -0
  68. package/dist/src/report/document.js +419 -0
  69. package/dist/src/report/escape.js +68 -0
  70. package/dist/src/report/snapshot.js +97 -0
  71. package/dist/src/service/arbitration.js +537 -0
  72. package/dist/src/service/artifacts.js +85 -0
  73. package/dist/src/service/bridge.js +577 -0
  74. package/dist/src/service/broker.js +120 -0
  75. package/dist/src/service/browser-session.js +269 -0
  76. package/dist/src/service/capacity.js +62 -0
  77. package/dist/src/service/capture-seam.js +83 -0
  78. package/dist/src/service/capture-store.js +91 -0
  79. package/dist/src/service/comparison-store.js +101 -0
  80. package/dist/src/service/comparison.js +173 -0
  81. package/dist/src/service/events.js +93 -0
  82. package/dist/src/service/keys.js +68 -0
  83. package/dist/src/service/leases.js +147 -0
  84. package/dist/src/service/nudge.js +66 -0
  85. package/dist/src/service/operations/claim.js +692 -0
  86. package/dist/src/service/operations/give-back.js +131 -0
  87. package/dist/src/service/operations/pages.js +771 -0
  88. package/dist/src/service/operations/sign-in.js +915 -0
  89. package/dist/src/service/operations/status.js +62 -0
  90. package/dist/src/service/ownership.js +93 -0
  91. package/dist/src/service/pages.js +616 -0
  92. package/dist/src/service/pending-seeds.js +20 -0
  93. package/dist/src/service/queue.js +233 -0
  94. package/dist/src/service/reconcile.js +220 -0
  95. package/dist/src/service/refusals.js +262 -0
  96. package/dist/src/service/runtime.js +131 -0
  97. package/dist/src/service/signin-recovery.js +148 -0
  98. package/dist/src/service/storage-seed.js +239 -0
  99. package/dist/src/service/tabs.js +123 -0
  100. package/dist/src/store/budget.js +99 -0
  101. package/dist/src/store/location.js +42 -0
  102. package/dist/src/store/network-path.js +182 -0
  103. package/dist/src/store/network-volume.js +92 -0
  104. package/dist/src/store/open.js +226 -0
  105. package/dist/src/store/schema/step-001-initial.js +523 -0
  106. package/dist/src/store/schema/step-002-tab-budget.js +53 -0
  107. package/dist/src/store/schema/step-003-queue-order.js +110 -0
  108. package/dist/src/store/schema/step-004-tab-never-opened.js +100 -0
  109. package/dist/src/store/schema/step-005-storage-seed-event.js +90 -0
  110. package/dist/src/store/schema/step-006-signin-events.js +104 -0
  111. package/dist/src/store/schema/step-007-signin-without-process.js +92 -0
  112. package/dist/src/store/schema/step-008-signin-owner.js +76 -0
  113. package/dist/src/store/schema/step-009-named-browsers.js +138 -0
  114. package/dist/src/store/schema/step-010-signin-request.js +135 -0
  115. package/dist/src/store/schema/step.js +172 -0
  116. package/dist/src/store/schema/steps.js +58 -0
  117. package/dist/src/store/transaction.js +37 -0
  118. package/dist/src/tool/adapter.js +90 -0
  119. package/dist/src/tool/conformance-driver.js +184 -0
  120. package/dist/src/tool/protocol.js +310 -0
  121. package/dist/src/tool/session.js +351 -0
  122. package/dist/src/tool/tools.js +310 -0
  123. package/package.json +58 -0
@@ -0,0 +1,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.';