Skip to content

Commit abde588

Browse files
authored
fix(db): recover WASM SQLite locks via PID sentinel, not a 60s age window (#1261)
## Summary Follow-up to #1260 addressing a post-merge finding from **Cursor Bugbot** and **sentry-warden**. On the WASM SQLite fallback (npm package on Node < 22.15), `node-sqlite3-wasm` locks the DB with a `<db>.lock` directory (mkdir mutex) and releases it via `rmdir` on close. A CLI process killed by **SIGINT (Ctrl+C) / SIGTERM** mid-write bypasses the `process.on('exit')` handler, so the lock dir leaks. The previous recovery only cleared a leftover lock once it aged past a **60-second** window — so a quick re-run after Ctrl+C failed with `database is locked` for up to a minute. ## Fix PID-owner sentinel for **precise** liveness detection instead of a time guess: - On open (WASM), write `<db>.lock.owner` = our PID. - On the next open, `clearStaleWasmLock` reads it: - owner **dead** → lock is provably orphaned → cleared **immediately** (no wait), - owner **alive** → genuine live lock → left to `busy_timeout` (no stealing), - **no sentinel** → fall back to the age window (older CLI / races). - On clean close, remove the sentinel. The sentinel lives **outside** the lock dir — a file *inside* it would make the driver's `rmdir` unlock fail (verified), defeating the driver's own cleanup. ### Why not a SIGINT/SIGTERM handler? A DB-layer signal handler would risk interfering with commands that legitimately own SIGINT for graceful streaming shutdown (`log list --follow`, `dashboard view`), which keep the process alive after the signal. The recovery-side fix has zero interference risk. ## Verification - New tests: dead-owner lock cleared immediately despite fresh mtime; live-owner (current PID) lock preserved despite old mtime. 28 sqlite-adapter tests, 296 db tests pass. - E2E reproduction: a fresh lock dir with a dead-owner sentinel is cleared on the next run in ~startup time (no 60s stall); previously this exact case blocked for 60s. - SEA binary still excludes the WASM driver (0 driver symbols); native path unchanged. - npm real-install (no `node_modules`) smoke passes. Also corrected the now-inaccurate JSDoc that described the (previously wrong) 'live lock is recently re-touched' premise.
1 parent d2d3ca9 commit abde588

7 files changed

Lines changed: 273 additions & 57 deletions

File tree

.lore.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -483,9 +483,6 @@
483483
<!-- lore:019f666a-ac76-7581-82f8-8d2af3f3e428 -->
484484
* **Always perform rigorous, read-only code reviews with specific focus areas**: The user consistently conducts critical, adversarial code reviews in a read-only manner, focusing on specific technical areas. They provide detailed context about the repository, branch, and changes under review, and specify exact focus areas like path correctness, dependency management, build configurations, and security implications. The user maintains strict read-only discipline, prohibiting any modifications to source files, and often works within isolated worktrees. They expect thorough verification of technical changes against documented requirements and prior decisions.
485485

486-
<!-- lore:019e6134-4be2-75d2-8eb3-a69dd468428d -->
487-
* **Always plan systemic fixes with structured multi-problem breakdowns before implementation**: When the user identifies documentation or tooling issues, they consistently organize them as numbered problems with precise file locations, line numbers, and root causes before any code is written. They expect the assistant to engage at the planning level first — proposing detection strategies, fix approaches, and tradeoffs — and to consolidate related problems (e.g., merging overlapping tasks) rather than treating each in isolation. Plans are written to files and iterated on. Implementation only follows after the plan is agreed upon. The user prefers systemic/automated fixes (e.g., derive patterns from package.json) over one-off patches.
488-
489486
<!-- lore:019f65d7-0856-76f1-8ef3-252b56b66279 -->
490487
* **Always plans for testing or verification of tasks**: The user consistently implies a need for testing or verification of tasks, as seen in multiple instances where they either directly request tests or imply the necessity for verification. This pattern is observed across various sessions where the user is planning and executing tasks, indicating a preference for ensuring that tasks are validated or tested.
491488

@@ -507,6 +504,9 @@
507504
<!-- lore:019f6b87-5b0e-7186-935e-63eedf17b1ec -->
508505
* **Always provide comprehensive technical context for implementation decisions**: The user consistently provides detailed technical context when making implementation decisions, including code diffs, test results, build configurations, and dependency analysis. This pattern shows the user expects technical discussions to be grounded in concrete evidence and implementation details, with changes justified by specific requirements like Node version compatibility or WASM driver behavior.
509506

507+
<!-- lore:019f6f45-f55f-7448-bdd5-4b1840e8d3c6 -->
508+
* **Always provide comprehensive verification artifacts during code reviews**: The user consistently provides detailed verification artifacts when submitting code for review. This includes sharing commit hashes, full code snippets with context, test results across multiple environments, CI status updates, and bot review comments. The user expects reviewers to have complete visibility into the changes and their impact, and proactively shares all relevant information to facilitate thorough review and fast iteration.
509+
510510
<!-- lore:019e610a-fc63-7820-b403-4255d0f0e29e -->
511511
* **Always read and document full file details before proceeding with analysis or implementation**: When exploring a codebase, the user consistently reads files in full and records comprehensive structured details: exact line counts, all imports, every exported type/interface with their fields, all constants, all function signatures with their logic, and any notable comments or assertions. This applies to both source files and build/tooling scripts. The user expects the assistant to capture and reference these details precisely rather than summarizing loosely. When examining related files (e.g., a module and its consumers), the user reads each completely before drawing conclusions. This pattern applies during architecture exploration, feature planning, and documentation generation tasks.
512512

@@ -570,6 +570,9 @@
570570
<!-- lore:019f60f6-ec63-7db9-b92a-fd75ecf48e2b -->
571571
* **Always Verify and Validate Code Correctness**: The user consistently verifies and validates code correctness across multiple sessions. This includes checking the correctness of functions, ensuring proper scoping of catch blocks, verifying variable destructuring, and confirming the integrity of specific code elements. The user also tends to ask questions about code behavior, compatibility, and potential issues, demonstrating a thorough approach to code review and validation.
572572

573+
<!-- lore:019f6fa8-3cae-7682-afa0-80d90a694728 -->
574+
* **Always verify bot findings and test coverage before merging**: The user consistently demonstrates a pattern of thorough verification before merging changes. They always review bot findings (sentry-warden, Cursor Bugbot, etc.) in detail, share relevant code snippets to verify findings against current code, and run comprehensive tests including edge cases. The user expects to see test results for all scenarios, especially for database operations and signal handling. They also verify that fixes address the root cause and don't introduce new issues.
575+
573576
<!-- lore:019e6107-5329-700f-b5b8-e7fd5804d036 -->
574577
* **Always verify code claims against actual file contents before accepting them as true**: When evaluating PRs, documentation, or assertions about code behavior, the user systematically cross-checks every claim against the actual source files at specific line numbers. They expect the assistant to read the real files, confirm exact line locations, quote the relevant code/comments, and flag discrepancies between what is claimed and what the code actually does. The user marks confirmed findings with 🟡 (verified) and actionable directives with 🔴 (user assertion/directive). Never accept a PR description or assertion at face value — always ground-truth it against the codebase with precise line references.
575578

src/lib/binary.ts

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
} from "./custom-ca.js";
2626
import { stringifyUnknown, UpgradeError } from "./errors.js";
2727
import { logger } from "./logger.js";
28-
28+
import { isProcessRunning } from "./process-utils.js";
2929
/** Known directories where the curl installer may place the binary */
3030
export const KNOWN_CURL_DIRS = [".local/bin", "bin", ".sentry/bin"];
3131

@@ -360,27 +360,6 @@ export function cleanupOldBinary(oldPath: string): void {
360360

361361
// Lock Management
362362

363-
/**
364-
* Check if a process with the given PID is still running.
365-
*
366-
* On Unix, process.kill(pid, 0) throws:
367-
* - ESRCH: Process does not exist (not running)
368-
* - EPERM: Process exists but we lack permission to signal it (IS running)
369-
*/
370-
export function isProcessRunning(pid: number): boolean {
371-
try {
372-
process.kill(pid, 0); // Signal 0 just checks if process exists
373-
return true;
374-
} catch (error) {
375-
// EPERM means process exists but we can't signal it (different user)
376-
if ((error as NodeJS.ErrnoException).code === "EPERM") {
377-
return true;
378-
}
379-
// ESRCH or other errors mean process is not running
380-
return false;
381-
}
382-
}
383-
384363
/**
385364
* Acquire an exclusive lock for binary installation/upgrade.
386365
* Uses atomic file creation with 'wx' flag to prevent race conditions.

src/lib/db/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,10 @@ let dbOpenedPath: string | null = null;
4444
* wrapper finalizes cursors so this happens reliably. Explicitly closing on
4545
* a normal `exit` is a proactive backstop that shrinks the window in which a
4646
* lock could linger; it does NOT fire on SIGKILL/SIGINT, so it is not the
47-
* primary guarantee. Crash-orphaned locks are recovered at open time by
48-
* {@link clearStaleWasmLock}. Harmless (and cheap) for the native driver too.
47+
* primary guarantee. A lock orphaned by a signal-killed process is recovered
48+
* at the next open by `clearStaleWasmLock`, which uses the PID-owner sentinel
49+
* to clear it immediately once the owner is gone. Harmless (and cheap) for the
50+
* native driver too.
4951
*/
5052
let exitHandlerRegistered = false;
5153

src/lib/db/sqlite.ts

Lines changed: 138 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,16 @@
2020
* return-value differences that are reconciled here.
2121
*/
2222

23-
import { rmdirSync, statSync } from "node:fs";
23+
import {
24+
readFileSync,
25+
rmdirSync,
26+
rmSync,
27+
statSync,
28+
writeFileSync,
29+
} from "node:fs";
2430
import { createRequire } from "node:module";
2531
import { logger } from "../logger.js";
32+
import { isProcessRunning } from "../process-utils.js";
2633

2734
const _require = createRequire(import.meta.url);
2835

@@ -257,53 +264,104 @@ function resolveDriver(): ResolvedDriver {
257264
}
258265

259266
/**
260-
* Age (ms) beyond which a `<db>.lock` directory is considered stale.
267+
* Fallback age (ms) beyond which a `<db>.lock` directory with no usable PID
268+
* sentinel is treated as stale.
261269
*
262-
* The WASM driver holds the lock only briefly (during a write transaction's
263-
* lock escalation) and downgrades between operations, so a live lock's
264-
* directory is re-created and thus recently modified. A directory older than
265-
* this window is therefore almost certainly orphaned by a dead process. The
266-
* value is deliberately generous (well beyond any single write) to avoid
267-
* ever racing a genuinely concurrent writer.
270+
* The WASM driver acquires the lock (`mkdir`) for the duration of a write and
271+
* releases it (`rmdir`) when SQLite drops to lock level NONE; while held, the
272+
* directory's mtime is frozen at acquisition time (the driver never re-touches
273+
* it). A held lock therefore ages, so a pure time threshold cannot by itself
274+
* distinguish "long-running live writer" from "orphaned by a dead process" —
275+
* that is what the PID sentinel is for. This age check is only the fallback
276+
* for locks with no readable sentinel (e.g. left by an older CLI version); it
277+
* is kept generously large so it never races a plausibly-live writer.
268278
*/
269279
const STALE_LOCK_MAX_AGE_MS = 60_000;
270280

281+
/**
282+
* Minimum age (ms) before a lock may be removed *at all*, even when the PID
283+
* sentinel says its owner is dead.
284+
*
285+
* The sentinel is a single shared file, so it can be stale: a leftover
286+
* `.lock.owner` naming a dead PID may sit next to a lock that a *different*,
287+
* still-live process (e.g. an older CLI that doesn't write sentinels) just
288+
* acquired. Refusing to delete any lock younger than this floor guarantees we
289+
* never steal a freshly-acquired live lock on the strength of a stale
290+
* sentinel. It is comfortably larger than a single write's lock hold (sub-ms
291+
* to a few ms) but small enough that Ctrl+C recovery is near-instant instead
292+
* of the old 60s wait.
293+
*/
294+
const LOCK_SAFETY_FLOOR_MS = 3000;
295+
296+
/**
297+
* Sibling file recording the PID of the process that most recently opened the
298+
* DB on the WASM path. Lets a later invocation test whether a leftover
299+
* `<db>.lock` belongs to a still-running process or an orphan. Kept *outside*
300+
* the lock directory itself: a file inside it would make the driver's
301+
* `rmdir`-based unlock fail, defeating the driver's own cleanup.
302+
*/
303+
function lockOwnerPath(dbPath: string): string {
304+
return `${dbPath}.lock.owner`;
305+
}
306+
271307
/**
272308
* Remove a stale lock directory left by a previous `node-sqlite3-wasm`
273309
* process.
274310
*
275311
* The WASM driver implements SQLite file locking by `mkdir("<db>.lock")`
276312
* (an atomic mutex) and releasing it with `rmdir` only when SQLite lowers
277-
* the lock to NONE. If a process exits while still holding any lock level
278-
* — including a normal exit where the connection isn't explicitly downgraded
279-
* the empty directory is left behind, and the next invocation's `mkdir`
280-
* fails with EEXIST, surfacing as "database is locked" forever.
313+
* the lock to NONE. If a process is killed (SIGINT/SIGKILL) while holding a
314+
* lock — bypassing our normal close — the empty directory is left behind, and
315+
* the next invocation's `mkdir` fails with EEXIST, surfacing as "database is
316+
* locked".
281317
*
282-
* We only remove a lock directory that is *older* than
283-
* {@link STALE_LOCK_MAX_AGE_MS}, so a lock a concurrently-running CLI just
284-
* acquired is never deleted out from under it — that live contention is left
285-
* to SQLite's own `busy_timeout`. A recent lock is kept; only a clearly
286-
* orphaned one is cleared. Only the empty lock directory is removed (a
287-
* non-empty dir, never produced by this driver, is left intact by `rmdir`).
318+
* Removal is deliberately conservative — it never deletes a lock that could
319+
* still be live:
320+
* - A lock younger than {@link LOCK_SAFETY_FLOOR_MS} is always kept, even if a
321+
* (possibly stale) sentinel names a dead PID. This stops a leftover sentinel
322+
* from stealing a lock another process just acquired.
323+
* - Past that floor, a dead-owner sentinel means the lock is provably orphaned
324+
* → clear it immediately (near-instant Ctrl+C recovery).
325+
* - A live-owner sentinel means a genuine live lock → keep it (contention is
326+
* left to SQLite's `busy_timeout`).
327+
* - With no readable sentinel we fall back to the {@link STALE_LOCK_MAX_AGE_MS}
328+
* age heuristic.
288329
*
289-
* Best-effort: any failure is swallowed so a permissions issue or a genuine
290-
* race degrades to the driver's own locking error rather than a crash here.
330+
* Only the empty lock directory is removed (`rmdir` leaves a non-empty dir
331+
* intact). Best-effort: any failure is swallowed so a permissions issue or a
332+
* genuine race degrades to the driver's own locking error rather than a crash.
291333
*/
292334
function clearStaleWasmLock(dbPath: string): void {
293335
if (dbPath === ":memory:" || dbPath === "") {
294336
return;
295337
}
296338
const lockDir = `${dbPath}.lock`;
297339
try {
298-
const age = Date.now() - statSync(lockDir).mtimeMs;
299-
if (age < STALE_LOCK_MAX_AGE_MS) {
300-
// Recent — may belong to a live process. Let busy_timeout handle it.
340+
const stat = statSync(lockDir); // throws ENOENT when no lock — common case
341+
const age = Date.now() - stat.mtimeMs;
342+
343+
// Safety floor: too fresh to touch. A lock this young may have just been
344+
// acquired by a live peer, and a stale sentinel must not override that.
345+
if (age < LOCK_SAFETY_FLOOR_MS) {
301346
return;
302347
}
348+
349+
const owner = readLockOwner(dbPath);
350+
if (owner !== null) {
351+
if (isProcessRunning(owner)) {
352+
// Owner still running: a genuine live lock. Leave it to busy_timeout.
353+
return;
354+
}
355+
// Owner dead and lock past the safety floor: provably orphaned.
356+
} else if (age < STALE_LOCK_MAX_AGE_MS) {
357+
// No sentinel to prove death; not old enough for the age fallback.
358+
return;
359+
}
360+
303361
// rmdirSync only removes empty directories, so this can never delete a
304362
// lock actively held with contents (this driver's locks are always empty).
305363
rmdirSync(lockDir);
306-
log.debug(`Cleared stale WASM SQLite lock (age ${age}ms): ${lockDir}`);
364+
log.debug(`Cleared orphaned WASM SQLite lock: ${lockDir}`);
307365
} catch (error) {
308366
const code = (error as NodeJS.ErrnoException).code;
309367
// ENOENT: no stale lock (the common case). Anything else is logged.
@@ -313,6 +371,47 @@ function clearStaleWasmLock(dbPath: string): void {
313371
}
314372
}
315373

374+
/**
375+
* Record the current process as the WASM lock owner (best-effort).
376+
* Written on open so a later invocation can test our liveness if we're killed
377+
* mid-write before {@link removeLockOwner} runs.
378+
*/
379+
function writeLockOwner(dbPath: string): void {
380+
if (dbPath === ":memory:" || dbPath === "") {
381+
return;
382+
}
383+
try {
384+
writeFileSync(lockOwnerPath(dbPath), String(process.pid));
385+
} catch (error) {
386+
log.debug("Could not write WASM lock owner sentinel", error);
387+
}
388+
}
389+
390+
/** Read the recorded owner PID, or null if absent/unreadable. */
391+
function readLockOwner(dbPath: string): number | null {
392+
try {
393+
const pid = Number.parseInt(
394+
readFileSync(lockOwnerPath(dbPath), "utf8"),
395+
10
396+
);
397+
return Number.isInteger(pid) && pid > 0 ? pid : null;
398+
} catch {
399+
return null;
400+
}
401+
}
402+
403+
/** Remove our owner sentinel on clean close (best-effort). */
404+
function removeLockOwner(dbPath: string): void {
405+
if (dbPath === ":memory:" || dbPath === "") {
406+
return;
407+
}
408+
try {
409+
rmSync(lockOwnerPath(dbPath), { force: true });
410+
} catch (error) {
411+
log.debug("Could not remove WASM lock owner sentinel", error);
412+
}
413+
}
414+
316415
/**
317416
* SQLite database wrapper.
318417
*
@@ -328,13 +427,22 @@ export class Database {
328427
/** Which backing driver this instance uses (for param normalisation). */
329428
private readonly kind: DriverKind;
330429

430+
/** On-disk path (for WASM lock-owner sentinel cleanup on close). */
431+
private readonly path: string;
432+
331433
constructor(path: string) {
332434
const { Ctor, kind } = resolveDriver();
333435
if (kind === "wasm") {
334436
clearStaleWasmLock(path);
335437
}
336438
this.db = new Ctor(path);
337439
this.kind = kind;
440+
this.path = path;
441+
if (kind === "wasm") {
442+
// Record ourselves so a later invocation can tell whether a leftover
443+
// lock (e.g. after we're SIGINT-killed mid-write) is orphaned or live.
444+
writeLockOwner(path);
445+
}
338446
}
339447

340448
/**
@@ -369,11 +477,15 @@ export class Database {
369477
* remove the lock directory ourselves here: `<db>.lock` is a path-keyed
370478
* cross-process mutex, so a stray `rmdir` could delete a concurrently-
371479
* running CLI's live lock and allow two writers at once (corruption). Stale
372-
* locks left by a crashed process are handled conservatively at open time by
373-
* {@link clearStaleWasmLock}.
480+
* locks left by a crashed process are handled at open time by
481+
* {@link clearStaleWasmLock}. We do clear our own owner sentinel so it can't
482+
* linger and be misread by a later invocation.
374483
*/
375484
close(): void {
376485
this.db.close();
486+
if (this.kind === "wasm") {
487+
removeLockOwner(this.path);
488+
}
377489
}
378490

379491
/**

src/lib/process-utils.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* Small OS-process helpers shared across modules.
3+
*
4+
* Kept as a dependency-free leaf module so lean, hot-path callers (e.g. the
5+
* SQLite adapter, loaded on every command) can reuse process liveness checks
6+
* without pulling in the heavier graphs of modules like `binary.ts`.
7+
*/
8+
9+
/**
10+
* Check if a process with the given PID is still running.
11+
*
12+
* On Unix, `process.kill(pid, 0)` sends no signal but performs error checking:
13+
* - ESRCH: process does not exist (not running).
14+
* - EPERM: process exists but we lack permission to signal it (IS running).
15+
*
16+
* @param pid Process ID to probe.
17+
* @returns `true` if a process with this PID currently exists.
18+
*/
19+
export function isProcessRunning(pid: number): boolean {
20+
try {
21+
process.kill(pid, 0); // Signal 0 just checks if the process exists.
22+
return true;
23+
} catch (error) {
24+
// EPERM means the process exists but we can't signal it (different user).
25+
if ((error as NodeJS.ErrnoException).code === "EPERM") {
26+
return true;
27+
}
28+
// ESRCH or any other error means the process is not running.
29+
return false;
30+
}
31+
}

0 commit comments

Comments
 (0)