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.
@@ -190,6 +190,70 @@ export function checkSchemaVersion(found) {
190
190
  : 'Any spawn steps the schema. Run the service once; this command reports and does not step.',
191
191
  };
192
192
  }
193
+ /**
194
+ * That no tab has been waiting on a close nobody is coming to answer.
195
+ *
196
+ * ── The report that said nothing ────────────────────────────────────────
197
+ *
198
+ * `closing` means the tool was asked and has not answered. That is a
199
+ * transient state measured in a round trip, so a row sitting in it for hours
200
+ * is not slow — it is waiting for an answer that will never arrive, because
201
+ * the process that would have written it exited long ago.
202
+ *
203
+ * This check exists because a store was found holding 22 such rows while
204
+ * `broker doctor` reported **exit code 0**. Eight real pages were open on
205
+ * the operator's browser, owned by no lease, and the only reason anybody
206
+ * noticed was that a person looked at his own browser and thought there were
207
+ * too many tabs. A report that is clean while that is true is not reporting.
208
+ *
209
+ * ── Why the threshold is a lease's own lifetime ─────────────────────────
210
+ *
211
+ * The number has to separate "a close is in flight" from "a close is never
212
+ * happening", and the honest boundary is the one the system already uses to
213
+ * decide a caller is gone: if a lease may be declared lapsed after this long
214
+ * without contact, a round trip outstanding for longer is not pending.
215
+ * Taking the threshold from configuration rather than writing one down keeps
216
+ * the two from drifting apart.
217
+ *
218
+ * ── Why the count is broken down per browser ────────────────────────────
219
+ *
220
+ * The remedy is per-browser, so a single total cannot say which browsers
221
+ * still need it. An operator who reconciled one browser and watched the total
222
+ * fall from 29 to 13 reasonably concluded reconcile had not worked; the
223
+ * remaining 13 were all on the other browser, and the run had done exactly
224
+ * what it said. The breakdown is what makes the remaining work obvious, and
225
+ * the remedy names the browsers rather than saying "each browser" — the
226
+ * browsers are a configured list per kind rather than a fixed pair, so
227
+ * "each" is not something a reader can enumerate from the message alone.
228
+ */
229
+ export function checkStrandedTabs(byBrowser, thresholdSeconds) {
230
+ const stranded = byBrowser.reduce((total, entry) => total + entry.stranded, 0);
231
+ if (stranded === 0) {
232
+ return {
233
+ group: 'store',
234
+ id: 'store.stranded_tabs',
235
+ title: 'No tab is waiting on a close that will not come',
236
+ status: 'ok',
237
+ detail: 'Every tab has either been closed or is still within a close round trip.',
238
+ };
239
+ }
240
+ return {
241
+ group: 'store',
242
+ id: 'store.stranded_tabs',
243
+ title: 'No tab is waiting on a close that will not come',
244
+ status: 'failed',
245
+ detail: `${String(stranded)} tab(s) have been waiting on a close for longer than ` +
246
+ `${String(thresholdSeconds)} seconds, which is how long a lease may go without contact ` +
247
+ 'before it is declared lapsed. A close outstanding for longer is not in flight. ' +
248
+ `Per browser: ${byBrowser
249
+ .map((entry) => `${String(entry.stranded)} on ${entry.browserId}`)
250
+ .join(', ')}.`,
251
+ remedy: `Run \`broker reconcile\` against each browser named above: ${byBrowser
252
+ .map((entry) => `\`broker reconcile ${entry.browserId}\``)
253
+ .join(', ')}. It asks what the browser actually has open, ` +
254
+ 'closes pages no live lease owns, and settles the records whose page is gone.',
255
+ };
256
+ }
193
257
  export function checkAutomation(probe) {
194
258
  if (probe.present === undefined) {
195
259
  return {
@@ -1,9 +1,10 @@
1
1
  import { SIGNABLE_BROWSER } from "../service/operations/sign-in.js";
2
2
  import { readTabBudget } from "../operations/status.js";
3
3
  import { classifySignIn } from "../service/signin-recovery.js";
4
+ import { strandedTabsByBrowser } from "../service/tabs.js";
4
5
  import { readStoreVersion } from "../store/schema/step.js";
5
6
  import { inspectProfileSession } from "./session.js";
6
- import { checkAbandonedSignIn, checkAutomation, checkCaptureSurface, checkDiscoveryRecord, checkKeeperTab, checkRootWritable, checkSchemaVersion, checkSignInSession, checkStoreLocation, checkStorePresent, checkTabBudget, exitCodeFor, } from "./checks.js";
7
+ import { checkAbandonedSignIn, checkAutomation, checkCaptureSurface, checkDiscoveryRecord, checkKeeperTab, checkRootWritable, checkSchemaVersion, checkStrandedTabs, checkSignInSession, checkStoreLocation, checkStorePresent, checkTabBudget, exitCodeFor, } from "./checks.js";
7
8
  /**
8
9
  * Run the preconditions.
9
10
  *
@@ -75,6 +76,14 @@ export function runDoctor(environment, db, probes = {}) {
75
76
  // otherwise.
76
77
  checks.push(checkAbandonedSignIn(signInBrowser, classifySignIn(db === undefined ? undefined : readSignInOwner(db, signInBrowser), probes.processIsRunning)));
77
78
  checks.push(checkTabBudget(storedBudget, probes.configuredTabBudget ?? null));
79
+ // Counted here rather than in the check, which takes a number so it stays
80
+ // testable without a store. A store that is absent yields no count and the
81
+ // check is not run at all: "no store" is already reported by its own row,
82
+ // and a second row saying zero stranded tabs would read as reassurance
83
+ // drawn from nothing.
84
+ if (db !== undefined) {
85
+ checks.push(checkStrandedTabs(strandedTabsByBrowser(db, environment.leaseSeconds), environment.leaseSeconds));
86
+ }
78
87
  return {
79
88
  checks,
80
89
  exitCode: exitCodeFor(checks),
@@ -381,6 +381,67 @@ export function updateSweptTabs(db, tabs, now) {
381
381
  .all(...ids);
382
382
  return pending;
383
383
  }
384
+ /**
385
+ * Record how a close went, after the browser has answered.
386
+ *
387
+ * ── The half of the lifecycle that was never written ────────────────────
388
+ *
389
+ * `updateSweptTabs` moves a tab to `closing`, and the schema says what that
390
+ * means: *"the tool was asked and has not answered"*. Something has to write
391
+ * the answer down, and nothing did — so a tab that closed perfectly well sat
392
+ * at `closing` for the life of the store, with `close_attempts` at zero
393
+ * because no code path had ever incremented it.
394
+ *
395
+ * The cost was not cosmetic. `closing` is one of the states the partial
396
+ * unique index on `(browser_id, driver_tab_id)` covers, so every stranded
397
+ * row kept holding its slot; and a state meaning "asked, no answer" that is
398
+ * never resolved makes the ledger disagree with the browser permanently,
399
+ * which is worse than either being wrong on its own — the ledger is what
400
+ * every guard reads.
401
+ *
402
+ * ── Why the attempt is counted even when it succeeds ────────────────────
403
+ *
404
+ * `close_attempts` is what distinguishes *tried and failed* from *never
405
+ * tried*, and that distinction is the whole diagnostic value of the column.
406
+ * A field investigation found 22 stranded rows and could say with certainty
407
+ * that the close had never been attempted, rather than having to guess
408
+ * whether the browser was refusing — because the counter was zero rather
409
+ * than absent.
410
+ *
411
+ * ── Why a failure is not an error the caller sees ───────────────────────
412
+ *
413
+ * `SCHEMA.md` §2.4b: **a leaked tab is not a leaked lease.** The capacity is
414
+ * already back; what is left is a page. Turning that into a thrown error
415
+ * would fail a release that actually succeeded at the thing releases are for.
416
+ * So a failure is recorded as `close_failed` on a row that stays `closing`
417
+ * — visible, selectable, and reclaimable by reconciliation — rather than
418
+ * raised.
419
+ */
420
+ export function recordTabClosed(db, tabId, at) {
421
+ db.prepare(`UPDATE tabs
422
+ SET state = 'closed',
423
+ closed_at = ?,
424
+ close_failed = 0,
425
+ close_attempts = close_attempts + 1,
426
+ updated_at = ?
427
+ WHERE id = ?
428
+ AND state = 'closing'`).run(at, at, tabId);
429
+ }
430
+ /**
431
+ * Record that a close was attempted and the browser did not do it.
432
+ *
433
+ * The row stays `closing`, which is the honest state: the page may well
434
+ * still be there. What changes is that it is now *known* to have been tried,
435
+ * which is what `close_failed` is for and what reconciliation selects on.
436
+ */
437
+ export function recordTabCloseFailed(db, tabId, at) {
438
+ db.prepare(`UPDATE tabs
439
+ SET close_failed = 1,
440
+ close_attempts = close_attempts + 1,
441
+ updated_at = ?
442
+ WHERE id = ?
443
+ AND state = 'closing'`).run(at, tabId);
444
+ }
384
445
  /**
385
446
  * Record what the sweep did, on the call that performed it.
386
447
  *
@@ -175,6 +175,19 @@ export function serviceFor(options) {
175
175
  key,
176
176
  tabId: tabForKey(db, key),
177
177
  url: argument(args, 'url'),
178
+ // **Coercion only, never validation** — the same split
179
+ // `viewportFrom` documents at length, and here for the same
180
+ // reason. A command line produces flat strings and nothing else,
181
+ // so a wait that was never coerced would be a number no
182
+ // command-line caller could type: it would reach the integer guard
183
+ // as text and be refused however correctly it was written.
184
+ //
185
+ // Whether the number is whole, positive and within the lease is
186
+ // the operation's decision, on the ledger. Unparseable text
187
+ // becomes `NaN` and is handed on deliberately, so it fails that
188
+ // guard and produces the refusal naming the range rather than a
189
+ // different one invented here.
190
+ waitMs: asInteger(argument(args, 'wait_ms', 'waitMs')),
178
191
  })),
179
192
  };
180
193
  }
@@ -222,6 +235,22 @@ export function serviceFor(options) {
222
235
  // told the diff is available, passes it, and gets a capture with no
223
236
  // comparison and nothing saying why.
224
237
  const compareTo = argument(args, 'compare_to', 'compareTo');
238
+ // The resolution rung and the written justification the top rung
239
+ // requires. **Coercion only, never validation**, the same split the
240
+ // wait above keeps: `validateCaptureTier` names the accepted words and
241
+ // the pipeline decides whether a reason is owed, so an unrecognised
242
+ // value is handed on deliberately and reaches the rule that can
243
+ // explain it.
244
+ //
245
+ // Read here because the ladder they drive was built end to end and
246
+ // connected to nothing: the pipeline refuses the top rung without a
247
+ // reason, the `captures` table carries the column, and the telemetry
248
+ // rollups query it — while no surface could populate either field. An
249
+ // escalation rollup with no way to escalate does not report an empty
250
+ // result, it reports "nobody escalates", which is a fact that is not
251
+ // one.
252
+ const tier = argument(args, 'tier');
253
+ const reason = argument(args, 'reason');
225
254
  return {
226
255
  ...(await broker.capture({
227
256
  key,
@@ -229,6 +258,8 @@ export function serviceFor(options) {
229
258
  ...(fullPage === undefined ? {} : { fullPage: asBoolean(fullPage) }),
230
259
  ...(typeof selector === 'string' ? { selector } : {}),
231
260
  ...(typeof compareTo === 'string' && compareTo.length > 0 ? { compareTo } : {}),
261
+ ...(tier === undefined ? {} : { tier }),
262
+ ...(reason === undefined ? {} : { reason }),
232
263
  })),
233
264
  };
234
265
  }
@@ -358,8 +389,8 @@ function actionFrom(args) {
358
389
  const targetRef = argument(args, 'target_ref', 'targetRef');
359
390
  const viewport = viewportFrom(args);
360
391
  const preferences = preferencesFrom(args);
361
- const response = argument(args, 'response');
362
- const fields = argument(args, 'fields');
392
+ const response = responseFrom(args);
393
+ const fields = fieldsFrom(args);
363
394
  return {
364
395
  action,
365
396
  ...(ref === undefined ? {} : { ref }),
@@ -429,6 +460,121 @@ function viewportFrom(args) {
429
460
  }
430
461
  return undefined;
431
462
  }
463
+ /**
464
+ * The answer a `dialog` gives, assembled from flat flags.
465
+ *
466
+ * The same unreachability {@link viewportFrom} exists for, and it survived the
467
+ * pull request that fixed that one — recorded there as "known and deliberately
468
+ * not fixed here", which is a reasonable thing to write once and a poor thing
469
+ * to leave true.
470
+ *
471
+ * `validateAction` wants `response: { accept, promptText? }`, an object whose
472
+ * first member is a **boolean**. A command line produces flat strings, so
473
+ * `--response accept`, `--value accept` and `--accept true` all arrived as
474
+ * something that is not an object and drew the identical refusal:
475
+ *
476
+ * > Answering a dialog says whether to accept it or dismiss it.
477
+ *
478
+ * Which is what the caller was trying to say. As with a resize, the message
479
+ * describes the semantics and never the syntax, and no syntax existed.
480
+ *
481
+ * `--accept` and `--dismiss` are the two things a caller means, written as
482
+ * the two words the refusal itself uses. `--prompt-text` carries what to type
483
+ * before accepting. **Coercion only** — that text may not accompany a
484
+ * dismissal, and that stays the operation's decision, on the ledger, rather
485
+ * than being re-decided here.
486
+ */
487
+ function responseFrom(args) {
488
+ const given = argument(args, 'response');
489
+ if (given !== undefined && typeof given === 'object') {
490
+ return given;
491
+ }
492
+ const promptText = argument(args, 'prompt_text', 'promptText');
493
+ const accept = acceptFrom(args, given);
494
+ if (accept === undefined) {
495
+ return given;
496
+ }
497
+ return {
498
+ accept,
499
+ ...(typeof promptText === 'string' ? { promptText } : {}),
500
+ };
501
+ }
502
+ /**
503
+ * Whether the caller said accept or dismiss, across the spellings a person
504
+ * reaches for. Anything else is left alone so the operation refuses it.
505
+ */
506
+ function acceptFrom(args, given) {
507
+ const acceptFlag = argument(args, 'accept');
508
+ const dismissFlag = argument(args, 'dismiss');
509
+ // Both flags names opposite intentions, so neither is the answer. Resolving
510
+ // them by which was read first hands the caller a decision it never made,
511
+ // and about the one thing a dialog answer decides — the same reasoning that
512
+ // refuses prompt text alongside a dismissal rather than picking one.
513
+ //
514
+ // **Nothing is refused here, deliberately.** This function coerces; the
515
+ // operation decides. Yielding `undefined` for an unanswerable pair leaves
516
+ // the assembled response without an `accept`, which the dialog operation
517
+ // already refuses by name — so the rule stays spelled in one place and
518
+ // arrives identically whichever route a caller came in on. Inventing a
519
+ // second rule at this layer would make the same mistake refuse differently
520
+ // depending on the transport.
521
+ if (acceptFlag !== undefined && dismissFlag !== undefined) {
522
+ return undefined;
523
+ }
524
+ if (acceptFlag !== undefined) {
525
+ return acceptFlag === '' || acceptFlag === true || acceptFlag === 'true' ? true : undefined;
526
+ }
527
+ if (dismissFlag !== undefined) {
528
+ return dismissFlag === '' || dismissFlag === true || dismissFlag === 'true' ? false : undefined;
529
+ }
530
+ const word = typeof given === 'string' ? given : argument(args, 'value');
531
+ if (word === 'accept')
532
+ return true;
533
+ if (word === 'dismiss')
534
+ return false;
535
+ return undefined;
536
+ }
537
+ /**
538
+ * The fields a `fill_form` fills, assembled from repeated flat pairs.
539
+ *
540
+ * Unreachable for the same reason and fixed the same way: the operation wants
541
+ * an **array of objects**, and a command line has only strings.
542
+ *
543
+ * `--field ref=value` is the form, repeated once per field, because that is
544
+ * what the operation is — a list of pairs — and because a caller already types
545
+ * `--target` and `--value` for the single-field verbs. Only the first `=`
546
+ * separates, so a value may contain one.
547
+ *
548
+ * **Coercion only.** The bound on how many fields a batch carries, and whether
549
+ * a reference is a reference, stay inside the operation.
550
+ */
551
+ function fieldsFrom(args) {
552
+ const given = argument(args, 'fields');
553
+ if (given !== undefined && Array.isArray(given)) {
554
+ return given;
555
+ }
556
+ const pairs = argument(args, 'field');
557
+ const list = Array.isArray(pairs)
558
+ ? pairs
559
+ : pairs === undefined
560
+ ? []
561
+ : [pairs];
562
+ if (list.length === 0) {
563
+ return given;
564
+ }
565
+ return list.map((pair) => {
566
+ if (typeof pair !== 'string') {
567
+ return pair;
568
+ }
569
+ const split = pair.indexOf('=');
570
+ if (split < 0) {
571
+ // No separator is not a pair. Handed on so the operation refuses it
572
+ // and names which field, rather than being silently dropped here.
573
+ return { ref: pair };
574
+ }
575
+ return { ref: pair.slice(0, split), value: pair.slice(split + 1) };
576
+ });
577
+ }
432
578
  /**
433
579
  * The media preferences an `emulate` sets, assembled from flat flags.
434
580
  *
@@ -95,7 +95,58 @@ export function createBroker(options) {
95
95
  }
96
96
  return result;
97
97
  },
98
- status: (input) => run('status', input),
98
+ /**
99
+ * **After the call returns, so the probe is outside the transaction.**
100
+ * The same shape `claim` above uses for its seed, and for a stricter
101
+ * reason: this one talks to a browser, and
102
+ * `arbitration.no_browser_io` (§2.4b) is what keeps that out of the
103
+ * arbitration transaction.
104
+ *
105
+ * A queued lease is not probed. It holds no tab and is waiting for
106
+ * capacity rather than using it, so a browser that is down is the
107
+ * ordinary reason the queue exists — reporting a waiting caller as
108
+ * expired would end a lease that has nothing wrong with it.
109
+ */
110
+ status: async (input) => {
111
+ const result = await run('status', input);
112
+ if (result.state !== 'active' || options.checkBrowser === undefined) {
113
+ return result;
114
+ }
115
+ const liveness = await options.checkBrowser(result.browserId);
116
+ if (liveness !== 'gone') {
117
+ return { ...result, browser: liveness };
118
+ }
119
+ // **The lease is reported `expired`, and this writes nothing.**
120
+ //
121
+ // Two things are true at once and the split matters: the browser is
122
+ // gone, so this lease cannot do anything a lease is for; but the
123
+ // reclamation that ends leases is the sweep's, inside the arbitration
124
+ // transaction, and this code is deliberately outside it. Writing here
125
+ // would be a second writer of lease state racing the one that owns it.
126
+ //
127
+ // So this reports the truth without asserting authorship of it, which
128
+ // is exactly what §2.4's standing rule licenses: **stored state is
129
+ // provisional, derived state is the truth.** A row saying `active`
130
+ // against a dead browser is provisional in the same way a lapsed row
131
+ // is, and a reader that derives correctly renders the same picture
132
+ // whether or not the row has been swept.
133
+ //
134
+ // `tabId` is dropped rather than carried: it names a page in a browser
135
+ // that does not exist, and handing it back invites a caller to address
136
+ // it. Nothing owns it to close — the browser took it.
137
+ const { tabId, ...withoutTab } = result;
138
+ // Read so that dropping it is a decision the compiler can see rather
139
+ // than an unused binding a later cleanup would "tidy" back into the
140
+ // result. The tab is real in the store; what is gone is the browser
141
+ // holding it, which is why it is omitted here and not closed here.
142
+ void tabId;
143
+ return {
144
+ ...withoutTab,
145
+ state: 'expired',
146
+ browser: 'gone',
147
+ checkBack: `The ${result.browserId} browser this lease was held against is not running: its endpoint did not answer, or answered as a different browser. The lease cannot be used, so it is reported expired rather than active however much time is left on it. Release this lease and claim again — a claim is what starts a browser, and the next one will start a fresh one.`,
148
+ };
149
+ },
99
150
  release: (input) => run('release', {
100
151
  ...input,
101
152
  settings,
@@ -47,6 +47,30 @@ import { StartupRefusal } from "../errors.js";
47
47
  * The memo holds the **promise**, not the resolved session, so two verbs
48
48
  * racing in the same process await one acquisition rather than starting two.
49
49
  *
50
+ * ── …but a memoised session is checked before it is handed back ──────────
51
+ *
52
+ * One session per process is right; *trusting* it for the life of the process
53
+ * is not. A session is a connection, and a connection can end while the
54
+ * browser it points at is still running — so a memo that is never revalidated
55
+ * hands a dead connection to every page verb until the process exits.
56
+ *
57
+ * That is not hypothetical and it is why this check exists. It is the
58
+ * difference between the two surfaces: the command line is one process per
59
+ * command, so its memo cannot outlive the verb that created it and the state
60
+ * is unreachable there. The tool surface serves a whole session from one
61
+ * process, so it is the surface where a stale memo is not merely possible but
62
+ * eventually certain — and a caller doing the obvious correct thing (release,
63
+ * claim again, drive the page) got the same dead connection every time, with
64
+ * no way back from that surface at all.
65
+ *
66
+ * So {@link BrowserSessions.session} asks {@link BrowserSession.isConnected}
67
+ * before returning a settled entry and drops it if the answer is no. **This
68
+ * is a different question from {@link BrowserSessions.liveness}**, which asks
69
+ * the machine whether a browser is running: in the state above the browser is
70
+ * running, so liveness says `live` and correctly evicts nothing. Both checks
71
+ * are needed because a browser and a connection can each die without the
72
+ * other.
73
+ *
50
74
  * ── A failed acquisition is not cached ───────────────────────────────────
51
75
  *
52
76
  * If acquiring throws, the memo is cleared, so the next call tries again. The
@@ -82,20 +106,69 @@ export function browserSessionProvider(options) {
82
106
  // BrowserSessionProviderOptions.artifacts} for why this is the
83
107
  // shared tree rather than one lease's.
84
108
  { outputDirectory: path.join(options.artifacts.root, 'snapshots') }),
85
- // **The signed-in engine, carried from this process's one environment
86
- // snapshot** (§6.3, `DECISIONS.md` §13i). One driver serves every
87
- // browser in this process, so the kind-specific engine cannot be chosen
88
- // per session here — see the note on `RealDriverOptions.engine` for
89
- // what the value does and does not do, which is what makes one engine
90
- // per process the honest shape rather than a shortcut.
91
- engine: options.environment.regularBrowserEngine,
92
109
  });
93
110
  const inFlight = new Map();
94
111
  const settled = new Map();
95
112
  const session = (browser) => {
96
113
  const existing = inFlight.get(browser);
97
114
  if (existing !== undefined) {
98
- return existing;
115
+ // ── The memo is checked before it is trusted ──────────────────────
116
+ //
117
+ // **This is what stops a dead connection being handed out for the life
118
+ // of a long-running process.** A settled session is a live connection
119
+ // over the debugging protocol, and a connection can end while the
120
+ // browser it points at carries on perfectly well — a browser restart, a
121
+ // closed target, a dropped protocol socket. When that happens the
122
+ // session object is still here, still resolved, and every page verb
123
+ // performed over it fails with `Target page, context or browser has
124
+ // been closed`.
125
+ //
126
+ // Nothing else in this file can catch that state, which is why it
127
+ // survived a previous fix. The `.catch` below clears only a *rejected*
128
+ // acquisition, and a session that resolved and later died never
129
+ // rejects. {@link BrowserSessions.liveness} clears a dead entry, but it
130
+ // asks the machine whether a browser is **running** — which in this
131
+ // state is `true` — and it is reached only from `status`, never from a
132
+ // page verb.
133
+ //
134
+ // ── Why it is safe to consult on the hot path ─────────────────────
135
+ //
136
+ // `isConnected` reads a flag the connection already maintains. It
137
+ // performs no input/output and cannot throw, so this adds no round trip
138
+ // to a call that is about to make several.
139
+ //
140
+ // ── Every session answers, because the member is required ─────────
141
+ //
142
+ // It was optional first, so that a source unable to observe its
143
+ // connection could stay silent and be assumed usable. That let the one
144
+ // production session omit it entirely while `tsc` stayed quiet, and
145
+ // this guard then took the assume-usable branch on every real call —
146
+ // shipping a fix that changed nothing. A source that cannot tell now
147
+ // returns `true` explicitly instead, so the permissive answer is a
148
+ // decision in the source rather than a hole in it.
149
+ //
150
+ // ── Only a settled session can be judged ──────────────────────────
151
+ //
152
+ // An entry still in flight has no session to ask yet, and it is
153
+ // returned untouched: two verbs racing must await one acquisition
154
+ // rather than starting two, which is the property the promise-valued
155
+ // memo exists for. An acquisition in progress cannot be stale.
156
+ const open = settled.get(browser);
157
+ if (open === undefined || open.isConnected()) {
158
+ return existing;
159
+ }
160
+ // ── Dropped, not detached, and nothing is launched here ───────────
161
+ //
162
+ // There is nothing to detach from: the connection is the thing that
163
+ // ended. Dropping both entries sends this very call through `acquire`,
164
+ // which makes its own observation and wins or loses the launch race in
165
+ // the store like any other caller — the same recovery `liveness` takes,
166
+ // for the same reason it takes it that way. **No browser is ended**
167
+ // (`browser_scoped.never`, §7.3) and none is started from here: a
168
+ // second process launching against one profile directory is the
169
+ // measured silent-collision failure the race exists to prevent.
170
+ settled.delete(browser);
171
+ inFlight.delete(browser);
99
172
  }
100
173
  const acquiring = acquire(driver, browser, options)
101
174
  .then((acquired) => {
@@ -111,8 +184,74 @@ export function browserSessionProvider(options) {
111
184
  inFlight.set(browser, acquiring);
112
185
  return acquiring;
113
186
  };
187
+ const liveness = async (browser) => {
188
+ const isRunning = options.isRunning ?? browserIsRunning;
189
+ const profileDir = profileDirectory(options.environment.profileRoot, browser);
190
+ try {
191
+ const record = await isRunning(profileDir);
192
+ // The same two-condition reading `acquire` applies to its own
193
+ // observation: a record with no identifier failed the identity half,
194
+ // and a record that failed either half is stale (§1.2c). Stale means
195
+ // the browser is treated as not running.
196
+ if (record !== undefined && record.browserUuid !== undefined) {
197
+ return 'live';
198
+ }
199
+ // ── The recovery path, and it is one line for a reason ────────────
200
+ //
201
+ // **Forgetting the dead session is what makes reclaiming work.** The
202
+ // memoised entry is a connection to a browser that is gone, and it is
203
+ // handed to every page verb for the life of this process — so without
204
+ // this, a caller that does the obvious correct thing (release, claim
205
+ // again, drive the page) gets the same dead attachment each time and
206
+ // there is no way back from the tool surface at all.
207
+ //
208
+ // Dropping it sends the next caller through `acquire`, which makes the
209
+ // observation, loses or wins the launch race in the store like any
210
+ // other caller, and starts a browser. Nothing here launches anything
211
+ // itself: a second process launching against one profile directory is
212
+ // the measured silent-collision failure the race exists to prevent.
213
+ //
214
+ // **Nothing is detached and no browser is ended.** There is nothing to
215
+ // detach from — the browser is the thing that went away — and this
216
+ // service never ends a browser (`browser_scoped.never`, §7.3).
217
+ settled.delete(browser);
218
+ inFlight.delete(browser);
219
+ // ── A browser that was never started is NOT a browser that died ────
220
+ //
221
+ // The distinction is the whole reason this reads the row, and getting
222
+ // it wrong breaks the ordinary path rather than an edge case: **a lease
223
+ // is granted before any browser exists.** Acquisition is lazy, so the
224
+ // normal life of a lease is claim, then status, then a page verb that
225
+ // finally causes the launch — and on a machine with no browser
226
+ // installed at all, that launch never comes and the lease is still
227
+ // perfectly valid for everything that does not need a page.
228
+ //
229
+ // Both states look identical from the profile directory: no verified
230
+ // record either way. What separates them is what the store was told.
231
+ // `recordLaunched` moves the row to `running`, so a row that says
232
+ // `running` while nothing answers is a browser that **has died**;
233
+ // `stopped` is one that was never started, and a lease against it is
234
+ // waiting for a launch rather than holding a corpse.
235
+ const row = options.store.db
236
+ .prepare('SELECT state FROM browsers WHERE id = ?')
237
+ .get(browser);
238
+ return row?.state === 'running' ? 'gone' : 'unknown';
239
+ }
240
+ catch {
241
+ // **`unknown`, never `gone`.** A probe that could not be carried out —
242
+ // an unreadable profile directory, a fetch that threw rather than
243
+ // answering — has observed nothing, and reporting a browser dead on the
244
+ // strength of a failed observation would end working leases on a
245
+ // machine having an unrelated bad moment. Not knowing is a state this
246
+ // result can express precisely so it does not have to be guessed at.
247
+ return 'unknown';
248
+ }
249
+ };
250
+ const holds = (browser) => settled.has(browser) || inFlight.has(browser);
114
251
  return {
115
252
  session,
253
+ liveness,
254
+ holds,
116
255
  close: async () => {
117
256
  for (const open of settled.values()) {
118
257
  try {
@@ -4,13 +4,31 @@ import { reconcileGeometry } from "../diff/geometry.js";
4
4
  import { decodePng, encodePng } from "../diff/image.js";
5
5
  import { computeMask } from "../diff/mask.js";
6
6
  import { extractRegions } from "../diff/regions.js";
7
- /** A result carrying no diff, with the sentence saying why. */
8
- function noDiff(settings, explanation, extra = {}) {
7
+ /**
8
+ * A result carrying no diff, with the sentence saying why.
9
+ *
10
+ * **`changed`, `changedPixels` and `changedRatio` are not set here at all** —
11
+ * not to `false`, not to `0`. Every path into this function is a path on which
12
+ * no comparison ran, so there is no finding to report, and the shape says so by
13
+ * having no field rather than by having a field whose value happens to be the
14
+ * one a real all-clear also produces. `false` and `0` are what a comparison
15
+ * that ran and found nothing returns; emitting them here would make the two
16
+ * cases identical to anything reading the fields directly.
17
+ *
18
+ * `regions` stays an empty array and `truncated` stays `false` because those
19
+ * describe the *output listing* rather than a finding about the page: an empty
20
+ * list of regions is honest about a call that produced no regions, and neither
21
+ * can be misread as an assertion that the page is unchanged.
22
+ */
23
+ function noDiff(settings, explanation,
24
+ // **Typed to exclude the three findings**, rather than a bare
25
+ // `Partial<ComparisonResult>`. The spread below is the one way a caller could
26
+ // put `changed` back into a no-diff result, so the parameter that feeds it
27
+ // does not accept those keys and a future call site trying to pass one is a
28
+ // build failure instead of a silently restored defect.
29
+ extra = {}) {
9
30
  return {
10
31
  diffed: false,
11
- changed: false,
12
- changedPixels: 0,
13
- changedRatio: 0,
14
32
  regions: [],
15
33
  overlayPath: null,
16
34
  truncated: false,