Files
obra__episodic-memory/test/sync-cli-single-instance.test.ts
Jesse Vincent 6303454940 fix: single-instance lock for sync --background (#97)
Independent SessionStart events from multiple Claude Code sessions
each fire `episodic-memory sync --background`, which forks a detached
worker. Without coordination, N parents trigger N concurrent worker
processes racing the same archive and SQLite database.

The reentrancy guard (#87) covers same-process recursion, but it
can't stop SessionStart events from independent sessions whose envs
don't carry EPISODIC_MEMORY_SUMMARIZER_GUARD.

Reproduced locally on macOS: 3 parallel `node dist/sync-cli.js`
processes against the same TEST_ARCHIVE_DIR — worker 1 completes;
workers 2 and 3 crash with `SqliteError: database is locked`
(SQLITE_BUSY) trying to init the DB. On Windows, the reporter's
setup with ~67 worktrees instead piles up enough claude.exe children
to exhaust the desktop heap and crash with STATUS_DLL_INIT_FAILED
(0xC0000142). Same root cause; different blast radius depending on
how far the workers get before stepping on each other.

Fix: a single-instance lock around the sync worker, implemented as a
thin wrapper in src/file-lock.ts over the `proper-lockfile` package.
A first attempt at a hand-rolled openSync('wx') + PID-file protocol
had a residual race under concurrent stale-stealers that pure file
primitives cannot fully close without advisory locking.
proper-lockfile uses an atomic-mkdir + mtime-heartbeat protocol that
is race-free under that contention shape — the same approach npm
itself uses.

sync-cli.ts acquires <log-dir>/episodic-memory-sync.lock after the
source-dir check and before initDatabase(); if another live process
holds it, the worker prints "sync already running (pid X); skipping"
to stderr and exits 0. The lock releases on normal exit and on the
common signals (SIGINT/SIGTERM/SIGHUP).

Embedding migration's own lock now delegates to the generic helper;
its old export names (acquireMigrationLock, releaseMigrationLock,
MigrationLockHandle) stay for back-compat.

proper-lockfile is excluded from the esbuild bundle (runtime dep on
the same level as better-sqlite3/transformers/etc.) so the MCP
server bundle size is unchanged. The wrapper's install-health probe
(#95 Bug 1) gains proper-lockfile as a required package so a partial
extraction surfaces a useful diagnostic.

Tests:
- test/file-lock.test.ts: acquire/release, contention,
  parent-dir creation, garbage diagnostic content, I/O error
  propagation, N-concurrent-acquirers stress test, mtime-based
  stale recovery, fresh-lock-not-reclaimable. Subprocess imports
  use pathToFileURL for Windows compatibility.
- test/sync-cli-single-instance.test.ts: integration via real
  child-process spawn — two concurrent workers (one completes,
  one skips), single sequential run still works, lock released
  on normal exit, stale lock from a dead PID is reclaimable.
- Existing test/embedding-migration.test.ts continues to pass.

Closes #97.
2026-05-21 11:26:10 -07:00

131 lines
4.8 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { spawnSync, spawn } from 'child_process';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { tmpdir } from 'os';
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const SYNC_CLI = join(REPO_ROOT, 'dist', 'sync-cli.js');
interface RunResult {
status: number | null;
stdout: string;
stderr: string;
}
function runWith(env: Record<string, string>): RunResult {
const result = spawnSync(process.execPath, [SYNC_CLI], {
env: { ...process.env, ...env, EPISODIC_MEMORY_SUMMARIZER_GUARD: undefined as any },
timeout: 30_000,
encoding: 'utf-8',
});
return { status: result.status, stdout: result.stdout, stderr: result.stderr };
}
function spawnWith(env: Record<string, string>) {
return spawn(process.execPath, [SYNC_CLI], {
env: { ...process.env, ...env, EPISODIC_MEMORY_SUMMARIZER_GUARD: undefined as any },
stdio: ['ignore', 'pipe', 'pipe'],
});
}
async function collectOutput(child: ReturnType<typeof spawn>): Promise<RunResult> {
let stdout = '';
let stderr = '';
child.stdout!.on('data', d => { stdout += d.toString(); });
child.stderr!.on('data', d => { stderr += d.toString(); });
const status: number | null = await new Promise(resolve => {
child.on('close', code => resolve(code));
});
return { status, stdout, stderr };
}
describe('sync-cli single-instance lock (#97)', () => {
let testDir: string;
let envOverrides: Record<string, string>;
beforeEach(() => {
testDir = mkdtempSync(join(tmpdir(), 'episodic-memory-sync-lock-'));
mkdirSync(join(testDir, 'projects', 'project-a'), { recursive: true });
mkdirSync(join(testDir, 'archive'), { recursive: true });
mkdirSync(join(testDir, 'config', 'logs'), { recursive: true });
// A single zero-exchange file gives each worker something to (try to) do
// without making the test slow or network-dependent.
writeFileSync(
join(testDir, 'projects', 'project-a', '00000000-0000-0000-0000-000000000001.jsonl'),
JSON.stringify({
type: 'file-history-snapshot',
sessionId: '00000000-0000-0000-0000-000000000001',
uuid: 'meta-0',
timestamp: '2026-01-01T00:00:00Z',
}),
'utf-8'
);
envOverrides = {
TEST_PROJECTS_DIR: join(testDir, 'projects'),
TEST_ARCHIVE_DIR: join(testDir, 'archive'),
TEST_DB_PATH: join(testDir, 'test.db'),
EPISODIC_MEMORY_CONFIG_DIR: join(testDir, 'config'),
};
});
afterEach(() => {
try { rmSync(testDir, { recursive: true, force: true }); } catch {}
});
it('only one of two concurrent workers does real work; the other prints "sync already running" and exits 0', async () => {
// Launch two workers as close together as possible. One will win the lock
// race; the other must observe it and bail before initDatabase().
const a = spawnWith(envOverrides);
const b = spawnWith(envOverrides);
const [ra, rb] = await Promise.all([collectOutput(a), collectOutput(b)]);
expect(ra.status).toBe(0);
expect(rb.status).toBe(0);
const winner = ra.stdout.includes('Sync complete') ? ra : rb;
const loser = winner === ra ? rb : ra;
expect(winner.stdout).toMatch(/Sync complete/);
expect(loser.stderr).toMatch(/sync already running.*skipping/);
expect(loser.stdout).not.toMatch(/Sync complete/);
});
it('a single sequential run is unaffected by the lock — runs to completion as before', () => {
const result = runWith(envOverrides);
expect(result.status).toBe(0);
expect(result.stdout).toMatch(/Sync complete/);
});
it('releases the lock on normal exit — a subsequent run is not skipped', () => {
const first = runWith(envOverrides);
expect(first.status).toBe(0);
expect(first.stdout).toMatch(/Sync complete/);
// Lock file must not be left behind for the next run.
const lockPath = join(testDir, 'config', 'logs', 'episodic-memory-sync.lock');
expect(existsSync(lockPath)).toBe(false);
const second = runWith(envOverrides);
expect(second.status).toBe(0);
expect(second.stdout).toMatch(/Sync complete/);
expect(second.stderr).not.toMatch(/sync already running/);
});
it('steals a stale lock left by a crashed previous worker (PID 999999 is dead)', () => {
const lockPath = join(testDir, 'config', 'logs', 'episodic-memory-sync.lock');
writeFileSync(lockPath, '999999', 'utf-8');
const result = runWith(envOverrides);
expect(result.status).toBe(0);
expect(result.stdout).toMatch(/Sync complete/);
expect(result.stderr).not.toMatch(/sync already running/);
// After completion, the lock file is gone.
expect(existsSync(lockPath)).toBe(false);
});
});