◐ Off-By-One · answer catalog

typescript-daemon-pidfile-hygiene

1 answer(s)godocker

typescript-daemon-pidfile-hygiene

📦 Source in repository (JSON)

Answer

Root cause: the daemon wrote every instance's pid to one shared path (/tmp/duckbrain-http.pid), so instance B on another port overwrote — and later deleted — instance A's pidfile, corrupting /server_status; crash cleanup never ran, leaking 1500+ scratch duckbrain-<pid>-*.db files.

The fix is a shared helper src/utils/pidfile.ts that names pidfiles per instance (duckbrain-http-<port>.pid, with a socket-basename variant), resolved once in startHttpMode and reused for claim, status bookkeeping, and shutdown removal; process.on('exit') deletes only this process's own scratch files. Full repo (Node 22 runs TS natively, zero runtime deps): /tmp/db-hygiene.

src/utils/pidfile.ts (shared helper — the single source of naming truth):

export function httpInstanceName(port: number): string { return `duckbrain-http-${port}`; }
export function socketInstanceName(socketPath: string): string { return path.basename(socketPath); } // socket-basename variant
export function pidfilePathForHttp(port: number, dir: string = pidDir()): string {
  return path.join(dir, `${httpInstanceName(port)}.pid`);   // duckbrain-http-<port>.pid
}
export function pidfilePathForSocket(socketPath: string, dir: string = pidDir()): string {
  return path.join(dir, `${socketInstanceName(socketPath)}.pid`);
}
// pidDir() honours $DUCKBRAIN_PID_DIR (default /tmp) -> hermetic tests

export function writePidfile(pidfile: string, pid: number = process.pid): string {   // atomic: tmp + rename
  const tmp = `${pidfile}.${pid}.tmp`;
  fs.writeFileSync(tmp, `${pid}\n`, { encoding: 'utf8', mode: 0o644 });
  fs.renameSync(tmp, pidfile);
  return pidfile;
}
export function readPidfile(pidfile: string): number | null { /* missing/empty/unparsable -> null */ }
export function removePidfile(pidfile: string): void { /* ENOENT-safe unlink */ }
export function isProcessAlive(pid: number): boolean { /* kill(pid, 0) */ }

export function acquirePidfile(pidfile: string, pid: number = process.pid): AcquireResult {
  const existing = readPidfile(pidfile);
  if (existing !== null && existing !== pid && isProcessAlive(existing))
    return { ok: false, pidfile, ownerPid: existing };   // never clobber a live sibling
  writePidfile(pidfile, pid);                            // stale (dead-pid) files are taken over
  return { ok: true, pidfile, pid };
}

src/httpd.ts — startHttpMode computes the pidfile once and reuses that exact path for write, status, and shutdown-remove:

const pidfile = pidfilePathForHttp(actualPort, opts.pidDir);   // computed once
const claimed = acquirePidfile(pidfile, process.pid);          // (1) write/claim
if (!claimed.ok) { await close(server); throw new Error(`port ${actualPort} served by live pid ${claimed.ownerPid}`); }

// (2) /server_status bookkeeping resolves through the same helper
export function serverStatus(port: number, opts: { pidDir?: string } = {}): ServerStatus {
  const pidfile = pidfilePathForHttp(port, opts.pidDir);
  const pid = readPidfile(pidfile);
  return { running: pid !== null && isProcessAlive(pid), port, pid, pidfile };
}

// (3) shutdown-remove uses the same path, only while we still own it
shutdown: async () => { await close(server); if (readPidfile(pidfile) === process.pid) removePidfile(pidfile); }

// crash-leak fix: exit hook deletes ONLY this process's scratch files
export function isOwnScratchDbFile(name: string, pid: number = process.pid): boolean {
  return /^duckbrain-\d+-.*\.db$/.test(name) && name.startsWith(`duckbrain-${pid}-`);
}
export function cleanOwnScratchDbFiles(dir: string, pid: number = process.pid): number {
  // unlinks only duckbrain-<pid>-*.db; any other pid's files are never touched
}
process.on('exit', () => {
  for (const inst of activeInstances) {
    if (readPidfile(inst.pidfile) === inst.pid) removePidfile(inst.pidfile);
    cleanOwnScratchDbFiles(inst.dataDir, inst.pid);
  }
});

Two instances now live side by side (duckbrain-http-8000.pid vs duckbrain-http-9000.pid); a same-port second instance is refused by the OS bind and by the pidfile claim backstop; normal exit removes the exact pidfile and only own-pid scratch files.

Evidence & signatures

Verified in `/tmp/db-hygiene`: `tsc --noEmit` (strict) passes; `node --test` → **11/11 pass, stable across 3 consecutive runs**; no stray `/tmp/duckbrain-http*.pid` or `/tmp` residue (hermetic — every test uses `mkdtemp` pid/data dirs + ephemeral ports).

Reproduced old-vs-new behaviour directly:

```
OLD: :8000 status reads pid 2222 (expected 1111, got 2222) -> corrupted
NEW: :8000 -> duckbrain-http-8000.pid pid 1111
NEW: :9000 -> duckbrain-http-9000.pid pid 2222
NEW: :8000 status intact after :9000 starts: true
NEW: shutdown of A removes only A: true
```

11 hermetic tests (`tests/pidfile.test.ts`, `tests/daemon.test.ts`; fixture `tests/fixtures/exit-cleanup-child.ts`):

| # | Test | Edge cases covered |
|---|------|--------------------|
| 1 | http pidfile embeds port, per-port, under pid dir | never resolves to old `/tmp/duckbrain-http.pid` |
| 2 | socket-basename variant | `api.sock` → `api.sock.pid`; `DUCKBRAIN_PID_DIR` env honored |
| 3 | write/read/remove roundtrip | missing, empty/whitespace file → `null`; idempotent remove |
| 4 | atomic write | no `*.tmp` residue after rename |
| 5 | acquire refuses live owner | real spawned child pid; file untouched (not clobbered/deleted) |
| 6 | acquire takes over stale pidfile | deterministic dead pid (spawn→kill→reap) |
| 7 | `startHttpMode` writes helper-resolved pidfile w/ own pid; shutdown removes it | same `daemon.pidfile === pidfilePathForHttp(port)` |
| 8 | `/server_status` via same helper | running=true on own port; sibling port → running=false |
| 9 | second instance same port refused | rejects; first pidfile + status fully intact |
| 10 | shutdown removes only own pidfile | sibling instance (other port) survives, still serving |
| 11 | `process.on('exit')` cleanup (real child `process.exit(0)`) | own `duckbrain-<pid>-a/b.db` removed, foreign decoys `duckbrain-424242-*`, `duckbrain-<pid+1000>-*` untouched, pidfile removed |
{"model": "deepseek-v4-flash", "problem_class": "typescript-daemon-pidfile-hygiene", "result": "passed", "tests": 11}
Generated from the verified corpus · MIT licensedBack to the catalog