switchroom 0.21.3 → 0.21.4

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.
@@ -0,0 +1,713 @@
1
+ /**
2
+ * Outcome coverage for the orphaned-DB-fd sweep
3
+ * (`gateway/orphaned-db-sweep.ts` + `history.reopenHistory`).
4
+ *
5
+ * The core test REPRODUCES THE INCIDENT rather than mocking it: it opens a real
6
+ * history.db, checkpoints (standing in for the foreign process's exit
7
+ * checkpoint), unlinks the `-wal`/`-shm` out from under the live connection,
8
+ * and then proves both halves of the defect — the write still "succeeds", and
9
+ * the row is NOT in the on-disk database. No timers, no sleeps, no timing
10
+ * dependence.
11
+ *
12
+ * TWO MEASURED FACTS THIS TEST IS BUILT AROUND (both verified on bun 1.3.13,
13
+ * both of which invalidate the obvious way to write it):
14
+ *
15
+ * 1. During the orphan window a SECOND live connection to the same file cannot
16
+ * be READ from — SQLite's unix VFS keeps per-inode WAL-index state shared
17
+ * across connections in the process. So the "is it on disk?" probe CANNOT
18
+ * be a second `new Database` on the live path. It is instead a
19
+ * `snapshotOnDisk()` that copies `history.db` (+ `-wal` when one exists) to
20
+ * a scratch dir and opens the COPY — a different inode, and a faithful
21
+ * reading of what is durably on disk. A separate PROCESS opening the
22
+ * original reads the same thing, which is how we know the on-disk DB is not
23
+ * corrupt.
24
+ *
25
+ * 2. bun:sqlite's plain `db.close()` is a SOFT close that does not release the
26
+ * fds while prepared statements are un-finalized. `hardCloseDb` therefore
27
+ * FINALIZES every cached statement and then calls `close(true)`; the
28
+ * regression assertion for that is `detectOrphanedDbFds(stateDir)` being
29
+ * EMPTY after recovery.
30
+ *
31
+ * WHY THERE IS A DETERMINISM TEST UNDER AN ADVERSARIAL HEAP
32
+ * --------------------------------------------------------
33
+ * The first cut of this recovery leaned on `Bun.gc(true)` to make the
34
+ * un-finalized per-call statements collectable before `close(true)`. Measured
35
+ * on bun 1.3.13, that is NOT deterministic: with an essentially empty heap a
36
+ * single gc+close failed 13 times in 20, and with an ordinary arithmetic loop
37
+ * running between the writes and the close it failed 30 times in 30. The fix is
38
+ * the module-level statement cache in `history.ts`, whose entries are explicitly
39
+ * `.finalize()`d by `hardCloseDb`; with it, the same 30-iteration adversarial
40
+ * shape fails 0 times in 30 — and still 0 in 30 with `Bun.gc(true)` deleted
41
+ * entirely, which is the proof that the cache and not the gc is what makes the
42
+ * close deterministic. `'reopens deterministically under the heap shape that
43
+ * broke the gc-only version'` below is that measurement, pinned.
44
+ *
45
+ * NOTE ON THE `close(true)` ARGUMENT. A mutation of `close(true)` →
46
+ * `close()` inside `hardCloseDb` SURVIVES the incident-reproduction test: once
47
+ * every statement is finalized the soft close releases the fds too, so the
48
+ * integration test genuinely cannot see the difference. The `true` (throw on a
49
+ * still-busy handle) is an HONESTY property — never report a recovery that did
50
+ * not happen — so it is pinned one level down, on `hardCloseDb` itself, in the
51
+ * `describe('hardCloseDb')` block with a handle double.
52
+ *
53
+ * WHAT CANNOT BE TESTED DETERMINISTICALLY HERE, and what covers it instead:
54
+ *
55
+ * - The non-Linux early return in `detectOrphanedDbFds`. `/proc/self/fd` only
56
+ * exists on Linux; the guard is a platform branch a Linux CI runner cannot
57
+ * enter. Every `/proc`-dependent block below is `describe.skipIf`-guarded so
58
+ * a macOS developer gets a skip, not a spurious pass.
59
+ * - The 5-minute production cadence. The interval itself IS asserted (see
60
+ * `describe('startOrphanedDbSweep')`, which samples log output before and
61
+ * after `stop()` at a 5ms cadence); only the specific 5-minute default is
62
+ * left to code shape, since waiting for it would be a timing dependence.
63
+ * - The `gateway.ts` wiring line. gateway.ts boots a live gateway on import
64
+ * and is not unit-importable. Covered by `tsc --noEmit` for the call shape
65
+ * and `check-gateway-line-ratchet` for the size budget.
66
+ * - The no-corrupting-checkpoint property (that recovery never issues an
67
+ * explicit WAL checkpoint through the orphaned handle). Enforced by CODE
68
+ * SHAPE — there is no `wal_checkpoint` anywhere in `reopenHistory` — not by
69
+ * an assertion. A test cannot distinguish a safe checkpoint from a
70
+ * corrupting one without provoking real corruption.
71
+ */
72
+
73
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
74
+ import {
75
+ mkdtempSync,
76
+ rmSync,
77
+ existsSync,
78
+ unlinkSync,
79
+ copyFileSync,
80
+ openSync,
81
+ closeSync,
82
+ writeFileSync,
83
+ symlinkSync,
84
+ } from 'fs'
85
+ import { tmpdir } from 'os'
86
+ import { basename, join } from 'path'
87
+ // bun-only (this file is vitest-excluded and runs under `bun test`): the
88
+ // on-disk snapshot probe, and the raw registry.db handle for the
89
+ // detection-only lane.
90
+ import { Database } from 'bun:sqlite'
91
+ import {
92
+ initHistory,
93
+ reopenHistory,
94
+ hardCloseDb,
95
+ getHistoryReopenFailure,
96
+ recordInbound,
97
+ recordOutbound,
98
+ checkpointWal,
99
+ query,
100
+ lookupMessageRoleAndText,
101
+ verifyHistoryWritable,
102
+ _resetForTests,
103
+ } from '../history.js'
104
+ import {
105
+ detectOrphanedDbFds,
106
+ runOrphanedDbSweepTick,
107
+ startOrphanedDbSweep,
108
+ } from '../gateway/orphaned-db-sweep.js'
109
+ import { GATEWAY_SIGNATURES } from '../../src/fleet-health/detect.js'
110
+
111
+ /**
112
+ * `/proc/self/fd` is Linux-only. Without this guard every detection test would
113
+ * pass VACUOUSLY on macOS — `detectOrphanedDbFds` returns `[]` there by design,
114
+ * so `expect(...).toEqual([])` is satisfied by the platform, not the code.
115
+ */
116
+ const onLinux = process.platform === 'linux'
117
+
118
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
119
+
120
+ let stateDir: string
121
+ const scratchDirs: string[] = []
122
+ const openFds: number[] = []
123
+
124
+ beforeEach(() => {
125
+ stateDir = mkdtempSync(join(tmpdir(), 'orphaned-db-sweep-test-'))
126
+ })
127
+
128
+ afterEach(() => {
129
+ try { _resetForTests() } catch { /* an orphaned handle may refuse to close */ }
130
+ for (const fd of openFds.splice(0)) {
131
+ try { closeSync(fd) } catch { /* already gone */ }
132
+ }
133
+ for (const d of scratchDirs.splice(0)) rmSync(d, { recursive: true, force: true })
134
+ if (existsSync(stateDir)) rmSync(stateDir, { recursive: true, force: true })
135
+ })
136
+
137
+ /** A second temp dir, torn down with the test. */
138
+ function scratch(prefix: string): string {
139
+ const dir = mkdtempSync(join(tmpdir(), prefix))
140
+ scratchDirs.push(dir)
141
+ return dir
142
+ }
143
+
144
+ /**
145
+ * What a RESTART would see: copy the durable files to a scratch dir (a fresh
146
+ * inode, sidestepping the in-process WAL-index poisoning documented above) and
147
+ * read the copy. Deliberately does NOT go through the module singleton, whose
148
+ * writes may be landing in a deleted inode.
149
+ */
150
+ function snapshotOnDisk(): number[] {
151
+ const dir = scratch('orphaned-db-sweep-snap-')
152
+ const src = join(stateDir, 'history.db')
153
+ const dst = join(dir, 'history.db')
154
+ copyFileSync(src, dst)
155
+ if (existsSync(src + '-wal')) copyFileSync(src + '-wal', dst + '-wal')
156
+ const probe = new Database(dst)
157
+ try {
158
+ const rows = probe.prepare('SELECT message_id FROM messages ORDER BY message_id').all() as {
159
+ message_id: number
160
+ }[]
161
+ return rows.map((r) => r.message_id)
162
+ } finally {
163
+ probe.close()
164
+ }
165
+ }
166
+
167
+ /**
168
+ * `ts` must be recent — `initHistory`'s retention prune runs on every (re)open
169
+ * and would delete rows backdated past the retention window, which would make
170
+ * the post-reopen assertions lie.
171
+ */
172
+ function inbound(messageId: number, text: string): void {
173
+ recordInbound({
174
+ chat_id: '900001',
175
+ thread_id: null,
176
+ message_id: messageId,
177
+ user: 'tester',
178
+ user_id: '900001',
179
+ ts: Math.floor(Date.now() / 1000),
180
+ text,
181
+ })
182
+ }
183
+
184
+ /** Unlink the WAL sidecars out from under the live connection, as the incident did. */
185
+ function unlinkSidecars(dbFile: string): void {
186
+ for (const suffix of ['-wal', '-shm']) {
187
+ const f = dbFile + suffix
188
+ expect(existsSync(f)).toBe(true)
189
+ unlinkSync(f)
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Create a file, hold an fd on it, and unlink it — a deleted-inode fd that is
195
+ * NOT a database. The sweep must ignore it.
196
+ */
197
+ function orphanNonDbFile(path: string): void {
198
+ writeFileSync(path, 'x')
199
+ openFds.push(openSync(path, 'r'))
200
+ unlinkSync(path)
201
+ }
202
+
203
+ /**
204
+ * Seed a `history.db` whose schema is entirely valid — every DDL statement,
205
+ * migration and index in `initHistory` succeeds against it — but whose
206
+ * `messages` table REJECTS the writer self-check's sentinel row.
207
+ *
208
+ * This is the only shape that separates "the DB opened and the schema is
209
+ * there" from "a row can actually be persisted", which is exactly the gap the
210
+ * post-reopen self-check exists to close: `initHistory` only WARNS when its own
211
+ * self-check fails and returns normally, so without the gate `reopenHistory`
212
+ * returns success on a DB that cannot take a write and the sweep logs "writes
213
+ * are durable again" on top of "WRITER SELF-CHECK FAILED". Uses a CHECK
214
+ * constraint rather than filesystem permissions deliberately: the test suite
215
+ * runs as root in some containers, where a chmod-based read-only fixture is
216
+ * silently bypassed and the test passes for the wrong reason.
217
+ */
218
+ function seedSelfCheckPoisonedDb(): void {
219
+ const raw = new Database(join(stateDir, 'history.db'), { create: true })
220
+ try {
221
+ raw.exec(`
222
+ CREATE TABLE messages (
223
+ chat_id TEXT NOT NULL,
224
+ thread_id INTEGER,
225
+ message_id INTEGER NOT NULL,
226
+ role TEXT NOT NULL,
227
+ user TEXT,
228
+ user_id TEXT,
229
+ ts INTEGER NOT NULL,
230
+ text TEXT NOT NULL,
231
+ attachment_kind TEXT,
232
+ group_id INTEGER,
233
+ reply_to_message_id INTEGER,
234
+ reply_to_text TEXT,
235
+ kind TEXT,
236
+ PRIMARY KEY (chat_id, thread_id, message_id),
237
+ CHECK (text <> 'selfcheck')
238
+ )
239
+ `)
240
+ } finally {
241
+ raw.close(false)
242
+ }
243
+ }
244
+
245
+ /** The sorted bare file names behind a set of orphans, for exact-set assertions. */
246
+ function orphanNames(orphans: { target: string }[]): string[] {
247
+ return orphans
248
+ .map((o) => basename(o.target.replace(/ \(deleted\)$/, '')))
249
+ .sort()
250
+ }
251
+
252
+ describe.skipIf(!onLinux)('orphaned-db-sweep — history.db recovery', () => {
253
+ it('reproduces the deleted-inode data loss and restores durable writes', async () => {
254
+ const dbFile = join(stateDir, 'history.db')
255
+ initHistory(stateDir, 30)
256
+
257
+ // 1. A row that reaches the main DB via a checkpoint. The checkpoint stands
258
+ // in for the foreign process's checkpoint-on-last-close.
259
+ inbound(1001, 'row A — before the unlink')
260
+ expect(checkpointWal()).toBe(true)
261
+
262
+ // 2. That foreign process's exit ALSO unlinked the sidecars. Our live
263
+ // connection keeps the deleted inodes mapped.
264
+ unlinkSidecars(dbFile)
265
+
266
+ // 3. THE DEFECT, half one: the write path still reports success.
267
+ expect(() => inbound(1002, 'row B — into the deleted inode')).not.toThrow()
268
+
269
+ // 4. THE DEFECT, half two: row B never reached disk. What a restart would
270
+ // see is A and NOT B. If SQLite's behaviour ever changes, the test's
271
+ // premise fails loudly right here instead of passing vacuously.
272
+ expect(snapshotOnDisk()).toEqual([1001])
273
+
274
+ // 5. Detection sees BOTH orphaned handles. The real incident had two
275
+ // (`-wal` and `-shm`); asserting the exact set means a detector that
276
+ // returns only the first one fails here.
277
+ const orphans = await detectOrphanedDbFds(stateDir)
278
+ expect(orphanNames(orphans)).toEqual(['history.db-shm', 'history.db-wal'])
279
+
280
+ // 6. The tick alarms loudly and reopens.
281
+ const lines: string[] = []
282
+ await runOrphanedDbSweepTick({
283
+ stateDir,
284
+ reopenHistory: () => reopenHistory(stateDir, 30),
285
+ log: (l) => lines.push(l),
286
+ })
287
+ const log = lines.join('')
288
+ expect(log).toContain('LOST')
289
+ expect(log).toContain(`${dbFile}-wal`)
290
+ expect(log).toContain('reopened history.db')
291
+
292
+ // …and the fleet-health L0 detector actually matches that alarm. Without
293
+ // this the signature and the log line drift apart silently and the
294
+ // alarm has no consumer.
295
+ expect(GATEWAY_SIGNATURES['orphaned-db-handle'].test(log)).toBe(true)
296
+
297
+ // 7. THE FIX, half one: the deleted-inode handles are actually GONE. A
298
+ // close that leaves un-finalized statements behind leaves them open
299
+ // (measured), so this is the assertion that pins the hard close.
300
+ expect(await detectOrphanedDbFds(stateDir)).toEqual([])
301
+
302
+ // 8. THE FIX, half two: writes are durable again — on disk, and visible to
303
+ // a restart. And nothing is stuck.
304
+ inbound(1003, 'row C — after the reopen')
305
+ expect(snapshotOnDisk()).toContain(1003)
306
+ expect(verifyHistoryWritable().ok).toBe(true)
307
+ expect(getHistoryReopenFailure()).toBeNull()
308
+
309
+ // …and through the module's own read path after a full re-init, which is
310
+ // what `get_recent_messages` does on the next boot.
311
+ _resetForTests()
312
+ initHistory(stateDir, 30)
313
+ const texts = query({ chat_id: '900001', limit: 50 }).map((m) => m.text)
314
+ expect(texts).toContain('row C — after the reopen')
315
+ })
316
+
317
+ it('does not alarm on a healthy open DB with a live WAL', async () => {
318
+ initHistory(stateDir, 30)
319
+ inbound(2001, 'healthy row')
320
+ // A live WAL exists and is held open — the ONLY difference from the orphan
321
+ // case is that it has not been unlinked.
322
+ expect(existsSync(join(stateDir, 'history.db-wal'))).toBe(true)
323
+ expect(await detectOrphanedDbFds(stateDir)).toEqual([])
324
+
325
+ const lines: string[] = []
326
+ const orphans = await runOrphanedDbSweepTick({ stateDir, log: (l) => lines.push(l) })
327
+ expect(orphans).toEqual([])
328
+ expect(lines).toEqual([])
329
+ })
330
+
331
+ it('reopens deterministically under the heap shape that broke the gc-only version', () => {
332
+ // The measured adversarial shape: real writes (which leave statements
333
+ // behind) plus ordinary arithmetic between the writes and the close. The
334
+ // gc-only implementation failed this 30/30; the statement cache passes it
335
+ // even with the gc removed. Twelve iterations keeps the test under a second
336
+ // while still being far past the 13/20 failure rate of the empty-heap case
337
+ // — a regression to gc-dependence cannot survive twelve rolls.
338
+ for (let i = 0; i < 12; i++) {
339
+ initHistory(stateDir, 30)
340
+ inbound(3000 + i, `row ${i}`)
341
+ recordOutbound({
342
+ chat_id: '900001',
343
+ thread_id: null,
344
+ message_ids: [4000 + i],
345
+ texts: [`reply ${i}`],
346
+ })
347
+ checkpointWal()
348
+ let churn = 0
349
+ for (let j = 0; j < 500_000; j++) churn += j % 7
350
+ expect(churn).toBeGreaterThan(0)
351
+ expect(() => reopenHistory(stateDir, 30)).not.toThrow()
352
+ expect(verifyHistoryWritable().ok).toBe(true)
353
+ }
354
+ })
355
+ })
356
+
357
+ describe.skipIf(!onLinux)('orphaned-db-sweep — detection filters', () => {
358
+ it('ignores deleted non-DB files in the state dir', async () => {
359
+ // `basename.includes('.db')` matched every one of these.
360
+ orphanNonDbFile(join(stateDir, 'scratch.tmp'))
361
+ orphanNonDbFile(join(stateDir, 'notes.dbg'))
362
+ orphanNonDbFile(join(stateDir, 'dump.dbf'))
363
+ orphanNonDbFile(join(stateDir, 'history.db.bak'))
364
+ orphanNonDbFile(join(stateDir, 'session.dbus'))
365
+
366
+ expect(await detectOrphanedDbFds(stateDir)).toEqual([])
367
+
368
+ const lines: string[] = []
369
+ await runOrphanedDbSweepTick({ stateDir, log: (l) => lines.push(l) })
370
+ expect(lines).toEqual([])
371
+ })
372
+
373
+ it('ignores a deleted DB outside the state dir', async () => {
374
+ // A SECOND temp dir, so the filtering is done by the prefix test and not
375
+ // by "there happen to be no orphaned fds at all".
376
+ const elsewhere = scratch('orphaned-db-sweep-elsewhere-')
377
+ const foreign = join(elsewhere, 'foreign.db')
378
+ const raw = new Database(foreign, { create: true })
379
+ try {
380
+ raw.exec('PRAGMA journal_mode = WAL')
381
+ raw.exec('CREATE TABLE t (id INTEGER PRIMARY KEY)')
382
+ raw.prepare('INSERT INTO t (id) VALUES (?)').run(1)
383
+ unlinkSidecars(foreign)
384
+
385
+ // The orphan is real and detectable — from its OWN dir…
386
+ expect(orphanNames(await detectOrphanedDbFds(elsewhere)))
387
+ .toEqual(['foreign.db-shm', 'foreign.db-wal'])
388
+ // …and invisible from ours.
389
+ expect(await detectOrphanedDbFds(stateDir)).toEqual([])
390
+ } finally {
391
+ raw.close(false)
392
+ }
393
+ })
394
+
395
+ it('detects through a symlinked state dir', async () => {
396
+ // `/proc/self/fd` targets are always fully resolved. Comparing them
397
+ // against an unresolved `TELEGRAM_STATE_DIR` disables detection silently
398
+ // and forever — the worst possible failure for a data-loss detector.
399
+ const linkParent = scratch('orphaned-db-sweep-link-')
400
+ const link = join(linkParent, 'state-link')
401
+ symlinkSync(stateDir, link)
402
+
403
+ initHistory(stateDir, 30)
404
+ inbound(5001, 'row via symlink')
405
+ checkpointWal()
406
+ unlinkSidecars(join(stateDir, 'history.db'))
407
+
408
+ expect(orphanNames(await detectOrphanedDbFds(link)))
409
+ .toEqual(['history.db-shm', 'history.db-wal'])
410
+ })
411
+
412
+ it('warns rather than reporting healthy when the state dir cannot be resolved', async () => {
413
+ const missing = join(stateDir, 'does-not-exist')
414
+ const lines: string[] = []
415
+ const orphans = await runOrphanedDbSweepTick({ stateDir: missing, log: (l) => lines.push(l) })
416
+ expect(orphans).toEqual([])
417
+ const log = lines.join('')
418
+ expect(log).toContain('cannot resolve stateDir')
419
+ expect(log).toContain('DISABLED')
420
+ })
421
+ })
422
+
423
+ describe.skipIf(!onLinux)('orphaned-db-sweep — registry.db and unowned lanes', () => {
424
+ it('alarms and demands a restart without touching the registry handle', async () => {
425
+ const registryFile = join(stateDir, 'registry.db')
426
+ const raw = new Database(registryFile, { create: true })
427
+ try {
428
+ raw.exec('PRAGMA journal_mode = WAL')
429
+ raw.exec('CREATE TABLE turns (id INTEGER PRIMARY KEY, note TEXT)')
430
+ raw.prepare('INSERT INTO turns (id, note) VALUES (?, ?)').run(1, 'before')
431
+ unlinkSidecars(registryFile)
432
+
433
+ const lines: string[] = []
434
+ // No reopenHistory wired: this lane must alarm off the state-dir prefix,
435
+ // not off the history.db filename.
436
+ const orphans = await runOrphanedDbSweepTick({ stateDir, log: (l) => lines.push(l) })
437
+ expect(orphanNames(orphans)).toEqual(['registry.db-shm', 'registry.db-wal'])
438
+ const log = lines.join('')
439
+ expect(log).toContain('registry.db')
440
+ expect(log).toContain('RESTART')
441
+ expect(GATEWAY_SIGNATURES['orphaned-db-handle'].test(log)).toBe(true)
442
+
443
+ // Detection ONLY: the raw handle is left alone, so its consumers (the
444
+ // by-value `turnsDb` captures in gateway.ts) are never handed a closed DB.
445
+ expect(() =>
446
+ raw.prepare('INSERT INTO turns (id, note) VALUES (?, ?)').run(2, 'after'),
447
+ ).not.toThrow()
448
+ } finally {
449
+ raw.close(false)
450
+ }
451
+ })
452
+
453
+ it('names an unowned state-dir DB instead of raising a lane-less data-loss alarm', async () => {
454
+ const grants = join(stateDir, 'grants.db')
455
+ const raw = new Database(grants, { create: true })
456
+ try {
457
+ raw.exec('PRAGMA journal_mode = WAL')
458
+ raw.exec('CREATE TABLE g (id INTEGER PRIMARY KEY)')
459
+ raw.prepare('INSERT INTO g (id) VALUES (?)').run(1)
460
+ unlinkSidecars(grants)
461
+
462
+ const lines: string[] = []
463
+ await runOrphanedDbSweepTick({
464
+ stateDir,
465
+ reopenHistory: () => { throw new Error('history lane must not run') },
466
+ log: (l) => lines.push(l),
467
+ })
468
+ const log = lines.join('')
469
+ expect(log).toContain('grants.db-wal')
470
+ expect(log).toContain('no recovery lane owns')
471
+ expect(log).toContain('RESTART')
472
+ // The history lane is wired but must NOT have fired for a foreign DB.
473
+ expect(log).not.toContain('history.db')
474
+ } finally {
475
+ raw.close(false)
476
+ }
477
+ })
478
+ })
479
+
480
+ describe.skipIf(!onLinux)('orphaned-db-sweep — reopen failure lanes', () => {
481
+ it('reports a failed reopen honestly instead of claiming recovery', async () => {
482
+ initHistory(stateDir, 30)
483
+ inbound(6001, 'row before the unlink')
484
+ checkpointWal()
485
+ unlinkSidecars(join(stateDir, 'history.db'))
486
+
487
+ const lines: string[] = []
488
+ await runOrphanedDbSweepTick({
489
+ stateDir,
490
+ reopenHistory: () => { throw new Error('boom') },
491
+ log: (l) => lines.push(l),
492
+ })
493
+ const log = lines.join('')
494
+ expect(log).toContain('FAILED to reopen history.db')
495
+ expect(log).toContain('boom')
496
+ expect(log).toContain('RESTART')
497
+ // The success line must be gated on the reopen actually succeeding.
498
+ expect(log).not.toContain('writes are durable again')
499
+ })
500
+
501
+ it('says a history reopen is not wired when history is disabled', async () => {
502
+ initHistory(stateDir, 30)
503
+ inbound(6101, 'row before the unlink')
504
+ checkpointWal()
505
+ unlinkSidecars(join(stateDir, 'history.db'))
506
+
507
+ const lines: string[] = []
508
+ await runOrphanedDbSweepTick({ stateDir, reopenHistory: undefined, log: (l) => lines.push(l) })
509
+ const log = lines.join('')
510
+ expect(log).toContain('no reopen')
511
+ expect(log).toContain('RESTART')
512
+ expect(log).not.toContain('writes are durable again')
513
+ })
514
+
515
+ it('keeps alarming on a sticky reopen failure after the fds are gone', async () => {
516
+ // The nastiest shape: the close SUCCEEDED (fds released) and the re-init
517
+ // FAILED. There is no fd evidence left, so a purely fd-driven sweep would
518
+ // report healthy forever while every history read and write is dead.
519
+ let stuck: string | null = 'disk full'
520
+ const lines: string[] = []
521
+ const opts = {
522
+ stateDir,
523
+ reopenHistory: () => { throw new Error('still broken') },
524
+ historyReopenFailure: () => stuck,
525
+ log: (l: string) => lines.push(l),
526
+ }
527
+ expect(await detectOrphanedDbFds(stateDir)).toEqual([])
528
+
529
+ await runOrphanedDbSweepTick(opts)
530
+ await runOrphanedDbSweepTick(opts)
531
+ const log = lines.join('')
532
+ expect(log.match(/history\.db is CLOSED/g)?.length).toBe(2)
533
+ expect(log).toContain('disk full')
534
+
535
+ // …and goes quiet the moment the flag clears, so it cannot become noise
536
+ // the operator learns to ignore.
537
+ stuck = null
538
+ lines.length = 0
539
+ await runOrphanedDbSweepTick(opts)
540
+ expect(lines).toEqual([])
541
+ })
542
+
543
+ it('recovers off the sticky flag with no orphaned fd to trigger it', async () => {
544
+ initHistory(stateDir, 30)
545
+ const lines: string[] = []
546
+ await runOrphanedDbSweepTick({
547
+ stateDir,
548
+ reopenHistory: () => reopenHistory(stateDir, 30),
549
+ historyReopenFailure: () => 'previous reopen failed',
550
+ log: (l) => lines.push(l),
551
+ })
552
+ const log = lines.join('')
553
+ expect(log).toContain('history.db is CLOSED')
554
+ expect(log).toContain('recovered history.db')
555
+ expect(log).toContain('writes are durable again')
556
+ expect(getHistoryReopenFailure()).toBeNull()
557
+ expect(verifyHistoryWritable().ok).toBe(true)
558
+ })
559
+
560
+ it('refuses to call a reopen successful when the reopened DB cannot take a write', async () => {
561
+ seedSelfCheckPoisonedDb()
562
+ initHistory(stateDir, 30) // warns about the self-check, returns normally
563
+
564
+ expect(() => reopenHistory(stateDir, 30)).toThrow('post-reopen writer self-check failed')
565
+
566
+ // …and the sweep repeats that honestly rather than announcing durability.
567
+ const lines: string[] = []
568
+ await runOrphanedDbSweepTick({
569
+ stateDir,
570
+ reopenHistory: () => reopenHistory(stateDir, 30),
571
+ historyReopenFailure: getHistoryReopenFailure,
572
+ log: (l) => lines.push(l),
573
+ })
574
+ const log = lines.join('')
575
+ expect(log).toContain('FAILED to reopen history.db')
576
+ expect(log).not.toContain('writes are durable again')
577
+ })
578
+
579
+ it('clears the sticky failure once a later reopen genuinely succeeds', async () => {
580
+ seedSelfCheckPoisonedDb()
581
+ initHistory(stateDir, 30)
582
+ expect(() => reopenHistory(stateDir, 30)).toThrow()
583
+ expect(getHistoryReopenFailure()).toContain('post-reopen writer self-check failed')
584
+
585
+ // Repair the underlying cause (drop the poisoned file; the still-open
586
+ // handle onto it becomes an orphan, which is the sweep's other trigger)
587
+ // and let a tick recover.
588
+ for (const suffix of ['', '-wal', '-shm']) {
589
+ const f = join(stateDir, 'history.db' + suffix)
590
+ if (existsSync(f)) unlinkSync(f)
591
+ }
592
+ const lines: string[] = []
593
+ await runOrphanedDbSweepTick({
594
+ stateDir,
595
+ reopenHistory: () => reopenHistory(stateDir, 30),
596
+ historyReopenFailure: getHistoryReopenFailure,
597
+ log: (l) => lines.push(l),
598
+ })
599
+ expect(lines.join('')).toContain('writes are durable again')
600
+
601
+ // A flag that never clears turns the recovered state into a permanent
602
+ // alarm — the operator learns to ignore it, and the next real outage is
603
+ // invisible. The next tick must be silent.
604
+ expect(getHistoryReopenFailure()).toBeNull()
605
+ lines.length = 0
606
+ await runOrphanedDbSweepTick({
607
+ stateDir,
608
+ reopenHistory: () => reopenHistory(stateDir, 30),
609
+ historyReopenFailure: getHistoryReopenFailure,
610
+ log: (l) => lines.push(l),
611
+ })
612
+ expect(lines).toEqual([])
613
+ })
614
+
615
+ it('degrades reads to empty instead of throwing when history is dead', () => {
616
+ initHistory(stateDir, 30)
617
+ inbound(6201, 'row before the outage')
618
+
619
+ // Make the re-init genuinely impossible: replace the state DIR with a FILE
620
+ // so `initHistory`'s mkdir throws. The close still succeeds, so this is the
621
+ // real end-to-end shape of the sticky failure, not a stub.
622
+ rmSync(stateDir, { recursive: true, force: true })
623
+ writeFileSync(stateDir, 'not a directory')
624
+
625
+ expect(() => reopenHistory(stateDir, 30)).toThrow()
626
+ expect(getHistoryReopenFailure()).not.toBeNull()
627
+
628
+ // The read paths degrade instead of exploding through their callers.
629
+ expect(query({ chat_id: '900001', limit: 10 })).toEqual([])
630
+ expect(lookupMessageRoleAndText('900001', 6201)).toBeNull()
631
+
632
+ unlinkSync(stateDir)
633
+ })
634
+ })
635
+
636
+ describe('hardCloseDb', () => {
637
+ /**
638
+ * A `Database` double with bun's close semantics: `close(true)` throws when
639
+ * statements are still outstanding, `close()` (soft) never does. This is
640
+ * where the `true` argument is pinned — see the note in the file header for
641
+ * why the integration test cannot see it.
642
+ */
643
+ function fakeHandle(busy: boolean) {
644
+ const calls: (boolean | undefined)[] = []
645
+ return {
646
+ calls,
647
+ close(throwOnError?: boolean) {
648
+ calls.push(throwOnError)
649
+ if (throwOnError === true && busy) throw new Error('database is locked')
650
+ },
651
+ prepare() { throw new Error('unused') },
652
+ exec() { throw new Error('unused') },
653
+ }
654
+ }
655
+
656
+ it('closes with throwOnError so a stuck handle cannot be reported as recovered', () => {
657
+ const handle = fakeHandle(false)
658
+ hardCloseDb(handle as never)
659
+ // `close()` / `close(false)` would silently leave a busy handle behind and
660
+ // let the caller log "writes are durable again".
661
+ expect(handle.calls).toEqual([true])
662
+ })
663
+
664
+ it('propagates a close failure rather than swallowing it', () => {
665
+ const handle = fakeHandle(true)
666
+ expect(() => hardCloseDb(handle as never)).toThrow('database is locked')
667
+ })
668
+
669
+ it('finalizes every cached statement before closing', () => {
670
+ // The real determinism proof: after a hard close, re-opening and writing
671
+ // must work, which requires the cache to have been dropped along with the
672
+ // finalized statements. A cache that survived the close would hand the new
673
+ // connection statements bound to the old one.
674
+ initHistory(stateDir, 30)
675
+ inbound(7001, 'cached statement holder')
676
+ expect(verifyHistoryWritable().ok).toBe(true)
677
+ expect(() => reopenHistory(stateDir, 30)).not.toThrow()
678
+ expect(verifyHistoryWritable().ok).toBe(true)
679
+ expect(query({ chat_id: '900001', limit: 10 }).map((m) => m.message_id)).toEqual([7001])
680
+ })
681
+ })
682
+
683
+ describe('startOrphanedDbSweep', () => {
684
+ it('stops ticking after stop() is called', async () => {
685
+ // A sticky failure with no reopen wired makes every tick emit exactly one
686
+ // line, so the log line count IS the tick count — an observable the
687
+ // interval must actually drive.
688
+ const lines: string[] = []
689
+ const stop = startOrphanedDbSweep({
690
+ stateDir,
691
+ historyReopenFailure: () => 'stuck',
692
+ log: (l) => lines.push(l),
693
+ intervalMs: 5,
694
+ })
695
+ // Poll to a generous deadline rather than sampling a fixed window: under a
696
+ // loaded CI runner a 5ms interval can be starved, and a flaky assertion on
697
+ // a real property is worse than a slow one.
698
+ const deadline = Date.now() + 5_000
699
+ while (lines.length < 3 && Date.now() < deadline) await sleep(5)
700
+ // Multiple ticks: proves the interval repeats, not just fires once.
701
+ expect(lines.length).toBeGreaterThan(1)
702
+
703
+ stop()
704
+ // A tick is async, so one may be in flight when stop() lands. Let it settle
705
+ // before snapshotting — the property under test is "no NEW ticks start",
706
+ // not "no line is ever appended after the stop() call returns".
707
+ await sleep(60)
708
+ const atStop = lines.length
709
+ await sleep(250)
710
+ // A stop() that did not clear the interval would add ~50 more lines here.
711
+ expect(lines.length).toBe(atStop)
712
+ })
713
+ })