browser-broker 0.1.0 → 0.3.1

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.
@@ -5,6 +5,7 @@ import { append } from "../events.js";
5
5
  import { hashKey, mintKey } from "../keys.js";
6
6
  import { nudgeIfOwnObstacle } from "../nudge.js";
7
7
  import { queuePosition, waitEstimateSeconds } from "../queue.js";
8
+ import { countStrandedTabsFor } from "../tabs.js";
8
9
  import { CallRefusal } from "../refusals.js";
9
10
  import { StorageSeedRefusal, seedRecord, validateStorageSeed, } from "../storage-seed.js";
10
11
  /**
@@ -612,6 +613,30 @@ function grant(branch) {
612
613
  // last unit and the rest of its work is now queued behind other callers.
613
614
  // §2.3a scopes the nudge to a refusal or a queue placement, so nothing is
614
615
  // attached here; the ledger already records the grant.
616
+ // **The count is taken on the handle this transaction is already writing
617
+ // through**, over the table the INSERT above has just touched, so it costs
618
+ // no round trip and cannot read a browser's state from a different instant
619
+ // than the grant did. The threshold is `doctor`'s own definition, imported
620
+ // rather than restated: two notions of "stranded" in one product is the
621
+ // defect this note exists to report.
622
+ const strandedBacklog = strandedBacklogNote(scope.db, browserId, settings.leaseSeconds, scope.adapter);
623
+ if (strandedBacklog !== undefined) {
624
+ // The ledger row is the part that is not optional. Each occurrence is
625
+ // invisible once the caller acts on it, which is exactly why it is
626
+ // recorded: without a record there is no way to learn that granting into
627
+ // a browser with a backlog has become common, and *common* is the signal
628
+ // that something upstream is killing callers mid-lease.
629
+ append(scope.db, {
630
+ kind: 'claim_granted',
631
+ outcome: 'allow',
632
+ adapter: scope.adapter,
633
+ claimId,
634
+ tabId,
635
+ sessionId: input.sessionId,
636
+ browserId,
637
+ detail: { note: 'stranded_backlog', stranded: strandedBacklog.stranded },
638
+ });
639
+ }
615
640
  return {
616
641
  value: {
617
642
  outcome: 'granted',
@@ -622,9 +647,47 @@ function grant(branch) {
622
647
  expiresAt,
623
648
  leaseSeconds: settings.leaseSeconds,
624
649
  storageSeed,
650
+ ...(strandedBacklog === undefined ? {} : { strandedBacklog }),
625
651
  },
626
652
  };
627
653
  }
654
+ /**
655
+ * The note, when this browser is carrying a backlog worth telling a caller
656
+ * about.
657
+ *
658
+ * ── Why the threshold is one stranded tab ───────────────────────────────
659
+ *
660
+ * Not a round number chosen for feel. `doctor` already fails its check at
661
+ * one, on the reasoning that a report which is clean while any tab waits on a
662
+ * close nobody will answer is not reporting — and a second, higher threshold
663
+ * here would mean the claim path and the readiness check disagreeing about
664
+ * whether the same browser is healthy. **That disagreement is the defect this
665
+ * note exists to close**, so the two thresholds are the same threshold, read
666
+ * from the same function.
667
+ *
668
+ * The cost of saying it at one is a single extra line on a response, carrying
669
+ * a true fact, only when a fact is true. The cost of not saying it until some
670
+ * larger number is a caller working blind through exactly the band where the
671
+ * backlog is easiest to clear.
672
+ */
673
+ function strandedBacklogNote(db, browserId, leaseSeconds, adapter) {
674
+ const stranded = countStrandedTabsFor(db, browserId, leaseSeconds);
675
+ if (stranded === 0) {
676
+ return undefined;
677
+ }
678
+ // The remedy names the browser rather than saying "the browser", so it can
679
+ // be run as typed. `doctor` sets that standard and it is why it diagnosed
680
+ // this in one call.
681
+ const remedy = adapter === 'cli'
682
+ ? `broker reconcile ${browserId}`
683
+ : `broker reconcile ${browserId}, from a shell`;
684
+ return {
685
+ stranded,
686
+ note: `note: ${String(stranded)} tab(s) on ${browserId} are stranded mid-close, which can leave ` +
687
+ 'this browser unable to serve the lease just granted — a page call may report the browser ' +
688
+ `closed. Run \`${remedy}\` to clear them, and \`broker doctor\` to confirm.`,
689
+ };
690
+ }
628
691
  /**
629
692
  * The queue placement: a lease and a key, and no tab.
630
693
  *
@@ -2,12 +2,13 @@ import { updateSweptTabs } from "../arbitration.js";
2
2
  import { append } from "../events.js";
3
3
  import { extendLease, resolveLease } from "../leases.js";
4
4
  import { resolveOwnedTabOrRefuse } from "../ownership.js";
5
- import { disposeEvaluationResult, MAX_INLINE_RESULT_BYTES, validateCaptureMode, resolveReadArtifacts, validateAction, validateExpression, validateNavigationTarget, } from "../pages.js";
5
+ import { disposeEvaluationResult, MAX_INLINE_RESULT_BYTES, validateCaptureMode, validateCaptureTier, resolveReadArtifacts, validateAction, validateExpression, validateNavigationTarget, validateNavigationWait, } from "../pages.js";
6
6
  import { recordTabOpened, reserveTab } from "../tabs.js";
7
7
  import { seedRecord } from "../storage-seed.js";
8
8
  import { BrokerError } from "../../errors.js";
9
9
  import { sanitiseLabel, stampFromInstant } from "../../artifacts/names.js";
10
10
  import { takeCapture } from "../../capture/pipeline.js";
11
+ import { describeReduction } from "../../capture/tiers.js";
11
12
  import { capturesTakenBy, recordCapture } from "../capture-store.js";
12
13
  import { captureSource } from "../capture-seam.js";
13
14
  import { insertComparison } from "../comparison-store.js";
@@ -277,10 +278,22 @@ function withPageDriven(value, work) {
277
278
  *
278
279
  * The address is checked against the scheme allowlist before anything is
279
280
  * written, so a refused scheme leaves no trace but the refusal row.
281
+ *
282
+ * The wait is checked after the lease has been resolved rather than before,
283
+ * because what bounds it is that lease's own promised lifetime. It leaves the
284
+ * same absence behind: a refusal rolls the transaction back, taking the
285
+ * renewal with it, so the tab is never asked to go anywhere.
280
286
  */
281
287
  export function decideNavigate(scope, input) {
282
288
  const url = validateNavigationTarget(input.url);
283
289
  const { lease, tab, expiresAt } = admit(scope, input, 'navigate');
290
+ // Bounded by **this lease's own promised lifetime**, read off the row
291
+ // `admit` just renewed rather than from a settings snapshot. That is the
292
+ // same source every duration these handlers report comes from, and for the
293
+ // same reason: a renewal extends by the duration the caller was already
294
+ // told about, so a ceiling taken from the environment could differ from the
295
+ // lease the caller is actually holding.
296
+ const waitMs = validateNavigationWait(input.waitMs, lease.ttlSeconds);
284
297
  append(scope.db, {
285
298
  kind: 'navigate',
286
299
  outcome: 'allow',
@@ -289,14 +302,64 @@ export function decideNavigate(scope, input) {
289
302
  tabId: tab.tabId,
290
303
  sessionId: lease.sessionId,
291
304
  browserId: tab.browserId,
292
- detail: { url },
305
+ // The wait is on the row when the caller asked for one, because the ledger
306
+ // is what answers *"what was this call actually given"* long after the
307
+ // call — and an argument that is invisible in the record is one nobody can
308
+ // check was honoured.
309
+ detail: { url, ...(waitMs === undefined ? {} : { waitMs }) },
293
310
  });
294
- const work = afterCommitWork(scope, input, tab, (session, page) => session.navigate(page, url), lease.claimId);
311
+ // Where the page actually ended up, filled by the after-commit closure
312
+ // below. Undefined until the browser has answered — and permanently so on a
313
+ // build with no browser, which is what the getters' fallbacks are for.
314
+ let arrived;
315
+ const work = afterCommitWork(scope, input, tab, async (session, page) => {
316
+ // **The driver's answer is kept, not discarded.** It reports
317
+ // `page.url()` read after the load settles, plus the title and status,
318
+ // and this assignment is the whole of the redirect fix: the value was
319
+ // always available here and was being thrown away.
320
+ arrived = await session.navigate(page, url, waitMs);
321
+ }, lease.claimId);
295
322
  return {
296
- value: withPageDriven({ claimId: lease.claimId, tabId: tab.tabId, expiresAt, url }, work),
323
+ value: withPageDriven({
324
+ claimId: lease.claimId,
325
+ tabId: tab.tabId,
326
+ expiresAt,
327
+ // Getters, for the reason `pageDriven` is one — see
328
+ // {@link withPageDriven}. Read eagerly they would always report the
329
+ // request and no title, because nothing has run yet. That eager read
330
+ // is precisely the defect this fix removes, so spelling these as
331
+ // plain properties would restore it while looking correct.
332
+ get url() {
333
+ return arrived?.url ?? url;
334
+ },
335
+ get title() {
336
+ return arrived?.title;
337
+ },
338
+ get status() {
339
+ return arrived?.status;
340
+ },
341
+ }, work),
297
342
  afterCommit: work.afterCommit,
298
343
  };
299
344
  }
345
+ /**
346
+ * What an `emulate` result says about how long its effect lasts.
347
+ *
348
+ * A constant rather than an inline literal so there is one place to reword it.
349
+ *
350
+ * **What the tests hold it to is the meaning, not the wording.** They match the
351
+ * parts that have to survive a rewrite — that the effect is scoped to the
352
+ * connection, and that a path which works is named — rather than the sentence
353
+ * itself. Equality against the whole string would break on every harmless
354
+ * rewording while proving less: a note can keep every word and still stop
355
+ * telling a caller what to do. So a rewrite that keeps the meaning is free, and
356
+ * one that drops the working path fails.
357
+ */
358
+ export const EMULATION_SCOPE_NOTE = 'This preference lasts as long as the connection that set it, not as long ' +
359
+ 'as the tab. A later call from a separate invocation will not see it: the ' +
360
+ 'tab survives and the emulation binding does not. To act on it, emulate ' +
361
+ 'and capture within one invocation, or use the tool surface, where one ' +
362
+ 'connection spans the calls.';
300
363
  /**
301
364
  * `act` (§3.6) — one interaction against an owned tab.
302
365
  *
@@ -319,7 +382,17 @@ export function decideAct(scope, input) {
319
382
  });
320
383
  const work = afterCommitWork(scope, input, tab, (session, page) => session.act(page, request), lease.claimId);
321
384
  return {
322
- value: withPageDriven({ claimId: lease.claimId, tabId: tab.tabId, expiresAt, action: request.action }, work),
385
+ value: withPageDriven({
386
+ claimId: lease.claimId,
387
+ tabId: tab.tabId,
388
+ expiresAt,
389
+ action: request.action,
390
+ // Derived from the action that was validated, in the one place that
391
+ // knows which action ran — the same property `pageDriven` is built
392
+ // for. Spread away on the other twelve so the field's presence is
393
+ // itself the signal, with no "" to mistake for a scope nobody stated.
394
+ ...(request.action === 'emulate' ? { emulationScope: EMULATION_SCOPE_NOTE } : {}),
395
+ }, work),
323
396
  afterCommit: work.afterCommit,
324
397
  };
325
398
  }
@@ -478,9 +551,40 @@ export function decideCapture(scope, input) {
478
551
  // before ownership is checked and before a single row is written, so the
479
552
  // refusal leaves nothing behind but its own ledger entry.
480
553
  validateCaptureMode({ fullPage, selector: input.selector });
554
+ // Checked here for the same reason and in the same place. The pipeline's own
555
+ // `refuseArgumentMistakes` still decides whether the top rung carries its
556
+ // written reason — that rule is not duplicated here, only the one the type
557
+ // system cannot make on text arriving from a surface.
558
+ const tier = validateCaptureTier(input.tier);
559
+ // **Typed as the pipeline's options, not the driver's `CaptureRequest`.**
560
+ //
561
+ // This annotation is the defect's whole mechanism, so it is worth naming.
562
+ // `CaptureRequest` is the *driver* seam — what the browser is told — and it
563
+ // has no `tier` and no `reason`, correctly: a rung is a decision about the
564
+ // picture after the shutter, not something a browser is asked for. The
565
+ // literal below nevertheless packed both in, and the excess-property check
566
+ // that would ordinarily catch that **does not apply to conditionally spread
567
+ // properties**, so it compiled silently. The value then had nowhere to go,
568
+ // and the one call site downstream quietly took only the two fields the
569
+ // driver type admits.
570
+ //
571
+ // Annotating with the type that actually consumes these fields is what makes
572
+ // the same mistake a compile error next time rather than an inert argument.
481
573
  const request = {
482
574
  fullPage,
483
575
  ...(input.selector === undefined ? {} : { selector: input.selector }),
576
+ ...(tier === undefined ? {} : { tier }),
577
+ // Carried whenever it was given, rather than only alongside a tier.
578
+ //
579
+ // **A reason passed without a tier is still discarded**, and that is the
580
+ // pipeline's existing rule rather than something introduced here: it
581
+ // records a reason "only ever on the tier that requires it", because a
582
+ // reason attached to a rung nobody had to justify is not evidence of an
583
+ // escalation. Passing it on regardless keeps that decision in the one
584
+ // place that makes it, instead of adding a second, quieter version of it
585
+ // here — the value reaches the rule either way, and this layer does not
586
+ // get to have an opinion about which reasons are worth carrying.
587
+ ...(typeof input.reason === 'string' ? { reason: input.reason } : {}),
484
588
  };
485
589
  const { lease, tab, expiresAt } = admit(scope, input, 'capture');
486
590
  append(scope.db, {
@@ -529,16 +633,57 @@ export function decideCapture(scope, input) {
529
633
  const taken = await takeCapture({ tabs: session, artifacts }, lease.claimId, page, {
530
634
  fullPage,
531
635
  ...(request.selector === undefined ? {} : { selector: request.selector }),
636
+ // **The rung and its justification, which used to stop here.**
637
+ //
638
+ // `request` was built with both a dozen lines above — `tier`
639
+ // validated by `validateCaptureTier`, `reason` carried whenever it
640
+ // was given — and then this call site spread only `fullPage` and
641
+ // `selector`, so both died one line before the pipeline that
642
+ // honours them. Every capture was consequently taken at the default
643
+ // rung no matter what the caller asked for.
644
+ //
645
+ // That is the inert-argument defect `check:argument-reachability`
646
+ // exists to prevent, one layer below where that check looks: its
647
+ // rule is that a declared argument is read *at the bridge*, and
648
+ // `tier` is read there, so the check passed while the value went
649
+ // nowhere. It is worse than the `wait_ms` case that motivated the
650
+ // check, because `tier="max"` charges the caller a written
651
+ // justification first — the caller pays for the escalation, is told
652
+ // it was accepted, and receives the unescalated picture.
653
+ ...(request.tier === undefined ? {} : { tier: request.tier }),
654
+ ...(request.reason === undefined ? {} : { reason: request.reason }),
532
655
  }, takenBefore);
533
656
  // The row last, describing a file that is already on disk. See
534
657
  // `capture-store.ts` for why that order is the rule and not a preference.
535
658
  recordCapture(scope.db, lease.claimId, tab.tabId, taken.telemetry);
659
+ // **What the browser produced, and by how much it was shrunk to fit the
660
+ // rung** — present exactly when the two differ.
661
+ //
662
+ // The pipeline has computed `sourceWidth`/`sourceHeight` all along and
663
+ // `captures` has stored them all along; this object simply never passed
664
+ // them on, so the one layer that talks to the caller was the one layer
665
+ // that could not tell a reduced picture from an unreduced one. A
666
+ // `full_page` capture of a long article consequently came back at about
667
+ // sixteen per cent, complete and undistorted and entirely illegible,
668
+ // with nothing in the response saying so.
669
+ const reduction = describeReduction({ width: taken.sourceWidth, height: taken.sourceHeight }, { width: taken.width, height: taken.height }, taken.tier);
536
670
  written = {
537
671
  captureId: taken.captureId,
538
672
  path: taken.path,
539
673
  width: taken.width,
540
674
  height: taken.height,
541
675
  bytes: taken.bytes,
676
+ // Echoed on every capture, reduced or not, because "what did the page
677
+ // actually measure" is a fact a caller may want either way — and
678
+ // because a field that appears only on the bad case is a field nobody
679
+ // learns to read.
680
+ sourceWidth: taken.sourceWidth,
681
+ sourceHeight: taken.sourceHeight,
682
+ tier: taken.tier,
683
+ // Absent when nothing was shrunk. Its **presence** is the signal, which
684
+ // is why it is not a `scale: 1` that a caller would learn to skip.
685
+ ...(reduction === undefined ? {} : { reduced: reduction }),
686
+ compareHint: `to diff a later capture against this one, pass compare_to: ${taken.captureId}`,
542
687
  };
543
688
  // ── The diff, when one was asked for (§3.11, §1.9) ──────────────────
544
689
  //
@@ -57,6 +57,14 @@ export function decideStatus(scope, input) {
57
57
  checkBackSeconds: checkBack,
58
58
  checkBack: advice,
59
59
  ...(tab === undefined ? {} : { tabId: tab.tabId }),
60
+ // **`unknown` rather than `live`, and the difference is the whole
61
+ // defect.** Everything above is derived from rows and a clock, which is
62
+ // what a lease is; whether the browser still exists is a fact about the
63
+ // operating system and cannot be read here without breaking
64
+ // `arbitration.no_browser_io`. Claiming `live` from this seat would be
65
+ // asserting the very thing that was measured false — a store believing
66
+ // in a browser that had been dead for minutes.
67
+ browser: 'unknown',
60
68
  },
61
69
  };
62
70
  }
@@ -98,6 +98,59 @@ export function validateNavigationTarget(url) {
98
98
  }
99
99
  return candidate;
100
100
  }
101
+ /**
102
+ * Check how long a navigation may take, before anything navigates.
103
+ *
104
+ * Returns the wait in milliseconds, or `undefined` when the caller expressed
105
+ * no opinion and the browser library's own default should apply.
106
+ *
107
+ * ── Why the lease is the ceiling, rather than a number somebody picked ──
108
+ *
109
+ * `ttlSeconds` is the lifetime this particular lease was promised, converted
110
+ * to milliseconds. It is the number the bound is *about*: a lease is a tab
111
+ * (§2.3) and capacity is a fixed total across the browsers (§6.2), so a caller
112
+ * permitted to wait longer than its own lease lives would sit inside a single
113
+ * call while the tab it is holding becomes reclaimable — which is the one
114
+ * thing an expiry exists to make impossible, and it would be reached without
115
+ * the caller doing anything wrong.
116
+ *
117
+ * Any other ceiling would be a literal, and a literal is wrong in both
118
+ * directions at once: too low and it refuses a slow page an installation with
119
+ * long leases can perfectly well afford, too high and it reintroduces the
120
+ * overrun. Derived from the lease, it moves with the configuration that sets
121
+ * lease lifetimes and needs no separate setting of its own.
122
+ *
123
+ * **The lease's row rather than the environment**, which is the discipline
124
+ * every duration these operations report already keeps: the call carrying the
125
+ * wait renews the lease by the duration that lease was granted for, so a
126
+ * ceiling read from the environment could name a lifetime the caller is not
127
+ * actually holding.
128
+ *
129
+ * A wait exactly equal to the lease is allowed rather than refused. The call
130
+ * carrying it renews the lease first, so both are measured from that instant
131
+ * and equality is the exact edge rather than an overrun.
132
+ */
133
+ export function validateNavigationWait(waitMs, ttlSeconds) {
134
+ if (waitMs === undefined || waitMs === null)
135
+ return undefined;
136
+ const maximumMs = ttlSeconds * 1000;
137
+ // The syntax as well as the semantics, which is the lesson the viewport and
138
+ // emulate refusals below were both rewritten for: a caller that cannot see
139
+ // the accepted range from the message has no way to converge except by
140
+ // guessing, and a refusal that leaves it guessing costs more than the
141
+ // argument saves.
142
+ if (typeof waitMs !== 'number' || !Number.isInteger(waitMs) || waitMs <= 0) {
143
+ throw new PageRefusal('navigate.wait_bounded', `How long to wait for a page is a whole number of milliseconds from 1 to ${String(maximumMs)}, for example \`--wait-ms 5000\`.`, { waitMs, minimumMs: 1, maximumMs });
144
+ }
145
+ if (waitMs > maximumMs) {
146
+ // Named separately from the shape refusal above because the caller's
147
+ // mistake is a different one: the value is well formed and simply asks for
148
+ // more than a lease lasts, so the sentence says what the ceiling is and
149
+ // where it comes from rather than how to write a number.
150
+ throw new PageRefusal('navigate.wait_bounded', `A wait of ${String(waitMs)}ms is longer than a lease lives (${String(maximumMs)}ms), so the tab would be reclaimable before the navigation returned. Ask for at most ${String(maximumMs)}ms.`, { waitMs, minimumMs: 1, maximumMs });
151
+ }
152
+ return waitMs;
153
+ }
101
154
  /* ───────────────────────── act (#22, #61–#64) ───────────────────────── */
102
155
  /**
103
156
  * The refusal that **lists every action by name**.
@@ -581,6 +634,34 @@ export function validateCaptureMode(options) {
581
634
  throw new PageRefusal('capture.exclusive_mode', 'A capture takes a selector or the whole page, and this call asked for both. They are different pictures rather than different amounts of one, so nothing here can pick for you: ask for the element, or ask for the page.', { fullPage: true, selector: options.selector });
582
635
  }
583
636
  }
637
+ /**
638
+ * The resolution rung a capture was asked for, checked before the pipeline
639
+ * indexes anything by it.
640
+ *
641
+ * ── Why this guard is here and not left to the pipeline ─────────────────
642
+ *
643
+ * The pipeline types the field {@link RequestableTier}, so within the service
644
+ * an unknown rung is a compile error and no check is needed. It stops being a
645
+ * compile-time question at the surface: a tool call and a command line both
646
+ * arrive as free text, and an unrecognised word typed by a caller would reach
647
+ * `TIER_LONGEST_EDGE[tier]`, resolve to `undefined`, and be handed to the
648
+ * downscaler as a target edge. That is a bad answer arriving quietly, which is
649
+ * the same family as the inert argument this pair was wired for.
650
+ *
651
+ * **`default` is refused as a value even though it is a real tier**, because
652
+ * it is the rung you get by passing nothing. `RequestableTier` excludes it on
653
+ * the seam deliberately — "there is deliberately no way to ask for the default
654
+ * explicitly" is a compile error rather than a line in a document — and this
655
+ * refusal keeps that true for callers who reach the service through text.
656
+ */
657
+ export function validateCaptureTier(tier) {
658
+ if (tier === undefined || tier === null)
659
+ return undefined;
660
+ if (tier !== 'detail' && tier !== 'max') {
661
+ throw new PageRefusal('capture.tier_known', 'A capture tier is "detail" or "max". Omit it for the default resolution — there is no way to ask for the default by name, because passing nothing is how you get it. "max" additionally requires reason, a written explanation in your own words.', { tier, accepted: ['detail', 'max'] });
662
+ }
663
+ return tier;
664
+ }
584
665
  /**
585
666
  * Decide whether a result comes back inline or goes to a file.
586
667
  *
@@ -82,7 +82,9 @@
82
82
  * on the assumption the seam holds up its end.
83
83
  */
84
84
  export function decideReconciliation(pages, recorded) {
85
- const skippedOpening = recorded.filter((tab) => tab.driverTabId === null).map((tab) => tab.tabId);
85
+ const skippedOpening = recorded
86
+ .filter((tab) => tab.driverTabId === null)
87
+ .map((tab) => ({ tabId: tab.tabId, sessionId: tab.sessionId }));
86
88
  // Every driver name a live lease claims. Built from the rows that have one,
87
89
  // which by §1.4's check is exactly the rows that are not `opening`.
88
90
  const owned = new Set(recorded
@@ -123,7 +125,8 @@ export function readRecordedTabs(db, browserId) {
123
125
  return db
124
126
  .prepare(`SELECT tabs.id AS tabId,
125
127
  tabs.driver_tab_id AS driverTabId,
126
- tabs.claim_id AS claimId
128
+ tabs.claim_id AS claimId,
129
+ claims.session_id AS sessionId
127
130
  FROM tabs
128
131
  JOIN claims ON claims.id = tabs.claim_id
129
132
  WHERE tabs.browser_id = ?
@@ -132,6 +135,57 @@ export function readRecordedTabs(db, browserId) {
132
135
  ORDER BY tabs.id`)
133
136
  .all(browserId);
134
137
  }
138
+ /**
139
+ * Settle rows stranded at `closing` whose page the browser does not have.
140
+ *
141
+ * ── Why the vanished-tab path cannot reach these ────────────────────────
142
+ *
143
+ * {@link readRecordedTabs} requires `claims.state = 'active'`, and rightly:
144
+ * a lapsed lease's rows are the sweep's business, not reconciliation's. But
145
+ * that is exactly the population that strands. A lease ends, its tab is moved
146
+ * to `closing`, and if the answer is never written back the row is left in a
147
+ * state meaning "waiting for the tool" — attached to a lease that is no
148
+ * longer active, and therefore invisible to every later reconciliation.
149
+ *
150
+ * A store was found holding 22 such rows, the oldest two days old, every one
151
+ * with `close_attempts = 0`. The pages had been closed by hand; the rows
152
+ * could not be reached by anything.
153
+ *
154
+ * ── Why this is safe to settle without asking again ─────────────────────
155
+ *
156
+ * The caller has just asked the browser what it has open, and passes the
157
+ * driver names it answered with. A row whose name is not in that list
158
+ * describes a page this browser does not have, so there is no round trip
159
+ * outstanding and nothing to wait for — the same reasoning
160
+ * {@link applyReconciliation} uses for a vanished page, and the sweep uses
161
+ * for a tab that never opened.
162
+ *
163
+ * **A row whose name IS in the list is left alone.** Its page exists, the
164
+ * close may genuinely still be in flight, and settling it would claim an
165
+ * answer nobody has given.
166
+ */
167
+ export function settleStrandedTabs(db, browserId, openDriverTabIds, at) {
168
+ const stranded = db
169
+ .prepare(`SELECT tabs.id AS tabId, tabs.driver_tab_id AS driverTabId
170
+ FROM tabs
171
+ JOIN claims ON claims.id = tabs.claim_id
172
+ WHERE tabs.browser_id = ?
173
+ AND tabs.state = 'closing'
174
+ AND claims.state <> 'active'
175
+ ORDER BY tabs.id`)
176
+ .all(browserId);
177
+ const open = new Set(openDriverTabIds);
178
+ const gone = stranded.filter((tab) => tab.driverTabId === null || !open.has(tab.driverTabId));
179
+ if (gone.length === 0) {
180
+ return 0;
181
+ }
182
+ const placeholders = gone.map(() => '?').join(', ');
183
+ db.prepare(`UPDATE tabs
184
+ SET state = 'closed', closed_at = ?, updated_at = ?
185
+ WHERE id IN (${placeholders})
186
+ AND state = 'closing'`).run(at, at, ...gone.map((tab) => tab.tabId));
187
+ return gone.length;
188
+ }
135
189
  /**
136
190
  * Settle the rows whose pages are gone, and end the leases that held them.
137
191
  *
@@ -155,6 +209,24 @@ export function readRecordedTabs(db, browserId) {
155
209
  * | `updateSweptTabs` | The lease ended; is there a page to close? | `closing` — the tool is about to be asked |
156
210
  * | this | The page is already gone | `closed` — there is nothing to ask |
157
211
  *
212
+ * **`closing` appears in the predicate, and no row reaches it in that
213
+ * state.** The rows here come from {@link readRecordedTabs} by way of
214
+ * {@link decideReconciliation}, and that read requires `tabs.state IN
215
+ * ('opening', 'open')` — so the third value in the predicate below matches
216
+ * nothing this caller can supply. It is kept as a bound on what the write is
217
+ * permitted to touch rather than as a population it serves: the statement
218
+ * says which states this function may move a row out of, and a future caller
219
+ * that widens its own read cannot silently reopen a `closed` row through it.
220
+ *
221
+ * **The stranded-`closing` population is {@link settleStrandedTabs}'s**, not
222
+ * this function's, and the distinction is load-bearing. Those rows belong to
223
+ * leases that have already ended, which is precisely why `readRecordedTabs`
224
+ * (`claims.state = 'active'`) cannot see them and why they need their own
225
+ * pass. A store was found holding 22 of them, each still occupying its slot
226
+ * in the partial unique index on `(browser_id, driver_tab_id)`. Reading that
227
+ * story as this function's would leave the impression the gap is covered
228
+ * here, and it is not.
229
+ *
158
230
  * A vanished page has no round trip outstanding, so `closing` would assert
159
231
  * one that is not, and the row would wait forever for an answer nobody is
160
232
  * coming to give — the exact reasoning the sweep uses for a tab that never
@@ -193,7 +265,7 @@ export function applyReconciliation(db, vanished, at) {
193
265
  db.prepare(`UPDATE tabs
194
266
  SET state = 'closed', closed_at = ?, updated_at = ?
195
267
  WHERE id IN (${tabPlaceholders})
196
- AND state IN ('opening', 'open')`).run(at, at, ...tabIds);
268
+ AND state IN ('opening', 'open', 'closing')`).run(at, at, ...tabIds);
197
269
  // The lease goes with the tab, because a lease *is* a tab (§2.3): a lease
198
270
  // whose tab is gone owns nothing while still counting against the budget,
199
271
  // which §3.13 names as a state that should not exist.
@@ -1,3 +1,4 @@
1
+ import { recordTabCloseFailed, recordTabClosed } from "./arbitration.js";
1
2
  import { readEnvironment } from "../config/environment.js";
2
3
  import { prepareStore } from "../store/open.js";
3
4
  import { ArtifactStore } from "../artifacts/store.js";
@@ -70,6 +71,14 @@ export async function createRuntime(options) {
70
71
  environment,
71
72
  adapter: options.adapter,
72
73
  session: browsers.session,
74
+ // **What makes `status` able to tell the truth about a dead browser.**
75
+ // Supplied here rather than defaulted inside the broker for the reason
76
+ // the option's own comment gives: a build that cannot look must report
77
+ // `unknown` rather than claim the browser is fine. This build can look,
78
+ // so it does. Note this is the provider's `liveness`, which asks the
79
+ // operating system — not its memoised session, which is the very thing
80
+ // that keeps presenting a dead browser as a working connection.
81
+ checkBrowser: browsers.liveness,
73
82
  artifacts,
74
83
  // The same provider closes the tabs the sweep orphaned. Without one,
75
84
  // `SCHEMA.md` §2.4b's "a leaked tab is not a leaked lease" describes a
@@ -79,8 +88,24 @@ export async function createRuntime(options) {
79
88
  closeTab: async (tab) => {
80
89
  const session = await browsers.session(tab.browserId);
81
90
  const opened = await resolveDriverTab(store.db, tab.tabId);
82
- if (opened !== undefined) {
91
+ if (opened === undefined) {
92
+ return;
93
+ }
94
+ // **The answer is written down either way.** `closing` means "the tool
95
+ // was asked and has not answered", so a close that returns and is never
96
+ // recorded leaves a row saying that forever — which is what happened,
97
+ // 22 rows deep, until a person noticed his browser had filled with
98
+ // pages no lease owned.
99
+ //
100
+ // A failure is recorded rather than thrown: §2.4b's "a leaked tab is
101
+ // not a leaked lease" means the capacity is already back, and failing
102
+ // the release over the page would fail a call that did its job.
103
+ try {
83
104
  await session.closeTab({ browser: tab.browserId, driverTabId: opened });
105
+ recordTabClosed(store.db, tab.tabId, new Date().toISOString());
106
+ }
107
+ catch {
108
+ recordTabCloseFailed(store.db, tab.tabId, new Date().toISOString());
84
109
  }
85
110
  },
86
111
  });
@@ -121,3 +121,73 @@ export function recordTabOpened(db, tabId, driverTabId) {
121
121
  throw new Error(`Tab ${tabId} was not awaiting an open, so the driver name could not be recorded against it.`);
122
122
  }
123
123
  }
124
+ /**
125
+ * How many tabs on one browser are waiting on a close that will not come.
126
+ *
127
+ * ── Why this is one function and not two ────────────────────────────────
128
+ *
129
+ * `broker doctor` and the claim grant both have to answer *"is this browser
130
+ * carrying a backlog of stranded tabs"*, and a product holding two notions of
131
+ * "stranded" would be the same defect the backlog itself caused: a caller
132
+ * told one number by one surface and a different number by another has no way
133
+ * to tell which is the real one. So the predicate lives here once, and both
134
+ * read it.
135
+ *
136
+ * ── Why the threshold is a lease's own lifetime ─────────────────────────
137
+ *
138
+ * Taken from `doctor`'s existing definition rather than invented alongside
139
+ * it. `closing` means the tool was asked and has not answered — a transient
140
+ * state measured in a round trip. The honest boundary between "a close is in
141
+ * flight" and "a close is never happening" is the one the system already uses
142
+ * to decide a caller is gone: if a lease may be declared lapsed after this
143
+ * long without contact, a round trip outstanding for longer is not pending.
144
+ *
145
+ * The comparison is on `updated_at`, which is when the row was moved to
146
+ * `closing`. A round trip still inside the window is deliberately not
147
+ * counted, because reporting one would make a healthy release look like a
148
+ * fault.
149
+ */
150
+ export function countStrandedTabsFor(db, browserId, leaseSeconds, at = new Date()) {
151
+ const cutoff = new Date(at.getTime() - leaseSeconds * 1000).toISOString();
152
+ const row = db
153
+ .prepare(`SELECT COUNT(*) AS n
154
+ FROM tabs
155
+ WHERE browser_id = ?
156
+ AND state = 'closing'
157
+ AND updated_at < ?`)
158
+ .get(browserId, cutoff);
159
+ return row?.n ?? 0;
160
+ }
161
+ /**
162
+ * The stranded backlog broken down per browser, heaviest first.
163
+ *
164
+ * ── Why a breakdown rather than a total ─────────────────────────────────
165
+ *
166
+ * A total is not actionable when the remedy is per-browser. `doctor`'s
167
+ * remedy line says to run `broker reconcile` against each browser, but a
168
+ * single total cannot say which ones still need it: an operator who
169
+ * reconciled `regular` and saw the count fall from 29 to 13 reasonably
170
+ * concluded reconcile had not worked, when in fact the remaining 13 were all
171
+ * on `private` and the run had done exactly what it said.
172
+ *
173
+ * **Grouped rather than asked per browser from a configured list**, because
174
+ * the browsers are a configured list per kind and not a fixed pair — a
175
+ * breakdown assembled from the list this build happens to know about would
176
+ * silently omit a backlog on a browser that had been reconfigured away, which
177
+ * is the population most likely to be stranded.
178
+ *
179
+ * Only browsers carrying a backlog appear. A row reading zero is not a
180
+ * finding, and listing every configured browser on every healthy run would
181
+ * bury the one line that matters.
182
+ */
183
+ export function strandedTabsByBrowser(db, leaseSeconds, at = new Date()) {
184
+ const cutoff = new Date(at.getTime() - leaseSeconds * 1000).toISOString();
185
+ return db
186
+ .prepare(`SELECT browser_id AS browserId, COUNT(*) AS stranded
187
+ FROM tabs
188
+ WHERE state = 'closing'
189
+ AND updated_at < ?
190
+ GROUP BY browser_id
191
+ ORDER BY COUNT(*) DESC, browser_id`)
192
+ .all(cutoff);
193
+ }