Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

#### MCP / indexing

- Daemon startup and cleanup now preserve live legacy PID-only locks while still reclaiming dead or identity-disproved records, preventing two writers from serving the same project.

- The prompt hook no longer injects unrelated projects when run from your home directory or a broader directory containing a stray workspace manifest. (#1454)

- Indexing now succeeds when Node.js's SQLite lacks FTS5, with search falling back to name and fuzzy matching; thanks @aniruddhaadak80. (#1532)
Expand Down
31 changes: 31 additions & 0 deletions __tests__/cli-unlock.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,4 +99,35 @@ describe('codegraph unlock — daemon artifact recovery (#1553)', () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});

it('preserves a live legacy lock whose daemon identity cannot be probed', () => {
const pidPath = getDaemonPidPath(tempDir);
fs.writeFileSync(pidPath, `${process.pid}\n`);

const output = runCodegraph(['unlock', tempDir], tempDir);

expect(output).toContain('No stale lock files found');
expect(fs.readFileSync(pidPath, 'utf8')).toBe(`${process.pid}\n`);
expect(() => process.kill(process.pid, 0)).not.toThrow();
});

it('removes a legacy lock whose PID is dead', () => {
const pidPath = getDaemonPidPath(tempDir);
fs.writeFileSync(pidPath, '999999\n');

const output = runCodegraph(['unlock', tempDir], tempDir);

expect(output).toContain('Removed stale lock artifacts');
expect(fs.existsSync(pidPath)).toBe(false);
});

it('removes a malformed daemon lock', () => {
const pidPath = getDaemonPidPath(tempDir);
fs.writeFileSync(pidPath, 'not-a-lock\n');

const output = runCodegraph(['unlock', tempDir], tempDir);

expect(output).toContain('Removed stale lock artifacts');
expect(fs.existsSync(pidPath)).toBe(false);
});
});
29 changes: 29 additions & 0 deletions __tests__/daemon-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,35 @@ describe('runDaemonPicker', () => {
expect(h.getDone()).toBe('Done.');
});

it('does not report an unverified daemon as stopped', async () => {
const h = harness([rec('/p/a', 42, 1)], ['/p/a', CANCEL]);
h.deps.stop = async (root): Promise<StopResult> => ({
root,
pid: 42,
outcome: 'unverified',
});

await runDaemonPicker(h.deps);

expect(h.notes).toEqual([
'Could not verify daemon (pid 42); left it running with its artifacts intact — /p/a',
]);
expect(h.getDone()).toContain('Cancelled');
});

it.each([
['not-running', 42, 'Daemon was no longer running; removed stale artifacts — /p/a'],
['no-daemon', null, 'No daemon was found — /p/a'],
] as const)('reports the %s race outcome accurately', async (outcome, pid, message) => {
const h = harness([rec('/p/a', 42, 1)], ['/p/a', CANCEL]);
h.deps.stop = async (root): Promise<StopResult> => ({ root, pid, outcome });

await runDaemonPicker(h.deps);

expect(h.notes).toEqual([message]);
expect(h.getDone()).toContain('Cancelled');
});

it('Cancel (and Esc/Ctrl-C) stop nothing', async () => {
const h1 = harness([rec('/p/a', 1, 1)], [CANCEL]);
await runDaemonPicker(h1.deps);
Expand Down
85 changes: 85 additions & 0 deletions __tests__/daemon-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ import {
deregisterDaemon,
listDaemons,
listVerifiedDaemons,
clearStaleDaemonArtifacts,
stopDaemonAt,
type DaemonRecord,
} from '../src/mcp/daemon-registry';
import { encodeLockInfo, getDaemonPidPath } from '../src/mcp/daemon-paths';
import { releaseWriterLock, tryAcquireWriterLock } from '../src/mcp/writer-lock';

/** A pid that's guaranteed dead: spawn a trivial process, let it exit, reap it. */
async function deadPid(): Promise<number> {
Expand Down Expand Up @@ -155,4 +157,87 @@ describe('daemon-registry', () => {
expect(isProcessAlive(process.pid)).toBe(true);
expect(fs.existsSync(pidPath)).toBe(false);
});

it('preserves a live legacy lock when stop cannot verify daemon identity', async () => {
const root = fs.mkdtempSync(path.join(tmpHome, 'legacy-stop-'));
const pidPath = getDaemonPidPath(root);
fs.mkdirSync(path.dirname(pidPath), { recursive: true });
fs.writeFileSync(pidPath, `${process.pid}\n`);

const result = await stopDaemonAt(root);

expect(result).toMatchObject({ pid: process.pid, outcome: 'unverified' });
expect(fs.readFileSync(pidPath, 'utf8')).toBe(`${process.pid}\n`);
expect(isProcessAlive(process.pid)).toBe(true);
});

it('preserves a replacement lock written while stale identity is probed', async () => {
const root = fs.mkdtempSync(path.join(tmpHome, 'probe-race-'));
const pidPath = getDaemonPidPath(root);
const socketPath = process.platform === 'win32'
? `\\\\.\\pipe\\cg-race-old-${process.pid}-${Date.now()}`
: path.join(tmpHome, 'probe-race-old.sock');
const replacementSocketPath = process.platform === 'win32'
? `\\\\.\\pipe\\cg-race-new-${process.pid}-${Date.now()}`
: path.join(tmpHome, 'probe-race-new.sock');
let acceptConnection!: () => void;
const connected = new Promise<void>((resolve) => { acceptConnection = resolve; });
let acceptedSocket: net.Socket | null = null;
const server = net.createServer((socket) => {
acceptedSocket = socket;
acceptConnection();
});
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(socketPath, resolve);
});
const original = encodeLockInfo({
pid: process.pid,
version: '1.5.0',
socketPath,
startedAt: 1,
});
const replacement = encodeLockInfo({
pid: process.pid,
version: '1.5.0',
socketPath: replacementSocketPath,
startedAt: 2,
});
fs.mkdirSync(path.dirname(pidPath), { recursive: true });
fs.writeFileSync(pidPath, original);

try {
const clearing = clearStaleDaemonArtifacts(root);
await connected;
fs.writeFileSync(pidPath, replacement);
acceptedSocket!.end('{"protocol":0}\n');

expect(await clearing).toBe(false);
expect(fs.readFileSync(pidPath, 'utf8')).toBe(replacement);
} finally {
acceptedSocket?.destroy();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});

it('does not clean daemon artifacts while another writer owns the project', async () => {
const root = fs.mkdtempSync(path.join(tmpHome, 'writer-claim-'));
const pidPath = getDaemonPidPath(root);
const lock = encodeLockInfo({
pid: process.pid,
version: '1.5.0',
socketPath: path.join(root, '.codegraph', 'not-listening.sock'),
startedAt: 1,
});
fs.mkdirSync(path.dirname(pidPath), { recursive: true });
fs.writeFileSync(pidPath, lock);
expect(tryAcquireWriterLock(root, 'daemon').kind).toBe('acquired');

try {
expect(await clearStaleDaemonArtifacts(root)).toBe(false);
expect(fs.readFileSync(pidPath, 'utf8')).toBe(lock);
} finally {
releaseWriterLock(root);
}
});
});
42 changes: 42 additions & 0 deletions __tests__/daemon-socket-fallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { decodeLockInfo } from '../src/mcp/daemon-paths';
import {
acquireLockViaExclusiveOpen,
bindFirstUsableSocket,
clearStaleDaemonLock,
tryAcquireDaemonLock,
} from '../src/mcp/daemon';

Expand Down Expand Up @@ -244,3 +245,44 @@ describe('lock acquisition without hard links (#997)', () => {
expect(decodeLockInfo(fs.readFileSync(pidPath, 'utf8'))).toEqual(winner);
});
});

describe('legacy daemon lock decoding', () => {
it('decodes a plain decimal PID as a legacy lock record', () => {
expect(decodeLockInfo('4242\n')).toEqual({
pid: 4242,
version: 'unknown',
socketPath: '',
startedAt: 0,
});
});

it.each(['1e3', '0x3e8', '1000.0'])('rejects non-decimal PID syntax %s', (raw) => {
expect(decodeLockInfo(raw)).toBeNull();
});
});

describe('stale daemon lock snapshot validation', () => {
it('does not delete a same-PID replacement whose identity was never probed', () => {
const pidPath = path.join(os.tmpdir(), `cg-snapshot-${process.pid}-${Date.now()}.pid`);
tmpFiles.push(pidPath);
const original = JSON.stringify({
pid: process.pid,
version: '1.5.0',
socketPath: '/old.sock',
startedAt: 1,
});
const replacement = JSON.stringify({
pid: process.pid,
version: '1.5.0',
socketPath: '/new.sock',
startedAt: 2,
});
fs.writeFileSync(pidPath, replacement);

expect(clearStaleDaemonLock(pidPath, process.pid, {
allowLivePid: true,
expectedLockContents: original,
})).toBe(false);
expect(fs.readFileSync(pidPath, 'utf8')).toBe(replacement);
});
});
Loading