browser-broker 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +6 -5
- package/README.md +59 -19
- package/RELEASES.md +255 -97
- package/dist/package.json +3 -2
- package/dist/src/adapter/conformance/service-subject.js +5 -1
- package/dist/src/browser/fake.js +16 -2
- package/dist/src/browser/real.js +10 -2
- package/dist/src/cli/adapter.js +37 -3
- package/dist/src/cli/commands.js +37 -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 +35 -3
- 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 +66 -0
- package/dist/src/service/operations/claim.js +63 -0
- package/dist/src/service/operations/pages.js +66 -4
- 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 +154 -3
- package/package.json +3 -2
|
@@ -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,
|
|
@@ -111,8 +111,74 @@ export function browserSessionProvider(options) {
|
|
|
111
111
|
inFlight.set(browser, acquiring);
|
|
112
112
|
return acquiring;
|
|
113
113
|
};
|
|
114
|
+
const liveness = async (browser) => {
|
|
115
|
+
const isRunning = options.isRunning ?? browserIsRunning;
|
|
116
|
+
const profileDir = profileDirectory(options.environment.profileRoot, browser);
|
|
117
|
+
try {
|
|
118
|
+
const record = await isRunning(profileDir);
|
|
119
|
+
// The same two-condition reading `acquire` applies to its own
|
|
120
|
+
// observation: a record with no identifier failed the identity half,
|
|
121
|
+
// and a record that failed either half is stale (§1.2c). Stale means
|
|
122
|
+
// the browser is treated as not running.
|
|
123
|
+
if (record !== undefined && record.browserUuid !== undefined) {
|
|
124
|
+
return 'live';
|
|
125
|
+
}
|
|
126
|
+
// ── The recovery path, and it is one line for a reason ────────────
|
|
127
|
+
//
|
|
128
|
+
// **Forgetting the dead session is what makes reclaiming work.** The
|
|
129
|
+
// memoised entry is a connection to a browser that is gone, and it is
|
|
130
|
+
// handed to every page verb for the life of this process — so without
|
|
131
|
+
// this, a caller that does the obvious correct thing (release, claim
|
|
132
|
+
// again, drive the page) gets the same dead attachment each time and
|
|
133
|
+
// there is no way back from the tool surface at all.
|
|
134
|
+
//
|
|
135
|
+
// Dropping it sends the next caller through `acquire`, which makes the
|
|
136
|
+
// observation, loses or wins the launch race in the store like any
|
|
137
|
+
// other caller, and starts a browser. Nothing here launches anything
|
|
138
|
+
// itself: a second process launching against one profile directory is
|
|
139
|
+
// the measured silent-collision failure the race exists to prevent.
|
|
140
|
+
//
|
|
141
|
+
// **Nothing is detached and no browser is ended.** There is nothing to
|
|
142
|
+
// detach from — the browser is the thing that went away — and this
|
|
143
|
+
// service never ends a browser (`browser_scoped.never`, §7.3).
|
|
144
|
+
settled.delete(browser);
|
|
145
|
+
inFlight.delete(browser);
|
|
146
|
+
// ── A browser that was never started is NOT a browser that died ────
|
|
147
|
+
//
|
|
148
|
+
// The distinction is the whole reason this reads the row, and getting
|
|
149
|
+
// it wrong breaks the ordinary path rather than an edge case: **a lease
|
|
150
|
+
// is granted before any browser exists.** Acquisition is lazy, so the
|
|
151
|
+
// normal life of a lease is claim, then status, then a page verb that
|
|
152
|
+
// finally causes the launch — and on a machine with no browser
|
|
153
|
+
// installed at all, that launch never comes and the lease is still
|
|
154
|
+
// perfectly valid for everything that does not need a page.
|
|
155
|
+
//
|
|
156
|
+
// Both states look identical from the profile directory: no verified
|
|
157
|
+
// record either way. What separates them is what the store was told.
|
|
158
|
+
// `recordLaunched` moves the row to `running`, so a row that says
|
|
159
|
+
// `running` while nothing answers is a browser that **has died**;
|
|
160
|
+
// `stopped` is one that was never started, and a lease against it is
|
|
161
|
+
// waiting for a launch rather than holding a corpse.
|
|
162
|
+
const row = options.store.db
|
|
163
|
+
.prepare('SELECT state FROM browsers WHERE id = ?')
|
|
164
|
+
.get(browser);
|
|
165
|
+
return row?.state === 'running' ? 'gone' : 'unknown';
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
// **`unknown`, never `gone`.** A probe that could not be carried out —
|
|
169
|
+
// an unreadable profile directory, a fetch that threw rather than
|
|
170
|
+
// answering — has observed nothing, and reporting a browser dead on the
|
|
171
|
+
// strength of a failed observation would end working leases on a
|
|
172
|
+
// machine having an unrelated bad moment. Not knowing is a state this
|
|
173
|
+
// result can express precisely so it does not have to be guessed at.
|
|
174
|
+
return 'unknown';
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
const holds = (browser) => settled.has(browser) || inFlight.has(browser);
|
|
114
178
|
return {
|
|
115
179
|
session,
|
|
180
|
+
liveness,
|
|
181
|
+
holds,
|
|
116
182
|
close: async () => {
|
|
117
183
|
for (const open of settled.values()) {
|
|
118
184
|
try {
|
|
@@ -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,7 +2,7 @@ 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";
|
|
@@ -277,10 +277,22 @@ function withPageDriven(value, work) {
|
|
|
277
277
|
*
|
|
278
278
|
* The address is checked against the scheme allowlist before anything is
|
|
279
279
|
* written, so a refused scheme leaves no trace but the refusal row.
|
|
280
|
+
*
|
|
281
|
+
* The wait is checked after the lease has been resolved rather than before,
|
|
282
|
+
* because what bounds it is that lease's own promised lifetime. It leaves the
|
|
283
|
+
* same absence behind: a refusal rolls the transaction back, taking the
|
|
284
|
+
* renewal with it, so the tab is never asked to go anywhere.
|
|
280
285
|
*/
|
|
281
286
|
export function decideNavigate(scope, input) {
|
|
282
287
|
const url = validateNavigationTarget(input.url);
|
|
283
288
|
const { lease, tab, expiresAt } = admit(scope, input, 'navigate');
|
|
289
|
+
// Bounded by **this lease's own promised lifetime**, read off the row
|
|
290
|
+
// `admit` just renewed rather than from a settings snapshot. That is the
|
|
291
|
+
// same source every duration these handlers report comes from, and for the
|
|
292
|
+
// same reason: a renewal extends by the duration the caller was already
|
|
293
|
+
// told about, so a ceiling taken from the environment could differ from the
|
|
294
|
+
// lease the caller is actually holding.
|
|
295
|
+
const waitMs = validateNavigationWait(input.waitMs, lease.ttlSeconds);
|
|
284
296
|
append(scope.db, {
|
|
285
297
|
kind: 'navigate',
|
|
286
298
|
outcome: 'allow',
|
|
@@ -289,14 +301,36 @@ export function decideNavigate(scope, input) {
|
|
|
289
301
|
tabId: tab.tabId,
|
|
290
302
|
sessionId: lease.sessionId,
|
|
291
303
|
browserId: tab.browserId,
|
|
292
|
-
|
|
304
|
+
// The wait is on the row when the caller asked for one, because the ledger
|
|
305
|
+
// is what answers *"what was this call actually given"* long after the
|
|
306
|
+
// call — and an argument that is invisible in the record is one nobody can
|
|
307
|
+
// check was honoured.
|
|
308
|
+
detail: { url, ...(waitMs === undefined ? {} : { waitMs }) },
|
|
293
309
|
});
|
|
294
|
-
const work = afterCommitWork(scope, input, tab, (session, page) => session.navigate(page, url), lease.claimId);
|
|
310
|
+
const work = afterCommitWork(scope, input, tab, (session, page) => session.navigate(page, url, waitMs), lease.claimId);
|
|
295
311
|
return {
|
|
296
312
|
value: withPageDriven({ claimId: lease.claimId, tabId: tab.tabId, expiresAt, url }, work),
|
|
297
313
|
afterCommit: work.afterCommit,
|
|
298
314
|
};
|
|
299
315
|
}
|
|
316
|
+
/**
|
|
317
|
+
* What an `emulate` result says about how long its effect lasts.
|
|
318
|
+
*
|
|
319
|
+
* A constant rather than an inline literal so there is one place to reword it.
|
|
320
|
+
*
|
|
321
|
+
* **What the tests hold it to is the meaning, not the wording.** They match the
|
|
322
|
+
* parts that have to survive a rewrite — that the effect is scoped to the
|
|
323
|
+
* connection, and that a path which works is named — rather than the sentence
|
|
324
|
+
* itself. Equality against the whole string would break on every harmless
|
|
325
|
+
* rewording while proving less: a note can keep every word and still stop
|
|
326
|
+
* telling a caller what to do. So a rewrite that keeps the meaning is free, and
|
|
327
|
+
* one that drops the working path fails.
|
|
328
|
+
*/
|
|
329
|
+
export const EMULATION_SCOPE_NOTE = 'This preference lasts as long as the connection that set it, not as long ' +
|
|
330
|
+
'as the tab. A later call from a separate invocation will not see it: the ' +
|
|
331
|
+
'tab survives and the emulation binding does not. To act on it, emulate ' +
|
|
332
|
+
'and capture within one invocation, or use the tool surface, where one ' +
|
|
333
|
+
'connection spans the calls.';
|
|
300
334
|
/**
|
|
301
335
|
* `act` (§3.6) — one interaction against an owned tab.
|
|
302
336
|
*
|
|
@@ -319,7 +353,17 @@ export function decideAct(scope, input) {
|
|
|
319
353
|
});
|
|
320
354
|
const work = afterCommitWork(scope, input, tab, (session, page) => session.act(page, request), lease.claimId);
|
|
321
355
|
return {
|
|
322
|
-
value: withPageDriven({
|
|
356
|
+
value: withPageDriven({
|
|
357
|
+
claimId: lease.claimId,
|
|
358
|
+
tabId: tab.tabId,
|
|
359
|
+
expiresAt,
|
|
360
|
+
action: request.action,
|
|
361
|
+
// Derived from the action that was validated, in the one place that
|
|
362
|
+
// knows which action ran — the same property `pageDriven` is built
|
|
363
|
+
// for. Spread away on the other twelve so the field's presence is
|
|
364
|
+
// itself the signal, with no "" to mistake for a scope nobody stated.
|
|
365
|
+
...(request.action === 'emulate' ? { emulationScope: EMULATION_SCOPE_NOTE } : {}),
|
|
366
|
+
}, work),
|
|
323
367
|
afterCommit: work.afterCommit,
|
|
324
368
|
};
|
|
325
369
|
}
|
|
@@ -478,9 +522,26 @@ export function decideCapture(scope, input) {
|
|
|
478
522
|
// before ownership is checked and before a single row is written, so the
|
|
479
523
|
// refusal leaves nothing behind but its own ledger entry.
|
|
480
524
|
validateCaptureMode({ fullPage, selector: input.selector });
|
|
525
|
+
// Checked here for the same reason and in the same place. The pipeline's own
|
|
526
|
+
// `refuseArgumentMistakes` still decides whether the top rung carries its
|
|
527
|
+
// written reason — that rule is not duplicated here, only the one the type
|
|
528
|
+
// system cannot make on text arriving from a surface.
|
|
529
|
+
const tier = validateCaptureTier(input.tier);
|
|
481
530
|
const request = {
|
|
482
531
|
fullPage,
|
|
483
532
|
...(input.selector === undefined ? {} : { selector: input.selector }),
|
|
533
|
+
...(tier === undefined ? {} : { tier }),
|
|
534
|
+
// Carried whenever it was given, rather than only alongside a tier.
|
|
535
|
+
//
|
|
536
|
+
// **A reason passed without a tier is still discarded**, and that is the
|
|
537
|
+
// pipeline's existing rule rather than something introduced here: it
|
|
538
|
+
// records a reason "only ever on the tier that requires it", because a
|
|
539
|
+
// reason attached to a rung nobody had to justify is not evidence of an
|
|
540
|
+
// escalation. Passing it on regardless keeps that decision in the one
|
|
541
|
+
// place that makes it, instead of adding a second, quieter version of it
|
|
542
|
+
// here — the value reaches the rule either way, and this layer does not
|
|
543
|
+
// get to have an opinion about which reasons are worth carrying.
|
|
544
|
+
...(typeof input.reason === 'string' ? { reason: input.reason } : {}),
|
|
484
545
|
};
|
|
485
546
|
const { lease, tab, expiresAt } = admit(scope, input, 'capture');
|
|
486
547
|
append(scope.db, {
|
|
@@ -539,6 +600,7 @@ export function decideCapture(scope, input) {
|
|
|
539
600
|
width: taken.width,
|
|
540
601
|
height: taken.height,
|
|
541
602
|
bytes: taken.bytes,
|
|
603
|
+
compareHint: `to diff a later capture against this one, pass compare_to: ${taken.captureId}`,
|
|
542
604
|
};
|
|
543
605
|
// ── The diff, when one was asked for (§3.11, §1.9) ──────────────────
|
|
544
606
|
//
|
|
@@ -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
|
*
|