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.
@@ -396,8 +396,9 @@ import {
396
396
  pruneMessagesOlderThanDays,
397
397
  hasOutboundDeliveredSince,
398
398
  hasOutboundWithText,
399
- recordSystemOutbound, updateSystemOutboundText,
399
+ recordSystemOutbound, updateSystemOutboundText, reopenHistory, getHistoryReopenFailure,
400
400
  } from '../history.js'
401
+ import { startOrphanedDbSweep } from './orphaned-db-sweep.js'
401
402
  import { makeSystemMessageObserver } from './system-message-observer.js'
402
403
  import {
403
404
  runRegistryReaper,
@@ -2162,6 +2163,7 @@ if (isGatewayMain) runHistoryReaperNow('boot')
2162
2163
  if (isGatewayMain && !STATIC) {
2163
2164
  setInterval(() => runHistoryReaperNow('periodic'), REGISTRY_REAPER_INTERVAL_MS).unref()
2164
2165
  }
2166
+ if (isGatewayMain && !STATIC) startOrphanedDbSweep({ stateDir: STATE_DIR, reopenHistory: HISTORY_ENABLED ? () => reopenHistory(STATE_DIR, HISTORY_ACCESS.historyRetentionDays ?? 30) : undefined, historyReopenFailure: HISTORY_ENABLED ? getHistoryReopenFailure : undefined, log: (l) => process.stderr.write(l) }) // own 5-min tick, NOT the 6h reaper: bounds silent data loss from a deleted-inode DB handle to one interval (gateway/orphaned-db-sweep.ts). Runs regardless of HISTORY_ENABLED — registry.db opens whenever isGatewayMain.
2165
2167
 
2166
2168
  // ─── Approval polling ─────────────────────────────────────────────────────
2167
2169
  function checkApprovals(): void {
@@ -0,0 +1,315 @@
1
+ /**
2
+ * Orphaned-DB-fd sweep — detect and recover from a SQLite handle that is
3
+ * still writing into DELETED inodes.
4
+ *
5
+ * THE FAILURE MODE
6
+ * ----------------
7
+ * The gateway holds `history.db` (bun:sqlite, WAL) open for the process
8
+ * lifetime. A FOREIGN process rw-opened the same DB, and on exit — as the
9
+ * last connection — SQLite checkpointed and UNLINKED `history.db-wal` and
10
+ * `history.db-shm`. Our long-lived connection kept the deleted inodes mapped
11
+ * and kept writing into them for 3h06m, logging success on every insert. The
12
+ * rows were never on disk; they vanished at the next restart. The signature is
13
+ * visible from the process itself:
14
+ *
15
+ * /proc/<pid>/fd/13 -> /…/history.db-wal (deleted)
16
+ *
17
+ * Nothing in the write path can notice this: `INSERT` returns success, the
18
+ * boot-time `verifyHistoryWritable()` self-check already ran hours earlier, and
19
+ * a WAL checkpoint through the stale mapping "succeeds" too. The only in-process
20
+ * evidence is the fd table, so that is what we poll.
21
+ *
22
+ * WHAT THIS DOES
23
+ * --------------
24
+ * Every 5 minutes, walk `/proc/self/fd` and look for a link whose target is a
25
+ * `*.db` (or `-wal`/`-shm`/`-journal` sidecar) file inside the gateway's state
26
+ * dir marked `(deleted)`. On a hit:
27
+ *
28
+ * - Log LOUDLY that rows written since the last checkpoint are LOST. Silent
29
+ * data loss becomes a visible operator signal bounded to one sweep interval.
30
+ * - `history.db`: hard-close-drop-reopen via `reopenHistory()`. Read the long
31
+ * note there before touching it — a plain `close()` is NOT enough (it does
32
+ * not release the fds, and the reopened connection then throws on the first
33
+ * WRITE), and there is deliberately no salvage checkpoint through the
34
+ * orphaned handle. If the reopen throws, we say so and ask for a restart
35
+ * rather than claim a recovery that did not happen.
36
+ * - `registry.db`: alarm only, RESTART REQUIRED. The registry handle
37
+ * (`turnsDb`) is captured BY VALUE into long-lived wiring in `gateway.ts`
38
+ * (the subagent-watcher options object among others), so a close-and-
39
+ * reassign would leave those consumers holding a CLOSED handle — strictly
40
+ * worse than the orphaned one. Detection without action is the honest
41
+ * behaviour here; the operator restarts.
42
+ * - anything else `*.db` in the state dir: alarm only, RESTART REQUIRED. No
43
+ * lane owns it, so the honest answer is to name the file and say so rather
44
+ * than raise a data-loss alarm with no instruction attached.
45
+ *
46
+ * AND ONE THING THAT IS NOT FD-DRIVEN
47
+ * -----------------------------------
48
+ * A reopen that hard-closes the old handle and then fails to re-init leaves
49
+ * history NULL and the orphaned fds GONE — so fd detection can never fire
50
+ * again, and a "FAILED to reopen" line printed once at 03:00 would be the only
51
+ * record of a permanent outage. Every tick therefore also checks the sticky
52
+ * `getHistoryReopenFailure()` flag, alarms on it, and retries the reopen,
53
+ * independently of what the fd table says.
54
+ *
55
+ * WHY THE SCAN IS ASYNC
56
+ * ---------------------
57
+ * The walk is O(open fds) — measured at 10-13ms against a live gateway holding
58
+ * 3366 fds, and `RLIMIT_NOFILE` on the fleet is 524288, so the cost is
59
+ * unbounded by anything we control. A synchronous readdir+readlink loop of that
60
+ * shape blocks the event loop, i.e. stalls inbound Telegram handling, for a
61
+ * check that finds nothing 99.99% of the time. `fs/promises` `opendir` +
62
+ * `readlink` yields between entries, so a long scan costs latency on the sweep
63
+ * (which nobody is waiting for) instead of on the gateway. The alternative —
64
+ * probing only the known DB paths — was rejected: it cannot tell "the file is
65
+ * gone" from "we still hold the gone file open", which is the entire signal.
66
+ *
67
+ * Dependency-free on purpose (`fs` + `path` only) so it loads identically under
68
+ * `bun test` and vitest, and so the recovery path cannot itself be broken by a
69
+ * transitive import that touches the DB.
70
+ */
71
+
72
+ import { realpathSync } from 'fs'
73
+ import { opendir, readlink } from 'fs/promises'
74
+ import { basename } from 'path'
75
+
76
+ /** A `/proc/self/fd` entry pointing at a deleted DB file in the state dir. */
77
+ export interface OrphanedFd {
78
+ fd: number
79
+ /** The raw readlink target, including the trailing ` (deleted)` marker. */
80
+ target: string
81
+ }
82
+
83
+ /** Linux marks an unlinked-but-open fd's readlink target with this suffix. */
84
+ const DELETED_SUFFIX = ' (deleted)'
85
+
86
+ /**
87
+ * A SQLite database file or one of its sidecars, and nothing else.
88
+ *
89
+ * The previous `basename.includes('.db')` test also matched `notes.dbg`,
90
+ * `dump.dbf`, `history.db.bak` and `x.dbus` — an unrelated deleted temp file in
91
+ * the state dir would have raised a "rows are LOST" alarm. Anchored to the end
92
+ * of the basename so only a real `*.db` / `*.db-wal` / `*.db-shm` /
93
+ * `*.db-journal` matches.
94
+ */
95
+ const DB_BASENAME_RE = /\.db(-wal|-shm|-journal)?$/
96
+
97
+ /**
98
+ * Canonicalise the state dir for prefix matching against `/proc` targets.
99
+ *
100
+ * `/proc/self/fd` targets are ALWAYS fully resolved, so comparing them against
101
+ * a `TELEGRAM_STATE_DIR` that is a symlink (or relative) makes every
102
+ * `startsWith` fail and disables detection permanently — with no error, no log,
103
+ * and a sweep that reports "healthy" forever. Returns null when the path cannot
104
+ * be resolved at all, which callers surface as a warning rather than silence.
105
+ */
106
+ export function resolveStateDirPrefix(stateDir: string): string | null {
107
+ let resolved: string
108
+ try {
109
+ resolved = realpathSync(stateDir)
110
+ } catch {
111
+ return null
112
+ }
113
+ return resolved.endsWith('/') ? resolved : resolved + '/'
114
+ }
115
+
116
+ /**
117
+ * Scan `/proc/self/fd` for handles onto deleted DB files under `stateDir`.
118
+ *
119
+ * Returns `[]` — never throws — on non-Linux (no `/proc`), on an unreadable or
120
+ * unresolvable `stateDir`, on an unreadable `/proc/self/fd`, and for any
121
+ * individual fd that races closed between the directory read and the
122
+ * `readlink`.
123
+ *
124
+ * A match requires ALL THREE of:
125
+ * 1. the target is inside the CANONICAL `stateDir` (so an unrelated deleted
126
+ * DB elsewhere on the box is not our problem),
127
+ * 2. the target ends with ` (deleted)` (a healthy open WAL is NOT an orphan),
128
+ * 3. the basename is a SQLite file or sidecar (an unrelated deleted temp file
129
+ * in the state dir must not raise a data-loss alarm).
130
+ */
131
+ export async function detectOrphanedDbFds(stateDir: string): Promise<OrphanedFd[]> {
132
+ if (process.platform !== 'linux') return []
133
+ const prefix = resolveStateDirPrefix(stateDir)
134
+ if (prefix == null) return []
135
+ const found: OrphanedFd[] = []
136
+ try {
137
+ const dir = await opendir('/proc/self/fd')
138
+ for await (const entry of dir) {
139
+ let target: string
140
+ try {
141
+ target = await readlink(`/proc/self/fd/${entry.name}`)
142
+ } catch {
143
+ // The fd closed underneath us (including the directory handle's own
144
+ // fd). Not an orphan; just gone.
145
+ continue
146
+ }
147
+ // Parse FIRST, then filter. Doing the `slice` inside the deleted-check
148
+ // would make the basename test below silently do the deleted-check's job
149
+ // too (slicing 10 chars off a healthy `…/history.db-wal` yields `…/hist`,
150
+ // which fails the DB test by accident) — and an accidental guard is one
151
+ // nobody can mutation-test or safely refactor.
152
+ const deleted = target.endsWith(DELETED_SUFFIX)
153
+ const bare = deleted ? target.slice(0, -DELETED_SUFFIX.length) : target
154
+ if (!bare.startsWith(prefix)) continue
155
+ if (!deleted) continue
156
+ if (!DB_BASENAME_RE.test(basename(bare))) continue
157
+ found.push({ fd: Number(entry.name), target })
158
+ }
159
+ } catch {
160
+ return found
161
+ }
162
+ return found
163
+ }
164
+
165
+ /** Strip the ` (deleted)` marker and return the bare file name. */
166
+ function orphanBasename(target: string): string {
167
+ return basename(target.endsWith(DELETED_SUFFIX) ? target.slice(0, -DELETED_SUFFIX.length) : target)
168
+ }
169
+
170
+ export interface OrphanedDbSweepOptions {
171
+ /** The gateway state dir whose DB files we own. */
172
+ stateDir: string
173
+ /**
174
+ * Recovery for `history.db`. Omit when history is disabled — detection and
175
+ * the loud log still run, only the reopen is skipped.
176
+ */
177
+ reopenHistory?: () => void
178
+ /**
179
+ * The sticky "history is dead" flag (`history.getHistoryReopenFailure`).
180
+ * Checked on EVERY tick, not only when an orphaned fd is found: once a
181
+ * reopen has closed the old handle the fds are released, so fd detection can
182
+ * no longer see the outage it caused. Omit only where history is not wired.
183
+ */
184
+ historyReopenFailure?: () => string | null
185
+ /** Log sink. The gateway passes `(l) => process.stderr.write(l)`. */
186
+ log: (line: string) => void
187
+ }
188
+
189
+ /**
190
+ * One sweep tick: detect, alarm, and recover. Returns the orphans found (empty
191
+ * on the healthy path, which is every tick but the incident one).
192
+ *
193
+ * Never throws — a failed reopen is logged and the next tick retries. That
194
+ * promise is only meaningful because of the sticky-failure lane below: a reopen
195
+ * whose close succeeded but whose re-init failed releases the very fds that
196
+ * would have triggered the next retry.
197
+ */
198
+ export async function runOrphanedDbSweepTick(
199
+ opts: OrphanedDbSweepOptions,
200
+ ): Promise<OrphanedFd[]> {
201
+ if (process.platform === 'linux' && resolveStateDirPrefix(opts.stateDir) == null) {
202
+ opts.log(
203
+ `telegram gateway: orphaned-db-sweep cannot resolve stateDir=${opts.stateDir} —`
204
+ + ` deleted-inode DB detection is DISABLED until it exists and is readable.\n`,
205
+ )
206
+ }
207
+ const orphans = await detectOrphanedDbFds(opts.stateDir)
208
+ let historyHandled = false
209
+
210
+ if (orphans.length > 0) {
211
+ const names = orphans.map((o) => orphanBasename(o.target))
212
+ opts.log(
213
+ `telegram gateway: orphaned-db-sweep DETECTED ${orphans.length} deleted-inode DB handle(s): `
214
+ + orphans.map((o) => `fd=${o.fd} ${o.target}`).join(', ')
215
+ + ` — another process unlinked these files while we held them open; every row written`
216
+ + ` since the last checkpoint is LOST and further writes would be lost too.\n`,
217
+ )
218
+
219
+ if (names.some((n) => n.startsWith('history.db'))) {
220
+ historyHandled = true
221
+ if (opts.reopenHistory) {
222
+ attemptHistoryReopen(opts, 'reopened history.db')
223
+ } else {
224
+ opts.log(
225
+ `telegram gateway: orphaned-db-sweep found an orphaned history.db handle but no reopen`
226
+ + ` is wired (history disabled) — RESTART the gateway to recover.\n`,
227
+ )
228
+ }
229
+ }
230
+
231
+ if (names.some((n) => n.startsWith('registry.db'))) {
232
+ opts.log(
233
+ `telegram gateway: orphaned-db-sweep found an orphaned registry.db handle. An in-process`
234
+ + ` reopen is NOT safe here — the turnsDb handle is captured by value into long-lived`
235
+ + ` wiring, so closing it would leave those consumers on a closed handle. RESTART the`
236
+ + ` gateway to recover; subagent/turn rows written since the last checkpoint are LOST.\n`,
237
+ )
238
+ }
239
+
240
+ // Anything else under the state dir has no lane. Say so explicitly: an
241
+ // unnamed data-loss alarm with no recovery instruction is worse than none.
242
+ const unowned = [...new Set(names.filter(
243
+ (n) => !n.startsWith('history.db') && !n.startsWith('registry.db'),
244
+ ))]
245
+ if (unowned.length > 0) {
246
+ opts.log(
247
+ `telegram gateway: orphaned-db-sweep found orphaned handle(s) on ${unowned.join(', ')},`
248
+ + ` which no recovery lane owns — the gateway cannot reopen them in place. RESTART the`
249
+ + ` gateway to recover; rows written to those files since the last checkpoint are LOST.\n`,
250
+ )
251
+ }
252
+ }
253
+
254
+ // The fd table cannot see a history DB that is already closed and failed to
255
+ // re-open, so this lane runs whether or not anything was detected.
256
+ if (!historyHandled) {
257
+ const stuck = opts.historyReopenFailure?.()
258
+ if (stuck != null && stuck !== '') {
259
+ opts.log(
260
+ `telegram gateway: orphaned-db-sweep history.db is CLOSED and a previous reopen failed`
261
+ + ` (${stuck}) — every history read and write is dead and no fd evidence remains.`
262
+ + ` Retrying the reopen; RESTART the gateway if this keeps repeating.\n`,
263
+ )
264
+ if (opts.reopenHistory) attemptHistoryReopen(opts, 'recovered history.db')
265
+ }
266
+ }
267
+
268
+ return orphans
269
+ }
270
+
271
+ /**
272
+ * Run the wired reopen, logging honestly either way. `successVerb` distinguishes
273
+ * the first-detection recovery from the sticky-failure retry in the log.
274
+ */
275
+ function attemptHistoryReopen(opts: OrphanedDbSweepOptions, successVerb: string): void {
276
+ try {
277
+ opts.reopenHistory?.()
278
+ opts.log(
279
+ `telegram gateway: orphaned-db-sweep ${successVerb} — writes are durable again`
280
+ + ` (proved by the post-reopen writer self-check); rows written since the last`
281
+ + ` checkpoint are NOT recoverable.\n`,
282
+ )
283
+ } catch (err) {
284
+ opts.log(
285
+ `telegram gateway: orphaned-db-sweep FAILED to reopen history.db: ${(err as Error).message}`
286
+ + ` — history writes are NOT durable; RESTART the gateway.\n`,
287
+ )
288
+ }
289
+ }
290
+
291
+ /** Default cadence: bounds silent loss to 5 minutes without polling cost. */
292
+ const DEFAULT_INTERVAL_MS = 5 * 60_000
293
+
294
+ /**
295
+ * Start the periodic sweep. Returns a `stop()` so tests can tear it down;
296
+ * production never stops it (matches every sibling gateway interval).
297
+ *
298
+ * `unref()`s so the timer cannot hold the process alive past shutdown. Ticks do
299
+ * not overlap: the scan is async, so a slow `/proc` walk on a process holding
300
+ * hundreds of thousands of fds must not stack up behind itself.
301
+ */
302
+ export function startOrphanedDbSweep(
303
+ opts: OrphanedDbSweepOptions & { intervalMs?: number },
304
+ ): () => void {
305
+ let running = false
306
+ const timer = setInterval(() => {
307
+ if (running) return
308
+ running = true
309
+ void runOrphanedDbSweepTick(opts)
310
+ .catch(() => { /* a sweep must never take the gateway down; next tick retries */ })
311
+ .finally(() => { running = false })
312
+ }, opts.intervalMs ?? DEFAULT_INTERVAL_MS)
313
+ timer.unref?.()
314
+ return () => clearInterval(timer)
315
+ }