browser-broker 0.1.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.
Files changed (123) hide show
  1. package/.env.example +173 -0
  2. package/LICENSE +21 -0
  3. package/README.md +374 -0
  4. package/RELEASES.md +97 -0
  5. package/dist/package.json +58 -0
  6. package/dist/src/adapter/conformance/case.js +1 -0
  7. package/dist/src/adapter/conformance/cases.js +429 -0
  8. package/dist/src/adapter/conformance/discovery.js +156 -0
  9. package/dist/src/adapter/conformance/driver.js +1 -0
  10. package/dist/src/adapter/conformance/drivers.js +36 -0
  11. package/dist/src/adapter/conformance/run.js +224 -0
  12. package/dist/src/adapter/conformance/service-subject.js +165 -0
  13. package/dist/src/adapter/contract.js +24 -0
  14. package/dist/src/adapter/operations.js +114 -0
  15. package/dist/src/adapter/service-seam.js +1 -0
  16. package/dist/src/artifacts/names.js +229 -0
  17. package/dist/src/artifacts/store.js +174 -0
  18. package/dist/src/bin/broker-tool.js +63 -0
  19. package/dist/src/bin/broker.js +111 -0
  20. package/dist/src/browser/adoption.js +143 -0
  21. package/dist/src/browser/automation-probe.js +113 -0
  22. package/dist/src/browser/conformance/case.js +1 -0
  23. package/dist/src/browser/conformance/cases.js +192 -0
  24. package/dist/src/browser/conformance/run.js +102 -0
  25. package/dist/src/browser/conformance/subjects.js +19 -0
  26. package/dist/src/browser/discovery.js +226 -0
  27. package/dist/src/browser/driver.js +195 -0
  28. package/dist/src/browser/fake.js +585 -0
  29. package/dist/src/browser/launch.js +504 -0
  30. package/dist/src/browser/real.js +1425 -0
  31. package/dist/src/browser/setup.js +161 -0
  32. package/dist/src/capture/accounting.js +59 -0
  33. package/dist/src/capture/image.js +112 -0
  34. package/dist/src/capture/ladder.js +72 -0
  35. package/dist/src/capture/legibility.js +195 -0
  36. package/dist/src/capture/pipeline.js +153 -0
  37. package/dist/src/capture/tiers.js +166 -0
  38. package/dist/src/cli/adapter.js +233 -0
  39. package/dist/src/cli/commands.js +270 -0
  40. package/dist/src/cli/conformance-driver.js +119 -0
  41. package/dist/src/cli/diffs.js +122 -0
  42. package/dist/src/cli/image.js +274 -0
  43. package/dist/src/cli/index.js +895 -0
  44. package/dist/src/cli/login-command.js +401 -0
  45. package/dist/src/cli/operations-commands.js +186 -0
  46. package/dist/src/cli/reconcile-command.js +137 -0
  47. package/dist/src/cli/sign-in.js +134 -0
  48. package/dist/src/cli/telemetry.js +222 -0
  49. package/dist/src/config/environment.js +446 -0
  50. package/dist/src/diff/artifact-path.js +77 -0
  51. package/dist/src/diff/crops.js +102 -0
  52. package/dist/src/diff/geometry.js +122 -0
  53. package/dist/src/diff/image.js +132 -0
  54. package/dist/src/diff/mask.js +46 -0
  55. package/dist/src/diff/regions.js +263 -0
  56. package/dist/src/diff/settings.js +135 -0
  57. package/dist/src/doctor/checks.js +588 -0
  58. package/dist/src/doctor/report.js +152 -0
  59. package/dist/src/doctor/session.js +161 -0
  60. package/dist/src/errors.js +36 -0
  61. package/dist/src/feedback/read.js +119 -0
  62. package/dist/src/feedback/record.js +199 -0
  63. package/dist/src/operations/addresses.js +175 -0
  64. package/dist/src/operations/derive.js +109 -0
  65. package/dist/src/operations/ledger.js +194 -0
  66. package/dist/src/operations/status.js +197 -0
  67. package/dist/src/operations/telemetry.js +280 -0
  68. package/dist/src/report/document.js +419 -0
  69. package/dist/src/report/escape.js +68 -0
  70. package/dist/src/report/snapshot.js +97 -0
  71. package/dist/src/service/arbitration.js +537 -0
  72. package/dist/src/service/artifacts.js +85 -0
  73. package/dist/src/service/bridge.js +577 -0
  74. package/dist/src/service/broker.js +120 -0
  75. package/dist/src/service/browser-session.js +269 -0
  76. package/dist/src/service/capacity.js +62 -0
  77. package/dist/src/service/capture-seam.js +83 -0
  78. package/dist/src/service/capture-store.js +91 -0
  79. package/dist/src/service/comparison-store.js +101 -0
  80. package/dist/src/service/comparison.js +173 -0
  81. package/dist/src/service/events.js +93 -0
  82. package/dist/src/service/keys.js +68 -0
  83. package/dist/src/service/leases.js +147 -0
  84. package/dist/src/service/nudge.js +66 -0
  85. package/dist/src/service/operations/claim.js +692 -0
  86. package/dist/src/service/operations/give-back.js +131 -0
  87. package/dist/src/service/operations/pages.js +771 -0
  88. package/dist/src/service/operations/sign-in.js +915 -0
  89. package/dist/src/service/operations/status.js +62 -0
  90. package/dist/src/service/ownership.js +93 -0
  91. package/dist/src/service/pages.js +616 -0
  92. package/dist/src/service/pending-seeds.js +20 -0
  93. package/dist/src/service/queue.js +233 -0
  94. package/dist/src/service/reconcile.js +220 -0
  95. package/dist/src/service/refusals.js +262 -0
  96. package/dist/src/service/runtime.js +131 -0
  97. package/dist/src/service/signin-recovery.js +148 -0
  98. package/dist/src/service/storage-seed.js +239 -0
  99. package/dist/src/service/tabs.js +123 -0
  100. package/dist/src/store/budget.js +99 -0
  101. package/dist/src/store/location.js +42 -0
  102. package/dist/src/store/network-path.js +182 -0
  103. package/dist/src/store/network-volume.js +92 -0
  104. package/dist/src/store/open.js +226 -0
  105. package/dist/src/store/schema/step-001-initial.js +523 -0
  106. package/dist/src/store/schema/step-002-tab-budget.js +53 -0
  107. package/dist/src/store/schema/step-003-queue-order.js +110 -0
  108. package/dist/src/store/schema/step-004-tab-never-opened.js +100 -0
  109. package/dist/src/store/schema/step-005-storage-seed-event.js +90 -0
  110. package/dist/src/store/schema/step-006-signin-events.js +104 -0
  111. package/dist/src/store/schema/step-007-signin-without-process.js +92 -0
  112. package/dist/src/store/schema/step-008-signin-owner.js +76 -0
  113. package/dist/src/store/schema/step-009-named-browsers.js +138 -0
  114. package/dist/src/store/schema/step-010-signin-request.js +135 -0
  115. package/dist/src/store/schema/step.js +172 -0
  116. package/dist/src/store/schema/steps.js +58 -0
  117. package/dist/src/store/transaction.js +37 -0
  118. package/dist/src/tool/adapter.js +90 -0
  119. package/dist/src/tool/conformance-driver.js +184 -0
  120. package/dist/src/tool/protocol.js +310 -0
  121. package/dist/src/tool/session.js +351 -0
  122. package/dist/src/tool/tools.js +310 -0
  123. package/package.json +58 -0
@@ -0,0 +1,182 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { StartupRefusal } from "../errors.js";
4
+ import { isNetworkVolumeType, networkFilesystemName, } from "./network-volume.js";
5
+ /**
6
+ * `store.not_on_network_filesystem` (`SCHEMA.md` §7.2, §1.0).
7
+ *
8
+ * The write-ahead log coordinates through a shared-memory index that requires
9
+ * every process using the database to be on the same host. On a network
10
+ * filesystem that requirement is not met, and the failure is not a clean
11
+ * error — it is two hosts each believing they hold the writer's position,
12
+ * which is corruption rather than contention. So this is a refusal to run,
13
+ * not a warning.
14
+ *
15
+ * ── Why one check is not enough ─────────────────────────────────────────
16
+ *
17
+ * §1.0's table is explicit: a path written as a share directly is caught by
18
+ * reading its root, but **a mapped network drive is lexically identical to a
19
+ * local one**. There is nothing in the string to read. A check that only
20
+ * inspects the string passes on every machine with nothing mapped, which is
21
+ * every machine anybody writes the test on — so a second check has to ask the
22
+ * operating system what the volume actually is.
23
+ *
24
+ * ── The three checks, and why the third is not the second again ─────────
25
+ *
26
+ * | | What it asks | What it catches |
27
+ * |---|---|---|
28
+ * | One | Does the path's root name a share? | A share written out directly, in either separator spelling |
29
+ * | Two | Does the path *resolve* to one? | A mapped drive, on the platform where mappings resolve to the share behind them |
30
+ * | Three | What does the volume's own type code say? | A mount on a platform that has no share spelling to read and no mapping to resolve |
31
+ *
32
+ * **Checks one and two cover exactly one platform's spelling of the problem.**
33
+ * On a platform whose separator is the forward slash, a mounted network volume
34
+ * lives at an ordinary absolute path with no share prefix and nothing to
35
+ * resolve to one — it is a directory as far as every string operation is
36
+ * concerned. That is the same argument the mapped drive makes, transposed, and
37
+ * it needs its own check for the same reason. Check three is in
38
+ * `network-volume.ts` with its own limits written down.
39
+ *
40
+ * **A guard developed on one platform is untested on the other by
41
+ * construction**, so the tests drive all three through injected inputs and
42
+ * refuse on every platform rather than on the one they were written on.
43
+ *
44
+ * ── How check two asks ──────────────────────────────────────────────────
45
+ *
46
+ * By resolving the path to its real location. Where a mapped drive resolves to
47
+ * the share it points at, that turns an invisible case into the visible one
48
+ * check one already handles — so it reduces to check one, applied to the
49
+ * resolved path.
50
+ *
51
+ * The alternative is asking the platform's management interface for the
52
+ * volume's drive-type code in a subprocess. It answers correctly and it was
53
+ * measured, and it is not used here: it costs between roughly 0.7 and 1.1
54
+ * seconds per call, against a process startup this design puts at tens of
55
+ * milliseconds and a service that is spawned once per session. Paying a
56
+ * second on every spawn to learn something a filesystem call answers in a
57
+ * fraction of a millisecond would invalidate the startup measurement the
58
+ * storage decision rests on. It stays documented here as the fallback if a
59
+ * mapping is ever found that does not resolve.
60
+ *
61
+ * Filesystem statistics are **not** a route to that answer on the platform
62
+ * with drive letters, and the reason is worth keeping: there the type field
63
+ * reports the same value for a local volume and a mapped network one, so an
64
+ * implementation built on it looks correct, tests green, and refuses nothing.
65
+ * That is why check three stands beside check two rather than standing in for
66
+ * it — each is blind exactly where the other sees.
67
+ */
68
+ /** A path that does not exist yet cannot be resolved; walk up to one that does. */
69
+ function nearestExistingAncestor(target) {
70
+ let candidate = path.resolve(target);
71
+ // The loop terminates: `path.dirname` of a root is the root itself.
72
+ for (;;) {
73
+ if (fs.existsSync(candidate)) {
74
+ return candidate;
75
+ }
76
+ const parent = path.dirname(candidate);
77
+ if (parent === candidate) {
78
+ return candidate;
79
+ }
80
+ candidate = parent;
81
+ }
82
+ }
83
+ /**
84
+ * Resolve a path to its real location, following any mapping.
85
+ *
86
+ * The store file does not exist on a first spawn, and resolving a path that
87
+ * is not there throws — so what gets resolved is the nearest ancestor that
88
+ * does exist. That ancestor is on the same volume as the file will be, which
89
+ * is the only property this check needs from it.
90
+ */
91
+ export function resolveRealPath(target) {
92
+ try {
93
+ return fs.realpathSync.native(nearestExistingAncestor(target));
94
+ }
95
+ catch {
96
+ // A path that cannot be resolved at all is left as it was written. The
97
+ // root check below still runs on it, and the store open that follows
98
+ // will fail for its own reasons with a better message than this one
99
+ // could invent.
100
+ return path.resolve(target);
101
+ }
102
+ }
103
+ /**
104
+ * Is this path's root a network share?
105
+ *
106
+ * Both separator spellings are tested. The platform path parser reports the
107
+ * share prefix as the root for the backslash spelling, and reports the
108
+ * forward-slash spelling **unchanged** rather than normalising it — so
109
+ * matching one spelling really does catch only half the cases.
110
+ */
111
+ export function hasNetworkShareRoot(target) {
112
+ const root = path.win32.parse(target).root;
113
+ if (root === '') {
114
+ return false;
115
+ }
116
+ const normalised = root.replace(/\//g, '\\');
117
+ // A share root is two separators, then a host, then a share. Two
118
+ // separators alone is a root-relative path on the current drive, which is
119
+ // local.
120
+ return /^\\\\[^\\]+\\/.test(normalised);
121
+ }
122
+ /**
123
+ * Read the volume statistics for a path, walking up to an ancestor that
124
+ * exists for the same reason resolution does — the store file is not there on
125
+ * a first spawn, and the ancestor is on the volume the file will be on.
126
+ *
127
+ * A path whose statistics cannot be read reports nothing rather than throwing.
128
+ * That is a deliberate allow: refusing on an unreadable answer would refuse
129
+ * paths for reasons that have nothing to do with a network, and the store open
130
+ * that follows fails with a better message than this could invent.
131
+ */
132
+ export function readVolumeStatistics(target) {
133
+ try {
134
+ return fs.statfsSync(nearestExistingAncestor(target));
135
+ }
136
+ catch {
137
+ return undefined;
138
+ }
139
+ }
140
+ export const realChecks = {
141
+ resolveRealPath,
142
+ hasNetworkShareRoot,
143
+ readVolumeStatistics,
144
+ };
145
+ /**
146
+ * Refuse a network location.
147
+ *
148
+ * Returns nothing on purpose. What check two resolves is the nearest
149
+ * *existing ancestor* of the store path, not the store path itself, so the
150
+ * resolved string is an answer to "which volume is this on" and would be
151
+ * wrong used as a location. The caller keeps the path it asked about.
152
+ *
153
+ * **Surrounding blank space is stripped before any check runs.** A value with
154
+ * a leading space is a value somebody typed with a leading space, and every
155
+ * check here reads the front of the string: the share-root test sees a space
156
+ * where it expects a separator and reports no root, and resolution treats the
157
+ * whole thing as a relative name. One invisible character would walk a share
158
+ * past all three, which is a guard defeated by a typing accident rather than
159
+ * by anything anybody meant.
160
+ */
161
+ export function refuseNetworkLocation(target, checks = realChecks) {
162
+ const candidate = target.trim();
163
+ // Check one: the path as written names a share. Tested before resolution,
164
+ // for the reason above.
165
+ if (checks.hasNetworkShareRoot(candidate)) {
166
+ throw new StartupRefusal('store.not_on_network_filesystem', `The store location is on a network share. The write-ahead log requires every process using the database to be on one host, so a network location is refused rather than risked. Set BROKER_DB to a local path.`);
167
+ }
168
+ // Check two: the path as written looks local, and resolving it says
169
+ // otherwise. This is the mapped-drive case, and it is the reason one check
170
+ // is not enough.
171
+ const real = checks.resolveRealPath(candidate);
172
+ if (checks.hasNetworkShareRoot(real)) {
173
+ throw new StartupRefusal('store.not_on_network_filesystem', `The store location resolves to a network share. A mapped network drive is indistinguishable from a local one by its path alone, and the write-ahead log requires every process using the database to be on one host. Set BROKER_DB to a local path.`);
174
+ }
175
+ // Check three: nothing in the string says share on this platform because
176
+ // this platform has no share spelling — so the volume is asked what it is.
177
+ const statistics = checks.readVolumeStatistics(candidate);
178
+ if (statistics !== undefined && isNetworkVolumeType(statistics.type)) {
179
+ const name = networkFilesystemName(statistics.type) ?? 'a network filesystem';
180
+ throw new StartupRefusal('store.not_on_network_filesystem', `The store location is on a network filesystem (${name}). A mount point is indistinguishable from an ordinary directory by its path alone, and the write-ahead log requires every process using the database to be on one host. Set BROKER_DB to a path on a local disk.`);
181
+ }
182
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The network-volume check for platforms whose separator is the forward slash.
3
+ *
4
+ * ── Why this file exists at all ─────────────────────────────────────────
5
+ *
6
+ * The share-root check reads a path's root and refuses a two-separator
7
+ * spelling. That is the whole of the detection on a platform that spells a
8
+ * share that way. **On a platform that does not, there is nothing in the
9
+ * string to read**: a mount lives at an ordinary absolute path, and a mount
10
+ * point is lexically identical to a directory. The mapped-drive argument of
11
+ * `SCHEMA.md` §1.0 transposes exactly — a check that only inspects the string
12
+ * passes on every machine with nothing mounted, which is every machine
13
+ * anybody writes the test on.
14
+ *
15
+ * The failure it is guarding is the same one and it is not a clean error: the
16
+ * write-ahead log coordinates through a shared-memory index that requires
17
+ * every process using the database to be on one host. Two hosts each believing
18
+ * they hold the writer's position is corruption rather than contention.
19
+ *
20
+ * ── How this asks ───────────────────────────────────────────────────────
21
+ *
22
+ * Filesystem statistics report a type code for the volume a path is on, and on
23
+ * a platform whose separator is the forward slash that code distinguishes a
24
+ * network filesystem from a local one. So the volume is asked what it is,
25
+ * rather than the string being asked what it looks like.
26
+ *
27
+ * > **The same call is not a route to this answer on the platform with drive
28
+ * > letters, and the reason is worth keeping**: there the type field reports
29
+ * > the same value for a local volume and a mapped network one, so an
30
+ * > implementation built on it looks correct, tests green, and refuses
31
+ * > nothing. That platform is served by resolving the path instead, which
32
+ * > turns its invisible case into the visible one — and this file is not
33
+ * > consulted there.
34
+ *
35
+ * ── Why a list of codes rather than a property ──────────────────────────
36
+ *
37
+ * There is no "is this remote" flag to read. The type code is the only thing
38
+ * reported that distinguishes the filesystems at all, so the check is a
39
+ * membership test against the codes the network filesystems in ordinary use
40
+ * report. That has a known and stated limit: **a network filesystem whose code
41
+ * is not below is not detected.** The list is the mitigation for the common
42
+ * cases rather than a proof over all of them, and saying so is better than
43
+ * implying a completeness this cannot have.
44
+ */
45
+ /**
46
+ * The type codes network filesystems report.
47
+ *
48
+ * Each is the constant that filesystem's own implementation reports for a
49
+ * mounted volume of its kind. They are magic numbers in the literal sense —
50
+ * fixed values with no derivation — so they are written down with the name of
51
+ * what reports them and nothing else to check them against.
52
+ */
53
+ export const NETWORK_FILESYSTEM_TYPES = new Map([
54
+ // Server message block, versions one through three — the protocol the
55
+ // drive-letter platform's shares also speak, mounted natively here.
56
+ [0x517b, 'SMB'],
57
+ [0xfe534d42, 'SMB2'],
58
+ [0xff534d42, 'CIFS'],
59
+ // Network file system, versions two through four.
60
+ [0x6969, 'NFS'],
61
+ // Andrew file system, and its open reimplementation.
62
+ [0x5346414f, 'AFS'],
63
+ [0x6b414653, 'AFS (OpenAFS)'],
64
+ // Netware core protocol.
65
+ [0x564c, 'NCP'],
66
+ // A filesystem in user space, which is how most user-mounted network
67
+ // filesystems arrive. Not every one of these is remote — the code says
68
+ // "a program is serving this", not "a program on another host is serving
69
+ // this" — and that is stated plainly below rather than hidden.
70
+ [0x65735546, 'FUSE'],
71
+ [0x65735543, 'FUSE (control)'],
72
+ // Cluster filesystems, which are shared between hosts by definition and so
73
+ // break the one-host requirement for the same reason a mount does.
74
+ [0x47504653, 'GPFS'],
75
+ [0x7461636f, 'OCFS2'],
76
+ ]);
77
+ /**
78
+ * Is this path on a filesystem whose type code says it is served over a
79
+ * network?
80
+ *
81
+ * A path whose statistics cannot be read is **not** refused. The store open
82
+ * that follows fails for its own reasons with a better message than this could
83
+ * invent, and refusing on an unreadable answer would refuse every path that
84
+ * does not exist yet — which is every path on a first spawn.
85
+ */
86
+ export function isNetworkVolumeType(type) {
87
+ return NETWORK_FILESYSTEM_TYPES.has(type);
88
+ }
89
+ /** The name of the filesystem a type code belongs to, for the refusal message. */
90
+ export function networkFilesystemName(type) {
91
+ return NETWORK_FILESYSTEM_TYPES.get(type);
92
+ }
@@ -0,0 +1,226 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ // The one place the driver is imported. Keeping it to a single file is what
4
+ // makes the storage choice reversible: the built-in module is a plausible
5
+ // future once it stops being experimental and stops taking its SQLite
6
+ // version from the runtime, and swapping to it is then a change to this file
7
+ // rather than to everything that opens a store.
8
+ //
9
+ // A default import, not a named one: the driver is CommonJS, and under
10
+ // `verbatimModuleSyntax` with node-style resolution the named form
11
+ // type-checks in some configurations and fails at run time.
12
+ import Database from 'better-sqlite3';
13
+ import { agreeOnTabBudget } from "./budget.js";
14
+ import { resolveStoreLocation } from "./location.js";
15
+ import { stepSchema } from "./schema/step.js";
16
+ import { immediate } from "./transaction.js";
17
+ /**
18
+ * Open the store: resolve where it is, refuse a network location, create the
19
+ * directory if it is absent, and set the three pragmas that make many
20
+ * processes on one file safe.
21
+ *
22
+ * There is no connection pool and looking for one is looking for the wrong
23
+ * shape (`MILESTONES.md`): a pool shares connections between concurrent work
24
+ * inside one long-lived process, and here the callers are separate operating
25
+ * system processes, each opening the file, doing its work and exiting.
26
+ *
27
+ * ── There is exactly one way for a spawn to open this store ─────────────
28
+ *
29
+ * `prepareStore` below. It opens, steps the schema, and settles the tab-budget
30
+ * agreement, in that order, and **it is the only export that hands a spawn a
31
+ * handle**. The raw open is deliberately module-private now.
32
+ *
33
+ * **The privacy is the mechanism, and a comment would not be one.** An
34
+ * entry point that a sibling export can bypass is a suggestion; the thing that
35
+ * makes it a path is that there is nothing else to call. Were the raw open
36
+ * exported beside it, each binary could assemble its own open-and-step pair,
37
+ * every one of them would be a place for `agreeOnTabBudget` to go missing, and
38
+ * the omission would be invisible: a store opens, a schema steps, and the one
39
+ * value several processes must agree on (§1.10, §7.2) is simply never compared.
40
+ * The budget suite would keep passing throughout, because it calls
41
+ * `prepareStore` — so the tests would agree with a product that did something
42
+ * else.
43
+ *
44
+ * The one exception is a diagnostic that must be able to observe the states
45
+ * this refuses to return from, and it is exported under a name that says so.
46
+ *
47
+ * Stepping remains a separate asynchronous call inside this module because it
48
+ * goes through the transaction helper; no caller outside can hold the halves
49
+ * apart.
50
+ */
51
+ /** How long a blocked writer waits before giving up, in milliseconds. */
52
+ export const BUSY_TIMEOUT_MS = 5000;
53
+ /**
54
+ * How many times the conversion to write-ahead-log mode is retried, and how
55
+ * long each attempt waits before the next.
56
+ *
57
+ * Deliberately small. The conversion the retry is waiting on is one pragma on
58
+ * a file with no rows in it yet, so the only thing being waited for is another
59
+ * process finishing something that takes single-digit milliseconds. A budget
60
+ * this size turns the collision into a pause nobody notices; a larger one
61
+ * would turn a genuinely stuck file into a long hang.
62
+ */
63
+ const WAL_CONVERSION_ATTEMPTS = 10;
64
+ const WAL_CONVERSION_PAUSE_MS = 20;
65
+ /**
66
+ * Put the store into write-ahead-log mode, retrying while another process is
67
+ * doing the same thing.
68
+ *
69
+ * ══════════════════════════════════════════════════════════════════════════
70
+ * WHY A RETRY AND NOT A LONGER BUSY TIMEOUT — MEASURED, NOT ASSUMED
71
+ * ══════════════════════════════════════════════════════════════════════════
72
+ *
73
+ * Switching a database into write-ahead-log mode **takes an exclusive lock on
74
+ * the file**. On a store already in that mode the pragma is a cheap no-op, so
75
+ * this never shows once an installation is warm. On a **fresh file the first
76
+ * spawn converts it**, and because the service is spawned per session and
77
+ * exits with it, a second spawn arriving during that conversion is the
78
+ * ordinary case on a machine that has never run this.
79
+ *
80
+ * The obvious repair — set `busy_timeout` first and let the second process
81
+ * wait the conversion out — **is not sufficient, and that was measured rather
82
+ * than reasoned about.** The timeout *is* honoured: with the file held by
83
+ * another connection, the conversion waits and then throws `SQLITE_BUSY`
84
+ * anyway, and it waits longer the larger the timeout is (measured at 0, 50,
85
+ * 200 and 1000ms: it threw after 21, 321, 664 and 1787ms respectively, and at
86
+ * five seconds after 7.4). So the timeout buys time and does not buy success —
87
+ * raising it only makes the eventual failure slower.
88
+ *
89
+ * Retrying works because the thing being contended for is transient by
90
+ * construction: the other process is converting the same file to the same
91
+ * mode, and once it has, this call finds the mode already set and returns it
92
+ * without needing any lock at all.
93
+ *
94
+ * **How load-bearing this is, measured:** with the budget cut to a single
95
+ * attempt, two barrier-aligned spawns against an empty directory fail in
96
+ * **9 runs of 10**. It is not a defensive flourish; without it a fresh install
97
+ * where two agents reach for a browser at once usually fails outright.
98
+ *
99
+ * ── One thing this comment will not overclaim ───────────────────────────
100
+ *
101
+ * The `busy_timeout` ordering above is correct and is kept, but **no test
102
+ * fails if it is moved back** — the retry covers that case on its own. It is
103
+ * ordered this way because a timeout configured after the first thing that can
104
+ * block is a timeout that was not configured when it was needed. That reason
105
+ * stands on its own; no assertion rests on it.
106
+ *
107
+ * **A busy error is the only one retried.** Anything else — a directory that
108
+ * cannot be written, a file that is not a database — is returned to the caller
109
+ * immediately, because retrying a permanent failure ten times only delays the
110
+ * message that says what is actually wrong.
111
+ */
112
+ function convertToWriteAheadLog(db, location) {
113
+ let lastError;
114
+ for (let attempt = 0; attempt < WAL_CONVERSION_ATTEMPTS; attempt += 1) {
115
+ try {
116
+ return db.pragma('journal_mode = WAL', { simple: true });
117
+ }
118
+ catch (error) {
119
+ if (error.code !== 'SQLITE_BUSY') {
120
+ throw error;
121
+ }
122
+ lastError = error;
123
+ // A synchronous pause, because everything on this path is synchronous
124
+ // and making the open asynchronous to accommodate a rare retry would
125
+ // change the signature of every caller that wants a handle.
126
+ const until = Date.now() + WAL_CONVERSION_PAUSE_MS;
127
+ while (Date.now() < until) {
128
+ /* waiting for the other process to finish converting */
129
+ }
130
+ }
131
+ }
132
+ throw new Error(`The store at ${location} could not be put into write-ahead-log mode after ${String(WAL_CONVERSION_ATTEMPTS)} attempts: another process held the file locked throughout. That mode is what lets several processes share this file. The underlying error was: ${String(lastError.message ?? lastError)}`);
133
+ }
134
+ function openStore(environment, options = {}) {
135
+ const location = resolveStoreLocation(environment, options.checks);
136
+ // Created on first spawn, not at install time. An install step that
137
+ // prepares state is a second lifecycle, and installation is the whole of
138
+ // deployment here.
139
+ fs.mkdirSync(path.dirname(location), { recursive: true });
140
+ const db = new Database(location);
141
+ // ── The busy timeout is set BEFORE the journal mode, and that ordering is
142
+ // necessary but on its own not sufficient ─────────────────────────────
143
+ //
144
+ // Ordinary lock contention: a blocked writer waits rather than failing at
145
+ // once. What this does **not** do is worth knowing before somebody reads
146
+ // the line and concludes retries are handled — the busy-snapshot error a
147
+ // deferred transaction raises is not retryable by this setting at all
148
+ // (§1.0a). The transaction mode is what addresses that, not this number.
149
+ //
150
+ // It precedes the conversion below because the conversion is the first
151
+ // thing on this path that can block, and a timeout set after it is a
152
+ // timeout that was not configured at the moment it was needed.
153
+ db.pragma(`busy_timeout = ${String(BUSY_TIMEOUT_MS)}`);
154
+ // The mode that lets several processes read while one writes, which is the
155
+ // whole basis of the concurrency model (§1.0a). Asserted rather than
156
+ // assumed: the pragma returns the mode it actually set.
157
+ const journalMode = convertToWriteAheadLog(db, location);
158
+ if (journalMode !== 'wal') {
159
+ throw new Error(`The store could not be opened in write-ahead-log mode; the journal mode is ${String(journalMode)}. That mode is what lets several processes share this file.`);
160
+ }
161
+ // Set explicitly, and the reason is not that the engine defaults it off.
162
+ // The driver in use is compiled with foreign keys defaulted on, so this
163
+ // pragma is a restatement of what it already does rather than a change to
164
+ // it. It is set because **a correctness guarantee must not rest on a
165
+ // third-party dependency's compile-time flag**: that
166
+ // flag is not part of the driver's public interface, and a rebuild from
167
+ // source, a differently packaged build, or the driver swap this file's own
168
+ // import comment contemplates could all change it with nothing to notice.
169
+ // Row #7's composite foreign key on tabs — the one that stops a tab naming
170
+ // a browser its own lease did not — is the guarantee being protected.
171
+ db.pragma('foreign_keys = ON');
172
+ return {
173
+ location,
174
+ immediate: (fn) => immediate(db, fn),
175
+ pragma: (statement) => db.pragma(statement, { simple: true }),
176
+ close: () => {
177
+ db.close();
178
+ },
179
+ db,
180
+ };
181
+ }
182
+ export async function prepareStore(environment, options = {}) {
183
+ const store = openStore(environment, options);
184
+ try {
185
+ const stepped = await stepSchema(store.db);
186
+ // `budget.agrees_with_store` (§7.2), and it runs **after** stepping
187
+ // because the row it compares against is part of the schema. A process
188
+ // whose environment disagrees with the store refuses here rather than
189
+ // arbitrating against a bound the other processes are not using.
190
+ const budget = agreeOnTabBudget(store.db, environment.tabBudget);
191
+ return { ...store, stepped, budget };
192
+ }
193
+ catch (error) {
194
+ store.close();
195
+ throw error;
196
+ }
197
+ }
198
+ /**
199
+ * Open an **existing** store without stepping it and without settling the
200
+ * budget agreement — for `broker doctor`, and for nothing else.
201
+ *
202
+ * ── Why this exception exists, and why it is a separate name ────────────
203
+ *
204
+ * `doctor`'s whole job is to report the state of an installation, including
205
+ * the two states `prepareStore` refuses to return from: a store behind this
206
+ * build's schema version, and a store whose recorded tab budget disagrees with
207
+ * this process's environment. A diagnostic that used the spawn path would
208
+ * throw on exactly the installations it exists to describe, and the operator
209
+ * would get a refusal instead of the report naming both numbers.
210
+ *
211
+ * **It is a distinct exported name rather than a second call to the raw open**
212
+ * because that is the difference between an exception and a bypass. The
213
+ * defect this module's header describes came from four callers quietly doing
214
+ * their own open; a caller reaching for something spelled
215
+ * `openStoreForDiagnosis` is stating that it is the diagnostic, and anything
216
+ * else calling it is visible in one grep.
217
+ *
218
+ * **Never creates a store.** The caller checks the file exists first, and that
219
+ * ordering is load-bearing: the raw open creates the directory and the file,
220
+ * so a doctor that opened unconditionally would create an empty store at
221
+ * version zero and then truthfully report it at the wrong version — a fault it
222
+ * had itself caused on an installation that was fine a moment earlier.
223
+ */
224
+ export function openStoreForDiagnosis(environment, options = {}) {
225
+ return openStore(environment, options);
226
+ }