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
package/dist/src/cli/adapter.js
CHANGED
|
@@ -123,19 +123,53 @@ export function parseArguments(rest) {
|
|
|
123
123
|
const body = word.slice(2);
|
|
124
124
|
const equals = body.indexOf('=');
|
|
125
125
|
if (equals !== -1) {
|
|
126
|
-
parsed
|
|
126
|
+
record(parsed, normaliseKey(body.slice(0, equals)), body.slice(equals + 1));
|
|
127
127
|
continue;
|
|
128
128
|
}
|
|
129
129
|
const next = rest[index + 1];
|
|
130
130
|
if (next === undefined || looksLikeFlag(next)) {
|
|
131
|
-
parsed
|
|
131
|
+
record(parsed, normaliseKey(body), true);
|
|
132
132
|
continue;
|
|
133
133
|
}
|
|
134
|
-
parsed
|
|
134
|
+
record(parsed, normaliseKey(body), next);
|
|
135
135
|
index += 1;
|
|
136
136
|
}
|
|
137
137
|
return parsed;
|
|
138
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* Write an option, accumulating the ones that may legitimately repeat.
|
|
141
|
+
*
|
|
142
|
+
* Assignment was unconditional, so a repeated option kept only its **last**
|
|
143
|
+
* occurrence. For every option that names one thing that is the right
|
|
144
|
+
* behaviour and the last word plainly wins. For an option that names *one of
|
|
145
|
+
* many* it is silent data loss: `--field a=1 --field b=2` filled one field
|
|
146
|
+
* and reported success, which is worse than the refusal it replaced, because
|
|
147
|
+
* a refusal is visible.
|
|
148
|
+
*
|
|
149
|
+
* Only the options in {@link REPEATABLE} accumulate. Making every option an
|
|
150
|
+
* array on its second appearance would change the type a reader gets for
|
|
151
|
+
* `--value` typed twice by accident, and turn a typo into a shape no
|
|
152
|
+
* operation expects.
|
|
153
|
+
*/
|
|
154
|
+
function record(parsed, key, value) {
|
|
155
|
+
if (!REPEATABLE.has(key)) {
|
|
156
|
+
parsed[key] = value;
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const existing = parsed[key];
|
|
160
|
+
if (existing === undefined) {
|
|
161
|
+
parsed[key] = [value];
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
existing.push(value);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* The options that name one of many rather than one thing.
|
|
168
|
+
*
|
|
169
|
+
* Keyed by their normalised names. Deliberately a short list: an option
|
|
170
|
+
* belongs here only when the operation behind it takes a collection.
|
|
171
|
+
*/
|
|
172
|
+
const REPEATABLE = new Set(['field']);
|
|
139
173
|
function normaliseKey(key) {
|
|
140
174
|
return key.replaceAll('-', '_');
|
|
141
175
|
}
|
package/dist/src/cli/commands.js
CHANGED
|
@@ -35,6 +35,26 @@ export const OPERATION_COMMANDS = [
|
|
|
35
35
|
operation: 'claim',
|
|
36
36
|
summary: 'Ask for a lease. Get one tab, or a place in the queue.',
|
|
37
37
|
options: [
|
|
38
|
+
// **The two a claim is refused for omitting, listed first because they
|
|
39
|
+
// are the two it is refused for omitting.** Both were absent here while
|
|
40
|
+
// `claim.session_bounded` and `claim.purpose_bounded` asked for them by
|
|
41
|
+
// name, which is the worst arrangement available: the refusals are
|
|
42
|
+
// models of the form — each names the missing thing and says why it
|
|
43
|
+
// exists — and a caller who did as they asked, under a plausible
|
|
44
|
+
// spelling, was refused a second time in identical words. A help text
|
|
45
|
+
// that lists three optional flags and neither required one teaches a
|
|
46
|
+
// reader how to call this command unsuccessfully.
|
|
47
|
+
{
|
|
48
|
+
flag: '--session-id <id>',
|
|
49
|
+
summary: 'Required. Who is asking. It attributes this lease and every refusal on it in the ' +
|
|
50
|
+
'ledger, and it is how the service can tell you when you are queued behind capacity ' +
|
|
51
|
+
'you already hold.',
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
flag: '--purpose <text>',
|
|
55
|
+
summary: 'Required, 3 to 200 characters. What the lease is for, in one line, read by whoever ' +
|
|
56
|
+
'finds the tab still open.',
|
|
57
|
+
},
|
|
38
58
|
{
|
|
39
59
|
flag: '--wait',
|
|
40
60
|
summary: 'Poll a queued place until it is granted, lost or refused, rather than returning the place.',
|
|
@@ -56,7 +76,7 @@ export const OPERATION_COMMANDS = [
|
|
|
56
76
|
{
|
|
57
77
|
words: ['act'],
|
|
58
78
|
operation: 'act',
|
|
59
|
-
summary: 'Click, type, fill, press, select, hover, check, scroll, resize, emulate, dialog.',
|
|
79
|
+
summary: 'Click, type, fill, press, select, hover, check, scroll, resize, emulate, dialog, fill_form.',
|
|
60
80
|
// **Undocumented options are unusable options**, and this command had
|
|
61
81
|
// none listed at all — so `broker act --help` printed `--json` and
|
|
62
82
|
// `--help` and nothing else, for the verb with the most arguments on the
|
|
@@ -93,6 +113,16 @@ export const OPERATION_COMMANDS = [
|
|
|
93
113
|
flag: '--forced-colours <active|none>',
|
|
94
114
|
summary: 'For emulate.',
|
|
95
115
|
},
|
|
116
|
+
{
|
|
117
|
+
flag: '--accept | --dismiss',
|
|
118
|
+
summary: 'For dialog, which answer to give. `--prompt-text <text>` is what to type before ' +
|
|
119
|
+
'accepting, and cannot accompany a dismissal.',
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
flag: '--field <ref>=<value>',
|
|
123
|
+
summary: 'For fill_form, one field to fill. Repeat it once per field; only the first = ' +
|
|
124
|
+
'separates, so a value may contain one.',
|
|
125
|
+
},
|
|
96
126
|
],
|
|
97
127
|
},
|
|
98
128
|
{
|
|
@@ -222,6 +252,12 @@ export const STANDALONE_COMMANDS = [
|
|
|
222
252
|
flag: '--browser <regular|private>',
|
|
223
253
|
summary: 'Which browser to reconcile. May also be given as the first word.',
|
|
224
254
|
},
|
|
255
|
+
{
|
|
256
|
+
flag: '--session-id <id>',
|
|
257
|
+
summary: 'Optional. Who is asking, so a tab still being opened can be named as your own lease ' +
|
|
258
|
+
'rather than as somebody’s. Omitting it is fine and costs only that distinction: ' +
|
|
259
|
+
'the report degrades to the general caution, which is the honest answer when nobody said.',
|
|
260
|
+
},
|
|
225
261
|
],
|
|
226
262
|
},
|
|
227
263
|
{
|
package/dist/src/cli/index.js
CHANGED
|
@@ -11,7 +11,7 @@ import { ArtifactStore } from "../artifacts/store.js";
|
|
|
11
11
|
import { runDiffs } from "./diffs.js";
|
|
12
12
|
import { runCaptures } from "./telemetry.js";
|
|
13
13
|
import { runImage } from "./image.js";
|
|
14
|
-
import { runDoctorCommand, runEventsCommand, runSnapshotCommand } from "./operations-commands.js";
|
|
14
|
+
import { runDoctorCommand, runEventsCommand, runSnapshotCommand, UnknownFlagError, } from "./operations-commands.js";
|
|
15
15
|
import { explainLoginFailure, runLoginCommand } from "./login-command.js";
|
|
16
16
|
import { runReconcileCommand } from "./reconcile-command.js";
|
|
17
17
|
const defaultStreams = {
|
|
@@ -805,6 +805,17 @@ async function runOperationsCommand(command, rest, context) {
|
|
|
805
805
|
return runEventsCommand(rest, { db: store.db, streams, json });
|
|
806
806
|
}
|
|
807
807
|
catch (error) {
|
|
808
|
+
// **A mistyped flag is malformed input, not a refused decision.** It is
|
|
809
|
+
// answered here rather than inside each command because all three parse
|
|
810
|
+
// their flags the same way and would otherwise each need the same catch —
|
|
811
|
+
// and because the exit code is the thing a caller branches on: `malformed`
|
|
812
|
+
// says the vector was wrong, which is what a typo is, while `refused`
|
|
813
|
+
// would say the service considered the request and declined it. Nothing
|
|
814
|
+
// was considered; the command never ran.
|
|
815
|
+
if (error instanceof UnknownFlagError) {
|
|
816
|
+
streams.err(error.message);
|
|
817
|
+
return EXIT.malformed;
|
|
818
|
+
}
|
|
808
819
|
if (error instanceof BrokerError) {
|
|
809
820
|
streams.err(`refused (${error.rule}): ${error.message}`);
|
|
810
821
|
return EXIT.refused;
|
|
@@ -14,14 +14,80 @@ import { writeSnapshot } from "../report/snapshot.js";
|
|
|
14
14
|
* constant is how they stay in step with the route that does.
|
|
15
15
|
*/
|
|
16
16
|
export const COMMAND_EXIT = EXIT;
|
|
17
|
-
/**
|
|
18
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Flags every command on this route takes, rendered by the help writer rather
|
|
19
|
+
* than declared per command — so they are always accepted and never appear in
|
|
20
|
+
* a command's own list of known flags.
|
|
21
|
+
*/
|
|
22
|
+
const UNIVERSAL_FLAGS = ['json', 'help'];
|
|
23
|
+
/**
|
|
24
|
+
* An unknown flag, named, with the flags that command does accept.
|
|
25
|
+
*
|
|
26
|
+
* Thrown rather than returned because {@link parseFlags} answers with a record
|
|
27
|
+
* and has no room in it for a refusal, and every caller of it is a command
|
|
28
|
+
* that must stop rather than proceed on a misread vector.
|
|
29
|
+
*/
|
|
30
|
+
export class UnknownFlagError extends Error {
|
|
31
|
+
// Declared and assigned rather than written as constructor parameter
|
|
32
|
+
// properties: this build strips types rather than compiling them, and a
|
|
33
|
+
// parameter property is syntax that needs a compiler to exist at runtime.
|
|
34
|
+
flag;
|
|
35
|
+
known;
|
|
36
|
+
constructor(flag, known) {
|
|
37
|
+
// Named the way `claim.browser_known` names the browsers: the thing that
|
|
38
|
+
// was wrong, then the set it should have come from. A caller that mistypes
|
|
39
|
+
// a flag is one edit from being right, and the edit is only obvious if the
|
|
40
|
+
// alternatives are on the screen.
|
|
41
|
+
super(`There is no option named --${flag}. This command accepts ${known
|
|
42
|
+
.map((name) => `--${name}`)
|
|
43
|
+
.join(', ')}.`);
|
|
44
|
+
this.name = 'UnknownFlagError';
|
|
45
|
+
this.flag = flag;
|
|
46
|
+
this.known = known;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* `--name value` and `--name=value`, plus bare `--flag`.
|
|
51
|
+
*
|
|
52
|
+
* ── Why an unknown flag is refused rather than dropped ──────────────────
|
|
53
|
+
*
|
|
54
|
+
* Because dropping it produced the one failure a good refusal cannot rescue.
|
|
55
|
+
* A caller typing `--session` instead of `--session-id` had the flag discarded
|
|
56
|
+
* without comment, so the command ran as though nothing had been passed and
|
|
57
|
+
* truthfully reported that nothing was there — and any argument riding behind
|
|
58
|
+
* the bad flag was consumed as its value and lost with it. The message that
|
|
59
|
+
* came back was correct, and it pointed at the value rather than at the flag
|
|
60
|
+
* name, which is the one place the error actually was. A refusal that repeats
|
|
61
|
+
* identically after a caller has complied with it moves their suspicion onto
|
|
62
|
+
* the wrong thing.
|
|
63
|
+
*
|
|
64
|
+
* This is what makes an undocumented flag unrecoverable rather than merely
|
|
65
|
+
* inconvenient: with no entry in `--help` and no signal from the parser, a
|
|
66
|
+
* caller has nothing to correct against and no reason to suspect a typo.
|
|
67
|
+
*
|
|
68
|
+
* **The known set is passed in by the command**, because only the command
|
|
69
|
+
* knows it. A parser that guessed would either refuse a flag that works or
|
|
70
|
+
* accept one that does not, and both reintroduce the silence.
|
|
71
|
+
*
|
|
72
|
+
* Omitting `known` accepts everything, which is what an in-process caller
|
|
73
|
+
* testing the parsing shape itself wants; every shipped command passes its
|
|
74
|
+
* list.
|
|
75
|
+
*/
|
|
76
|
+
export function parseFlags(rest, known) {
|
|
19
77
|
const parsed = {};
|
|
78
|
+
const accepted = known === undefined ? undefined : new Set([...known, ...UNIVERSAL_FLAGS]);
|
|
20
79
|
for (let index = 0; index < rest.length; index += 1) {
|
|
21
80
|
const word = rest[index];
|
|
22
81
|
if (word === undefined || !word.startsWith('--')) {
|
|
23
82
|
continue;
|
|
24
83
|
}
|
|
84
|
+
const name = word.slice(2).split('=')[0] ?? '';
|
|
85
|
+
if (accepted !== undefined && !accepted.has(name)) {
|
|
86
|
+
// The command's own flags, without the universal two: those are
|
|
87
|
+
// rendered by the help writer on every command, so listing them here
|
|
88
|
+
// would pad the sentence with the two the caller did not get wrong.
|
|
89
|
+
throw new UnknownFlagError(name, known ?? []);
|
|
90
|
+
}
|
|
25
91
|
const body = word.slice(2);
|
|
26
92
|
const equals = body.indexOf('=');
|
|
27
93
|
if (equals !== -1) {
|
|
@@ -62,7 +128,10 @@ function asNumber(value) {
|
|
|
62
128
|
* leaving a blank.
|
|
63
129
|
*/
|
|
64
130
|
export async function runSnapshotCommand(rest, options) {
|
|
65
|
-
|
|
131
|
+
// `output` and `path` are long-standing spellings of `--out` that this
|
|
132
|
+
// command has always read; they are accepted here for that reason, and left
|
|
133
|
+
// out of the help table because one name is what a table should teach.
|
|
134
|
+
const flags = parseFlags(rest, ['out', 'output', 'path', 'events', 'feedback']);
|
|
66
135
|
const outputPath = asString(flags.out) ?? asString(flags.output) ?? asString(flags.path);
|
|
67
136
|
if (outputPath === undefined) {
|
|
68
137
|
options.streams.err('broker snapshot needs somewhere to write: --out <path>. It writes one self-contained HTML file and exits.');
|
|
@@ -151,7 +220,16 @@ export function runDoctorCommand(options) {
|
|
|
151
220
|
* caller types reaches the SQL text.
|
|
152
221
|
*/
|
|
153
222
|
export function runEventsCommand(rest, options) {
|
|
154
|
-
const flags = parseFlags(rest
|
|
223
|
+
const flags = parseFlags(rest, [
|
|
224
|
+
'kind',
|
|
225
|
+
'outcome',
|
|
226
|
+
'guard',
|
|
227
|
+
'session-id',
|
|
228
|
+
'claim-id',
|
|
229
|
+
'since',
|
|
230
|
+
'before',
|
|
231
|
+
'limit',
|
|
232
|
+
]);
|
|
155
233
|
const query = {
|
|
156
234
|
kinds: asString(flags.kind)?.split(',') ?? undefined,
|
|
157
235
|
outcome: asString(flags.outcome),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { DEFAULT_BROWSER_IDS } from "../browser/driver.js";
|
|
2
2
|
import { append } from "../service/events.js";
|
|
3
|
-
import { applyReconciliation, decideReconciliation, readRecordedTabs, } from "../service/reconcile.js";
|
|
3
|
+
import { applyReconciliation, decideReconciliation, readRecordedTabs, settleStrandedTabs, } from "../service/reconcile.js";
|
|
4
4
|
import { COMMAND_EXIT, parseFlags } from "./operations-commands.js";
|
|
5
5
|
/** Timestamps are spelled one way in this store. */
|
|
6
6
|
function now() {
|
|
@@ -18,9 +18,13 @@ function isBrowserId(value, browsers) {
|
|
|
18
18
|
* person who wants both runs it twice and reads two reports.
|
|
19
19
|
*/
|
|
20
20
|
export async function runReconcileCommand(rest, options) {
|
|
21
|
-
const flags = parseFlags(rest);
|
|
21
|
+
const flags = parseFlags(rest, ['browser', 'session-id']);
|
|
22
22
|
const named = rest.find((word) => !word.startsWith('--'));
|
|
23
23
|
const browser = typeof flags.browser === 'string' ? flags.browser : named;
|
|
24
|
+
// Optional, and the report degrades honestly without it: a caller that does
|
|
25
|
+
// not say who it is gets the ordinary message rather than a claim about
|
|
26
|
+
// ownership nobody established.
|
|
27
|
+
const callerSession = typeof flags['session-id'] === 'string' ? flags['session-id'] : undefined;
|
|
24
28
|
const browsers = options.browsers ?? DEFAULT_BROWSER_IDS;
|
|
25
29
|
if (browser === undefined) {
|
|
26
30
|
options.streams.err(`broker reconcile needs to be told which browser: ${browsers.join(' or ')}. It asks that browser what it has open, closes pages no live lease owns, and settles rows whose pages are gone.`);
|
|
@@ -51,6 +55,13 @@ export async function runReconcileCommand(rest, options) {
|
|
|
51
55
|
// ── 4. Writing. A database handle and no session.
|
|
52
56
|
const at = now();
|
|
53
57
|
applyReconciliation(options.db, plan.vanishedTabs, at);
|
|
58
|
+
// Rows left `closing` by a lease that has already ended, whose page this
|
|
59
|
+
// browser does not have. The vanished-tab path cannot see them — it reads
|
|
60
|
+
// only tabs of *active* leases — so without this they are unreachable by
|
|
61
|
+
// anything, forever, while still holding their slot in the partial unique
|
|
62
|
+
// index. The browser has just said what it has open; that is the answer
|
|
63
|
+
// those rows were waiting for.
|
|
64
|
+
const strandedSettled = settleStrandedTabs(options.db, browser, pages.map((page) => page.driverTabId), at);
|
|
54
65
|
for (const tab of plan.vanishedTabs) {
|
|
55
66
|
// §1.6: one row per decision, and this is a decision — a lease was ended
|
|
56
67
|
// by something that was neither the caller nor the clock. `cli` rather
|
|
@@ -85,10 +96,14 @@ export async function runReconcileCommand(rest, options) {
|
|
|
85
96
|
}
|
|
86
97
|
const report = {
|
|
87
98
|
pagesSeen: pages.length,
|
|
99
|
+
strandedSettled,
|
|
88
100
|
settled: plan.vanishedTabs.map((tab) => tab.tabId),
|
|
89
101
|
closed,
|
|
90
102
|
closeFailures,
|
|
91
103
|
skippedOpening: plan.skippedOpening.length,
|
|
104
|
+
skippedOpeningOwnedByCaller: callerSession === undefined
|
|
105
|
+
? 0
|
|
106
|
+
: plan.skippedOpening.filter((tab) => tab.sessionId === callerSession).length,
|
|
92
107
|
};
|
|
93
108
|
if (options.json) {
|
|
94
109
|
options.streams.out(JSON.stringify({
|
|
@@ -123,6 +138,11 @@ export function formatReconciliation(browser, report) {
|
|
|
123
138
|
for (const tabId of report.settled) {
|
|
124
139
|
lines.push(` tab ${tabId}`);
|
|
125
140
|
}
|
|
141
|
+
// Only when it happened. A line that is present and zero on every healthy
|
|
142
|
+
// run is noise, and this one describes a state that should be rare.
|
|
143
|
+
if (report.strandedSettled > 0) {
|
|
144
|
+
lines.push(`records settled that were waiting on a close nobody was coming to answer: ${String(report.strandedSettled)}`);
|
|
145
|
+
}
|
|
126
146
|
if (report.closeFailures > 0) {
|
|
127
147
|
// §2.4b: a leaked page, not a leaked lease. Said in those terms so the
|
|
128
148
|
// reader knows what it costs — memory, and not budget.
|
|
@@ -131,7 +151,19 @@ export function formatReconciliation(browser, report) {
|
|
|
131
151
|
if (report.skippedOpening > 0) {
|
|
132
152
|
// Said on the run it happened on, because the alternative is a person
|
|
133
153
|
// reading "0 closed" and concluding there was nothing to close.
|
|
134
|
-
|
|
154
|
+
//
|
|
155
|
+
// **The caution itself does not change when the caller owns the blocking
|
|
156
|
+
// row.** Closing a page belonging to an in-flight claim would be worse
|
|
157
|
+
// than declining, and that is true whoever the claim belongs to. What
|
|
158
|
+
// changes is that the caller is told the remedy is in its own hands: an
|
|
159
|
+
// operator once ran this four times against a row that was its own lease,
|
|
160
|
+
// held open while it ran the command, with nothing in the message able to
|
|
161
|
+
// say so.
|
|
162
|
+
const owned = report.skippedOpeningOwnedByCaller;
|
|
163
|
+
lines.push(`${String(report.skippedOpening)} tab(s) are still being opened, so nothing was closed on this run — a page seen now may belong to one of them.` +
|
|
164
|
+
(owned > 0
|
|
165
|
+
? ` ${String(owned)} of them ${owned === 1 ? 'belongs' : 'belong'} to your own lease — release ${owned === 1 ? 'it' : 'them'}, or run this from another session.`
|
|
166
|
+
: ' Run again once they have settled.'));
|
|
135
167
|
}
|
|
136
168
|
return lines;
|
|
137
169
|
}
|
|
@@ -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
|
*
|