fix(db): delete orphaned WAL/SHM files alongside empty gsd.db (#2478)

syncProjectRootToWorktree deleted empty gsd.db but left companion
-wal and -shm files on disk. On Node 24, node:sqlite attempts WAL
recovery from orphaned files, triggering a synchronous CPU spin loop
(227% CPU, 1.4GB RSS). Now deletes gsd.db-wal and gsd.db-shm when
the main DB is deleted or already missing.
This commit is contained in:
Jeremy 2026-04-04 19:36:43 -05:00
parent b0697f24f6
commit e4987f5337
2 changed files with 99 additions and 2 deletions

View file

@ -314,10 +314,28 @@ export function syncProjectRootToWorktree(
// openDatabase re-creates it, causing "no such table" failures (#2815).
try {
const wtDb = join(wtGsd, "gsd.db");
let deleteSidecars = false;
if (existsSync(wtDb)) {
const size = statSync(wtDb).size;
if (size === 0) {
unlinkSync(wtDb);
deleteSidecars = true;
}
} else {
// Main DB already missing — sidecars are orphaned from a previous
// partial cleanup and must still be removed.
deleteSidecars = true;
}
// Always clean up WAL/SHM sidecar files when the main DB was deleted
// or is already missing. Orphaned WAL/SHM files cause SQLite WAL
// recovery on next open, which triggers a CPU spin on Node 24's
// node:sqlite DatabaseSync implementation (#2478).
if (deleteSidecars) {
for (const suffix of ["-wal", "-shm"]) {
const f = wtDb + suffix;
if (existsSync(f)) {
unlinkSync(f);
}
}
}
} catch (err) {

View file

@ -100,8 +100,87 @@ describe('worktree-db-respawn-truncation (#2815)', async () => {
}
}
// ─── 3. Milestone artifacts still synced when DB is preserved ────────
console.log('\n=== 3. milestone artifacts still synced even when DB preserved ===');
// ─── 3. WAL/SHM sidecar files cleaned up when empty DB is deleted (#2478) ──
console.log('\n=== 3. orphaned WAL/SHM cleaned up alongside empty gsd.db (#2478) ===');
{
const mainBase = createBase('main');
const wtBase = createBase('wt');
try {
const m001Dir = join(mainBase, '.gsd', 'milestones', 'M001');
mkdirSync(m001Dir, { recursive: true });
writeFileSync(join(m001Dir, 'M001-ROADMAP.md'), '# Roadmap');
// Create an empty (0-byte) gsd.db plus orphaned WAL and SHM files —
// this is the exact state that causes Node 24 node:sqlite CPU spin (#2478).
const wtGsd = join(wtBase, '.gsd');
writeFileSync(join(wtGsd, 'gsd.db'), '');
writeFileSync(join(wtGsd, 'gsd.db-wal'), Buffer.alloc(605672, 0xAA));
writeFileSync(join(wtGsd, 'gsd.db-shm'), Buffer.alloc(32768, 0xBB));
assert.ok(existsSync(join(wtGsd, 'gsd.db')), 'gsd.db exists before sync');
assert.ok(existsSync(join(wtGsd, 'gsd.db-wal')), 'gsd.db-wal exists before sync');
assert.ok(existsSync(join(wtGsd, 'gsd.db-shm')), 'gsd.db-shm exists before sync');
syncProjectRootToWorktree(mainBase, wtBase, 'M001');
assert.ok(
!existsSync(join(wtGsd, 'gsd.db')),
'#2478: empty gsd.db must be deleted',
);
assert.ok(
!existsSync(join(wtGsd, 'gsd.db-wal')),
'#2478: orphaned gsd.db-wal must be deleted alongside gsd.db',
);
assert.ok(
!existsSync(join(wtGsd, 'gsd.db-shm')),
'#2478: orphaned gsd.db-shm must be deleted alongside gsd.db',
);
} finally {
cleanup(mainBase);
cleanup(wtBase);
}
}
// ─── 4. Orphaned WAL/SHM cleaned up even when gsd.db already missing (#2478) ──
console.log('\n=== 4. orphaned WAL/SHM cleaned up even without gsd.db (#2478) ===');
{
const mainBase = createBase('main');
const wtBase = createBase('wt');
try {
const m001Dir = join(mainBase, '.gsd', 'milestones', 'M001');
mkdirSync(m001Dir, { recursive: true });
writeFileSync(join(m001Dir, 'M001-ROADMAP.md'), '# Roadmap');
// Orphaned WAL/SHM with NO gsd.db at all — can happen from a previous
// partial cleanup. These must still be cleaned up.
const wtGsd = join(wtBase, '.gsd');
writeFileSync(join(wtGsd, 'gsd.db-wal'), Buffer.alloc(1024, 0xAA));
writeFileSync(join(wtGsd, 'gsd.db-shm'), Buffer.alloc(1024, 0xBB));
assert.ok(!existsSync(join(wtGsd, 'gsd.db')), 'gsd.db does not exist');
assert.ok(existsSync(join(wtGsd, 'gsd.db-wal')), 'orphaned gsd.db-wal exists');
assert.ok(existsSync(join(wtGsd, 'gsd.db-shm')), 'orphaned gsd.db-shm exists');
syncProjectRootToWorktree(mainBase, wtBase, 'M001');
assert.ok(
!existsSync(join(wtGsd, 'gsd.db-wal')),
'#2478: orphaned gsd.db-wal must be deleted even without main db file',
);
assert.ok(
!existsSync(join(wtGsd, 'gsd.db-shm')),
'#2478: orphaned gsd.db-shm must be deleted even without main db file',
);
} finally {
cleanup(mainBase);
cleanup(wtBase);
}
}
// ─── 5. Milestone artifacts still synced when DB is preserved ────────
console.log('\n=== 5. milestone artifacts still synced even when DB preserved ===');
{
const mainBase = createBase('main');
const wtBase = createBase('wt');