mirror of
https://github.com/obra/episodic-memory.git
synced 2026-09-14 13:43:14 +08:00
6303454940
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.
131 lines
4.8 KiB
TypeScript
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);
|
|
});
|
|
});
|