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.
- package/.env.example +0 -16
- package/README.md +55 -23
- package/dist/package.json +3 -3
- package/dist/src/adapter/conformance/cases.js +138 -1
- package/dist/src/adapter/conformance/run.js +135 -0
- package/dist/src/adapter/conformance/service-subject.js +5 -1
- package/dist/src/browser/fake.js +69 -3
- package/dist/src/browser/real.js +23 -33
- package/dist/src/capture/tiers.js +53 -0
- package/dist/src/cli/adapter.js +37 -3
- package/dist/src/cli/commands.js +42 -1
- package/dist/src/cli/index.js +12 -1
- package/dist/src/cli/operations-commands.js +82 -4
- package/dist/src/cli/reconcile-command.js +90 -7
- package/dist/src/config/environment.js +0 -44
- package/dist/src/doctor/checks.js +64 -0
- package/dist/src/doctor/report.js +10 -1
- package/dist/src/service/arbitration.js +61 -0
- package/dist/src/service/bridge.js +148 -2
- package/dist/src/service/broker.js +52 -1
- package/dist/src/service/browser-session.js +147 -8
- package/dist/src/service/comparison.js +23 -5
- package/dist/src/service/operations/claim.js +63 -0
- package/dist/src/service/operations/pages.js +150 -5
- package/dist/src/service/operations/status.js +8 -0
- package/dist/src/service/pages.js +81 -0
- package/dist/src/service/reconcile.js +75 -3
- package/dist/src/service/runtime.js +26 -1
- package/dist/src/service/tabs.js +70 -0
- package/dist/src/tool/session.js +17 -4
- package/dist/src/tool/tools.js +162 -6
- package/package.json +3 -3
- package/RELEASES.md +0 -97
|
@@ -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 =
|
|
362
|
-
const 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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
8
|
-
|
|
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,
|