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,233 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { admits, countActiveClaims } from "./capacity.js";
|
|
3
|
+
import { append } from "./events.js";
|
|
4
|
+
/**
|
|
5
|
+
* The queue: strictly first in, first out (§1.5, §2.5).
|
|
6
|
+
*
|
|
7
|
+
* **There is no queue table.** The queue is simply the leases whose state is
|
|
8
|
+
* `queued`, in the order they arrived. §1.5 argues the alternative looks
|
|
9
|
+
* tidier and is worse: a queue table means a waiting lease exists twice, once
|
|
10
|
+
* as a lease and once as an entry, and the moment the two disagree the
|
|
11
|
+
* service has two answers to *"where am I"*. Admission is one field changing
|
|
12
|
+
* on the row that was already there, which cannot half-happen.
|
|
13
|
+
*
|
|
14
|
+
* ── Why this is close to trivially correct, which is a consequence ──────
|
|
15
|
+
*
|
|
16
|
+
* Every request is one tab (§2.3), so a freed tab always fits the front of
|
|
17
|
+
* the queue. That is not a rule anybody enforces here; it falls out of there
|
|
18
|
+
* being no such thing as a larger or smaller request. What follows:
|
|
19
|
+
*
|
|
20
|
+
* - **Nothing to skip ahead of**, so there is no scheduling decision.
|
|
21
|
+
* - **No aging rule**, because a caller cannot be overtaken forever by
|
|
22
|
+
* smaller requests when no such request exists.
|
|
23
|
+
* - **A position only ever improves**, by exactly one each time a tab frees.
|
|
24
|
+
*
|
|
25
|
+
* **What is real is the dead entry at the head**, and it is the one failure
|
|
26
|
+
* invisible in a capacity count — the count is correct the whole time while
|
|
27
|
+
* an entry that will never take its tab blocks everyone behind it. Queue
|
|
28
|
+
* entries therefore expire by the same sweep as leases (§2.4), and that
|
|
29
|
+
* expiry is not housekeeping: **it is the only thing that makes strictness
|
|
30
|
+
* safe.**
|
|
31
|
+
*/
|
|
32
|
+
/**
|
|
33
|
+
* Where a lease sits in the queue, counting from one.
|
|
34
|
+
*
|
|
35
|
+
* **Computed rather than stored** (§1.3): storing it means rewriting every
|
|
36
|
+
* waiting row each time one is admitted, and a stored position is a second
|
|
37
|
+
* fact that can disagree with the order.
|
|
38
|
+
*
|
|
39
|
+
* **Ordered by `arrival`, a counter the database allocates at insert**, and
|
|
40
|
+
* that column exists because the ordering §1.5 describes does not keep the
|
|
41
|
+
* promise §2.5 makes. Ordering by `created_at` tie-broken by `id` is stable —
|
|
42
|
+
* which is what §1.5 argues for — but the tie-break is a random identifier,
|
|
43
|
+
* so among callers arriving inside one millisecond the order bears no
|
|
44
|
+
* relation to arrival. **Measured: a caller told position 1 is told position
|
|
45
|
+
* 2 on its next call**, because a caller that really did arrive earlier was
|
|
46
|
+
* placed ahead of it afterwards. §2.5 promises a position that only ever
|
|
47
|
+
* improves, and a counter has no ties to get that wrong with.
|
|
48
|
+
*
|
|
49
|
+
* It is not `activated_at`, which is null for everyone waiting and is set at
|
|
50
|
+
* the moment you stop waiting — the answer rather than the question.
|
|
51
|
+
*/
|
|
52
|
+
export function queuePosition(db, claimId) {
|
|
53
|
+
const row = db
|
|
54
|
+
.prepare(`SELECT count(*) + 1 AS position
|
|
55
|
+
FROM claims AS ahead
|
|
56
|
+
WHERE ahead.state = 'queued'
|
|
57
|
+
AND ahead.arrival <
|
|
58
|
+
(SELECT self.arrival FROM claims AS self WHERE self.id = @claimId)`)
|
|
59
|
+
.get({ claimId });
|
|
60
|
+
return row.position;
|
|
61
|
+
}
|
|
62
|
+
/** How many leases are waiting, in total. */
|
|
63
|
+
export function queueDepth(db) {
|
|
64
|
+
const row = db.prepare("SELECT count(*) AS n FROM claims WHERE state = 'queued'").get();
|
|
65
|
+
return row.n;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* How long recent leases were actually held, in seconds.
|
|
69
|
+
*
|
|
70
|
+
* **Deliberately not computed from the expiry** (§1.5): a lease that keeps
|
|
71
|
+
* being renewed runs far past its expiry, so an estimate built on that would
|
|
72
|
+
* be confidently wrong in the common case rather than vaguely wrong in all of
|
|
73
|
+
* them. It is `ended_at` minus `activated_at` over leases that actually ended,
|
|
74
|
+
* which is the only place that number can come from once the ledger has been
|
|
75
|
+
* trimmed.
|
|
76
|
+
*
|
|
77
|
+
* Returns `undefined` when nothing has ended yet, because a fresh
|
|
78
|
+
* installation has no history to average and inventing one would produce a
|
|
79
|
+
* confident number from no evidence.
|
|
80
|
+
*/
|
|
81
|
+
export function recentHoldSeconds(db, sample = 20) {
|
|
82
|
+
const rows = db
|
|
83
|
+
.prepare(`SELECT (julianday(ended_at) - julianday(activated_at)) * 86400.0 AS held
|
|
84
|
+
FROM claims
|
|
85
|
+
WHERE activated_at IS NOT NULL AND ended_at IS NOT NULL
|
|
86
|
+
ORDER BY ended_at DESC
|
|
87
|
+
LIMIT @sample`)
|
|
88
|
+
.all({ sample });
|
|
89
|
+
const held = rows.map((row) => row.held).filter((value) => value !== null);
|
|
90
|
+
if (held.length === 0) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
return held.reduce((total, value) => total + value, 0) / held.length;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The wait estimate: how many are ahead, multiplied by how long leases are
|
|
97
|
+
* held.
|
|
98
|
+
*
|
|
99
|
+
* **A weak number, and it is labelled as one everywhere it appears** (§1.5).
|
|
100
|
+
* It is, however, a better number than it would be under variable request
|
|
101
|
+
* sizes: with every request the same size, "how many are ahead of me"
|
|
102
|
+
* translates directly into how many tabs have to come free, which is one
|
|
103
|
+
* fewer piece of guesswork in the same calculation.
|
|
104
|
+
*
|
|
105
|
+
* `undefined` when there is no history to base it on, so a caller can say
|
|
106
|
+
* *"no estimate yet"* rather than print a zero that reads as *"any moment
|
|
107
|
+
* now"*.
|
|
108
|
+
*/
|
|
109
|
+
export function waitEstimateSeconds(db, position) {
|
|
110
|
+
const held = recentHoldSeconds(db);
|
|
111
|
+
if (held === undefined) {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
// Position counts from one and the caller ahead of nobody still waits for
|
|
115
|
+
// one lease to end, so the multiplier is the position itself.
|
|
116
|
+
return Math.round(position * held);
|
|
117
|
+
}
|
|
118
|
+
/** The lease at the front of the queue, or nothing if none is waiting. */
|
|
119
|
+
export function headOfQueue(db) {
|
|
120
|
+
return db
|
|
121
|
+
.prepare(`SELECT id, browser_id AS browserId
|
|
122
|
+
FROM claims
|
|
123
|
+
WHERE state = 'queued'
|
|
124
|
+
ORDER BY arrival
|
|
125
|
+
LIMIT 1`)
|
|
126
|
+
.get();
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Promote the front of the queue for as long as capacity exists.
|
|
130
|
+
*
|
|
131
|
+
* **This is the whole scheduling rule** (§2.5): the sweep promotes the front
|
|
132
|
+
* while capacity exists and stops when it does not. There is no arithmetic in
|
|
133
|
+
* the step — no size to check a freed tab against — because every waiting
|
|
134
|
+
* request is the same size as every freed tab.
|
|
135
|
+
*
|
|
136
|
+
* ── What a promotion does and does not do here ──────────────────────────
|
|
137
|
+
*
|
|
138
|
+
* It flips the lease to `active`, stamps `activated_at`, and puts the lease
|
|
139
|
+
* on a **fresh** lifetime: the promoted caller has just been given a tab and
|
|
140
|
+
* its clock starts now rather than continuing from a queue place it has
|
|
141
|
+
* already spent. `ttl_seconds` moves to the active duration with it, because
|
|
142
|
+
* §1.3 requires a renewal to extend by the duration the caller was told and
|
|
143
|
+
* the caller is being told this one on this response.
|
|
144
|
+
*
|
|
145
|
+
* **It creates the tab row but does not open the tab.** The row is required:
|
|
146
|
+
* §1.4 makes active leases and live tab rows the same count, always, so a
|
|
147
|
+
* promotion that moved the state without creating the row would leave the
|
|
148
|
+
* store one short of that identity — invisibly, because a capacity count
|
|
149
|
+
* counts claims. Opening the page is browser work and browser work never
|
|
150
|
+
* happens inside the arbitration transaction (§2.4b), so the row is created
|
|
151
|
+
* `opening` with no driver name and the driver call belongs to the caller's
|
|
152
|
+
* after-commit work.
|
|
153
|
+
*
|
|
154
|
+
* The loop is bounded by capacity and by the queue's own length, both of
|
|
155
|
+
* which shrink on each pass, so it terminates on the smaller of the two.
|
|
156
|
+
*/
|
|
157
|
+
export function promoteWhileCapacity(db, options) {
|
|
158
|
+
const promoted = [];
|
|
159
|
+
for (;;) {
|
|
160
|
+
if (!admits(countActiveClaims(db), options.budget)) {
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
const head = headOfQueue(db);
|
|
164
|
+
if (head === undefined) {
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
const row = db
|
|
168
|
+
.prepare(`UPDATE claims
|
|
169
|
+
SET state = 'active',
|
|
170
|
+
activated_at = @now,
|
|
171
|
+
expires_at = strftime('%Y-%m-%dT%H:%M:%fZ', @now, @extend),
|
|
172
|
+
ttl_seconds = @ttl,
|
|
173
|
+
updated_at = @now
|
|
174
|
+
WHERE id = @id AND state = 'queued'
|
|
175
|
+
RETURNING session_id AS sessionId, browser_id AS browserId`)
|
|
176
|
+
.get({
|
|
177
|
+
id: head.id,
|
|
178
|
+
now: options.now,
|
|
179
|
+
// The modifier is assembled from a number this process computed, not
|
|
180
|
+
// from anything a caller supplied.
|
|
181
|
+
extend: `+${String(options.leaseSeconds)} seconds`,
|
|
182
|
+
ttl: options.leaseSeconds,
|
|
183
|
+
});
|
|
184
|
+
if (row === undefined) {
|
|
185
|
+
// The lease stopped being queued between the read and the write. Inside
|
|
186
|
+
// one immediate transaction nothing else can have done that, so this is
|
|
187
|
+
// a bug rather than a race — but breaking is the safe response either
|
|
188
|
+
// way, and looping again on an unchanged head would not terminate.
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
// The tab row, created here rather than left to the caller.
|
|
192
|
+
//
|
|
193
|
+
// **A promoted lease is an active lease, and §1.4 makes active leases and
|
|
194
|
+
// live tab rows the same count, always** — "a lease is a tab (§2.3), so
|
|
195
|
+
// this table and the live part of `claims` have the same number of rows".
|
|
196
|
+
// A promotion that flipped the state without creating the row would put
|
|
197
|
+
// the store one short of that identity, and the shortfall would be
|
|
198
|
+
// invisible to a capacity count, which counts claims.
|
|
199
|
+
//
|
|
200
|
+
// `opening` with no driver name, exactly as a grant creates one
|
|
201
|
+
// (`claim.ts`): opening the page is browser work, and browser work never
|
|
202
|
+
// happens inside the arbitration transaction (§2.4b).
|
|
203
|
+
const tabId = randomUUID();
|
|
204
|
+
db.prepare(`INSERT INTO tabs (id, claim_id, browser_id, state, created_at, updated_at)
|
|
205
|
+
VALUES (@tabId, @claimId, @browserId, 'opening', @now, @now)`).run({ tabId, claimId: head.id, browserId: row.browserId, now: options.now });
|
|
206
|
+
append(db, {
|
|
207
|
+
kind: 'claim_promoted',
|
|
208
|
+
outcome: 'allow',
|
|
209
|
+
adapter: options.adapter,
|
|
210
|
+
claimId: head.id,
|
|
211
|
+
tabId,
|
|
212
|
+
sessionId: row.sessionId,
|
|
213
|
+
browserId: row.browserId,
|
|
214
|
+
detail: { promotedAt: options.now },
|
|
215
|
+
});
|
|
216
|
+
append(db, {
|
|
217
|
+
kind: 'tab_opening',
|
|
218
|
+
outcome: 'allow',
|
|
219
|
+
adapter: options.adapter,
|
|
220
|
+
claimId: head.id,
|
|
221
|
+
tabId,
|
|
222
|
+
sessionId: row.sessionId,
|
|
223
|
+
browserId: row.browserId,
|
|
224
|
+
});
|
|
225
|
+
promoted.push({
|
|
226
|
+
claimId: head.id,
|
|
227
|
+
browserId: row.browserId,
|
|
228
|
+
sessionId: row.sessionId,
|
|
229
|
+
tabId,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
return promoted;
|
|
233
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decide what a live browser and the store disagree about.
|
|
3
|
+
*
|
|
4
|
+
* **Pure: no database, no driver, no clock.** Two lists in, one plan out.
|
|
5
|
+
*
|
|
6
|
+
* ── The two disagreements, and the direction of each ────────────────────
|
|
7
|
+
*
|
|
8
|
+
* §2.6 names them and this function is a transcription of that sentence:
|
|
9
|
+
* *"A page open that no live lease owns is closed; a tab a live lease
|
|
10
|
+
* believes it owns that is not there is marked closed and its lease ended."*
|
|
11
|
+
*
|
|
12
|
+
* - **A page the browser has that no row names** — something opened a tab and
|
|
13
|
+
* the row that would have owned it is gone, or was never written. Nobody is
|
|
14
|
+
* using it and nobody can: it is unaddressable, because addressing goes
|
|
15
|
+
* through `tabs.id` and there is no such row. It costs memory forever.
|
|
16
|
+
* - **A row naming a page the browser does not have** — the page died with a
|
|
17
|
+
* crash, or a person closed it by hand. The lease still counts against the
|
|
18
|
+
* budget while the thing it is a lease *over* does not exist.
|
|
19
|
+
*
|
|
20
|
+
* ── `opening` rows: the race, and why the answer is not a timeout ───────
|
|
21
|
+
*
|
|
22
|
+
* This is the part that is easy to get wrong, and getting it wrong closes a
|
|
23
|
+
* tab that was mid-open.
|
|
24
|
+
*
|
|
25
|
+
* `reserveTab` writes the row **before the browser is asked**, and says why:
|
|
26
|
+
* the identifier has to exist before anything can fail, or a tab that opened
|
|
27
|
+
* and then lost its answer is a page with no row naming it. The open itself
|
|
28
|
+
* is browser work, so it happens after the commit (§2.4b). Between those two
|
|
29
|
+
* moments the store holds an `opening` row and the browser is about to grow a
|
|
30
|
+
* page that no row yet names.
|
|
31
|
+
*
|
|
32
|
+
* **Reconciliation running inside that window sees exactly the shape it is
|
|
33
|
+
* built to destroy**: a page nothing owns. Closing it would close a tab whose
|
|
34
|
+
* lease was granted seconds ago and whose caller is about to be handed it.
|
|
35
|
+
*
|
|
36
|
+
* What makes this safe is not a heuristic and not a grace period. It is
|
|
37
|
+
* §1.4's check constraint:
|
|
38
|
+
*
|
|
39
|
+
* > `CHECK ((state = 'opening') = (driver_tab_id IS NULL))`
|
|
40
|
+
*
|
|
41
|
+
* An `opening` row has **no driver name, by database constraint**. So it
|
|
42
|
+
* cannot participate in either comparison — there is nothing to compare it
|
|
43
|
+
* against. It cannot be found vanished, because "vanished" means the browser
|
|
44
|
+
* does not list a name the row holds and it holds none. And the page it is
|
|
45
|
+
* about to acquire cannot be matched to it, because matching is by name.
|
|
46
|
+
*
|
|
47
|
+
* **Therefore the safe rule is: while any row is `opening`, no page can be
|
|
48
|
+
* proven unowned.** A page that looks unowned may be that row's, a
|
|
49
|
+
* millisecond before `recordTabOpened` names it. So this function refuses to
|
|
50
|
+
* close anything at all when an `opening` row is present, rather than trying
|
|
51
|
+
* to work out which page belongs to it — which is not knowable from here, and
|
|
52
|
+
* would be a guess dressed as a decision.
|
|
53
|
+
*
|
|
54
|
+
* That is deliberately blunt, and the bluntness is the argument: **the cost
|
|
55
|
+
* of declining is that a genuinely leaked page survives until the next run,
|
|
56
|
+
* and the cost of guessing is a caller losing a tab it was just granted.**
|
|
57
|
+
* Those are not comparable. Reconciliation is not the only thing that
|
|
58
|
+
* reclaims a page — the sweep closes an expired lease's tabs on every
|
|
59
|
+
* arbitration call (§2.4) — so a page this declines to close is a page that
|
|
60
|
+
* waits, not a page that leaks forever.
|
|
61
|
+
*
|
|
62
|
+
* The vanished half is unaffected and still runs: it is a claim about rows
|
|
63
|
+
* that name a page, and an `opening` row names none, so no `opening` row is
|
|
64
|
+
* ever settled by it. A row is only settled when it holds a driver name the
|
|
65
|
+
* browser did not list.
|
|
66
|
+
*
|
|
67
|
+
* ── What is not decided here ────────────────────────────────────────────
|
|
68
|
+
*
|
|
69
|
+
* **Expiry.** `arbitration.ts` owns time-based reclamation and nothing here
|
|
70
|
+
* reads a clock. The question this asks is *"does this page exist"*, which is
|
|
71
|
+
* a different question from *"has this lease lapsed"*, and answering the
|
|
72
|
+
* second one here would be the second spelling of the sweep that `tabs.ts`
|
|
73
|
+
* says to go and not write.
|
|
74
|
+
*
|
|
75
|
+
* **The keeper tab.** It is not in `pages` because `listTabs` excludes it
|
|
76
|
+
* (§3.15). It is worth stating what would happen if a driver ever included
|
|
77
|
+
* it, because the failure is silent and severe: the keeper is owned by no
|
|
78
|
+
* lease, so it would be decided unowned, and closing it kills the shared
|
|
79
|
+
* signed-in session — a headed browser dies within about half a second of its
|
|
80
|
+
* final tab closing. The conformance obligation that keeps this true belongs
|
|
81
|
+
* to the seam, and `tests/service/reconcile.test.ts` asserts the shape here
|
|
82
|
+
* on the assumption the seam holds up its end.
|
|
83
|
+
*/
|
|
84
|
+
export function decideReconciliation(pages, recorded) {
|
|
85
|
+
const skippedOpening = recorded.filter((tab) => tab.driverTabId === null).map((tab) => tab.tabId);
|
|
86
|
+
// Every driver name a live lease claims. Built from the rows that have one,
|
|
87
|
+
// which by §1.4's check is exactly the rows that are not `opening`.
|
|
88
|
+
const owned = new Set(recorded
|
|
89
|
+
.map((tab) => tab.driverTabId)
|
|
90
|
+
.filter((driverTabId) => driverTabId !== null));
|
|
91
|
+
const listed = new Set(pages.map((page) => page.driverTabId));
|
|
92
|
+
// A row holding a name the browser did not list. Safe regardless of the
|
|
93
|
+
// `opening` window: a row in that window holds no name and so is never
|
|
94
|
+
// here.
|
|
95
|
+
const vanishedTabs = recorded
|
|
96
|
+
.filter((tab) => tab.driverTabId !== null && !listed.has(tab.driverTabId))
|
|
97
|
+
.map((tab) => ({
|
|
98
|
+
tabId: tab.tabId,
|
|
99
|
+
claimId: tab.claimId,
|
|
100
|
+
// Non-null by the filter above; narrowed for the type rather than
|
|
101
|
+
// asserted, because an assertion here would be the one place a null
|
|
102
|
+
// could reach a driver call.
|
|
103
|
+
driverTabId: tab.driverTabId ?? '',
|
|
104
|
+
}));
|
|
105
|
+
// See the docblock: while a row is mid-open, "unowned" is not provable, so
|
|
106
|
+
// nothing is closed. The vanished half above is untouched by this.
|
|
107
|
+
const unownedPages = skippedOpening.length > 0
|
|
108
|
+
? []
|
|
109
|
+
: pages
|
|
110
|
+
.filter((page) => !owned.has(page.driverTabId))
|
|
111
|
+
.map((page) => ({ driverTabId: page.driverTabId }));
|
|
112
|
+
return { unownedPages, vanishedTabs, skippedOpening };
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* The live tab rows to reconcile against, for one browser.
|
|
116
|
+
*
|
|
117
|
+
* **A `Database` and no session** — this is the read half of the writing
|
|
118
|
+
* part, and it is here rather than in the command so that the command holds
|
|
119
|
+
* no SQL. `claims.state = 'active'` is what makes it *live* leases: a lapsed
|
|
120
|
+
* lease's rows belong to the sweep.
|
|
121
|
+
*/
|
|
122
|
+
export function readRecordedTabs(db, browserId) {
|
|
123
|
+
return db
|
|
124
|
+
.prepare(`SELECT tabs.id AS tabId,
|
|
125
|
+
tabs.driver_tab_id AS driverTabId,
|
|
126
|
+
tabs.claim_id AS claimId
|
|
127
|
+
FROM tabs
|
|
128
|
+
JOIN claims ON claims.id = tabs.claim_id
|
|
129
|
+
WHERE tabs.browser_id = ?
|
|
130
|
+
AND tabs.state IN ('opening', 'open')
|
|
131
|
+
AND claims.state = 'active'
|
|
132
|
+
ORDER BY tabs.id`)
|
|
133
|
+
.all(browserId);
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Settle the rows whose pages are gone, and end the leases that held them.
|
|
137
|
+
*
|
|
138
|
+
* **A database handle and no session** (the constraint `tabs.ts` sets), so
|
|
139
|
+
* this cannot be the function that also asks the browser. It is called
|
|
140
|
+
* *before* any page is closed, which is the safe order: a write that lands
|
|
141
|
+
* and a close that fails leaves a settled row and a leaked page, which §2.4b
|
|
142
|
+
* already describes and `broker doctor` already reports. The reverse order —
|
|
143
|
+
* closing first — would leave a closed page and a row still claiming a live
|
|
144
|
+
* lease over it, which is capacity pinned by nothing.
|
|
145
|
+
*
|
|
146
|
+
* ── Why this is not `updateSweptTabs` ───────────────────────────────────
|
|
147
|
+
*
|
|
148
|
+
* `arbitration.ts` exports that function so release and sweep cannot spell
|
|
149
|
+
* one rule twice, and it is the right function for the question *"this lease
|
|
150
|
+
* has ended, what becomes of its tabs"*. This is a different question, and
|
|
151
|
+
* the difference is visible in the states each produces:
|
|
152
|
+
*
|
|
153
|
+
* | | Question | The tab goes to |
|
|
154
|
+
* |---|---|---|
|
|
155
|
+
* | `updateSweptTabs` | The lease ended; is there a page to close? | `closing` — the tool is about to be asked |
|
|
156
|
+
* | this | The page is already gone | `closed` — there is nothing to ask |
|
|
157
|
+
*
|
|
158
|
+
* A vanished page has no round trip outstanding, so `closing` would assert
|
|
159
|
+
* one that is not, and the row would wait forever for an answer nobody is
|
|
160
|
+
* coming to give — the exact reasoning the sweep uses for a tab that never
|
|
161
|
+
* opened. `closed_at` is this moment because that is when the absence was
|
|
162
|
+
* established; the page died earlier and nothing here knows when, which is
|
|
163
|
+
* the honest version of §2.4a's distinction rather than a violation of it:
|
|
164
|
+
* a fact nobody observed is not recoverable by stamping a guess.
|
|
165
|
+
*
|
|
166
|
+
* **And the lease is ended, not expired.** §2.6 says the row is marked closed
|
|
167
|
+
* *"and its lease ended"*. `expired` would claim the lease lapsed on time,
|
|
168
|
+
* which is a claim about a clock and is false — this lease was cut short by
|
|
169
|
+
* its tab disappearing. `revoked` is the state for a lease ended by a
|
|
170
|
+
* decision that was not the caller's and not the clock's, and an
|
|
171
|
+
* administrator running reconciliation is exactly that.
|
|
172
|
+
*
|
|
173
|
+
* ── The sentence is not decoration, and the schema says so ──────────────
|
|
174
|
+
*
|
|
175
|
+
* `CHECK ((state = 'revoked') = (revoke_reason IS NOT NULL))` refuses a
|
|
176
|
+
* revoked lease with no reason, and the column's own comment gives the
|
|
177
|
+
* purpose: *"an operator taking capacity off a caller owes a sentence, and
|
|
178
|
+
* the caller's next call is refused with it"*. That is the whole reason this
|
|
179
|
+
* state is the right one — the caller does not get a bare failure on its next
|
|
180
|
+
* call, it gets told its page was gone when somebody looked.
|
|
181
|
+
*
|
|
182
|
+
* **Found by the constraint rather than by review.** The first version of
|
|
183
|
+
* this function wrote `state = 'revoked'` with no reason and would have
|
|
184
|
+
* thrown on the first vanished tab it ever met. The check is doing exactly
|
|
185
|
+
* the job §1.11 describes.
|
|
186
|
+
*/
|
|
187
|
+
export function applyReconciliation(db, vanished, at) {
|
|
188
|
+
if (vanished.length === 0) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const tabIds = vanished.map((tab) => tab.tabId);
|
|
192
|
+
const tabPlaceholders = tabIds.map(() => '?').join(', ');
|
|
193
|
+
db.prepare(`UPDATE tabs
|
|
194
|
+
SET state = 'closed', closed_at = ?, updated_at = ?
|
|
195
|
+
WHERE id IN (${tabPlaceholders})
|
|
196
|
+
AND state IN ('opening', 'open')`).run(at, at, ...tabIds);
|
|
197
|
+
// The lease goes with the tab, because a lease *is* a tab (§2.3): a lease
|
|
198
|
+
// whose tab is gone owns nothing while still counting against the budget,
|
|
199
|
+
// which §3.13 names as a state that should not exist.
|
|
200
|
+
const claimIds = [...new Set(vanished.map((tab) => tab.claimId))];
|
|
201
|
+
const claimPlaceholders = claimIds.map(() => '?').join(', ');
|
|
202
|
+
db.prepare(`UPDATE claims
|
|
203
|
+
SET state = 'revoked',
|
|
204
|
+
revoke_reason = ?,
|
|
205
|
+
ended_at = ?,
|
|
206
|
+
updated_at = ?
|
|
207
|
+
WHERE id IN (${claimPlaceholders})
|
|
208
|
+
AND state = 'active'`).run(VANISHED_TAB_REASON, at, at, ...claimIds);
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* The sentence a caller is refused with after its page went away.
|
|
212
|
+
*
|
|
213
|
+
* **Written for the caller rather than for a log.** §3.14: every refusal
|
|
214
|
+
* *"names the way forward, because the alternative teaches a caller to
|
|
215
|
+
* satisfy the check rather than to do the right thing"*. The way forward
|
|
216
|
+
* after losing a page is a fresh lease, so that is what it says — and it says
|
|
217
|
+
* what happened in terms the caller can act on, without naming the driver's
|
|
218
|
+
* identifier for the page (§1.4) or implying the caller did anything wrong.
|
|
219
|
+
*/
|
|
220
|
+
export const VANISHED_TAB_REASON = 'This lease’s tab was absent from the browser when reconciliation checked, so the lease was ended. Claim again to get a fresh tab.';
|