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.
@@ -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[normaliseKey(body.slice(0, equals))] = body.slice(equals + 1);
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[normaliseKey(body)] = true;
131
+ record(parsed, normaliseKey(body), true);
132
132
  continue;
133
133
  }
134
- parsed[normaliseKey(body)] = next;
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
  }
@@ -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
  {
@@ -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
- /** `--name value` and `--name=value`, plus bare `--flag`. */
18
- export function parseFlags(rest) {
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
- const flags = parseFlags(rest);
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
- 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. Run again once they have settled.`);
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
  *