Files
codegraph/__tests__/daemon-install-check.test.ts
Colby Mchenry dea076fd1e fix(mcp,extraction): exit a daemon whose install changed; never store a grammar-load failure (#2335, #2336) (#2346)
* fix(mcp,extraction): a daemon whose install changed exits; grammar-load failures are never stored (#2335, #2336)

A daemon that outlived an upgrade loaded grammars lazily from an install
that no longer existed, and stored every file its watcher re-indexed as a
zero-node row under the file's new content hash: no hash-based sync ever
revisited those rows, and `codegraph status` called the index up to date.

- The daemon checks its own package.json every 30 s
  (CODEGRAPH_DAEMON_INSTALL_CHECK_MS, 0 turns it off) and exits once the
  file is gone or carries another version, so the next session starts a
  daemon from the current install.
- A result whose grammar failed to load (`parser_error`) is never stored:
  the file keeps its previous data and the next sync or index retries it,
  and a row an older engine stored that way is re-indexed even though its
  hash matches. The tag-based CFML path reports a missing grammar with the
  same code.
- A launcher whose own install was deleted no longer crashes on the spawn
  error of the daemon it tried to start.
- `codegraph status` and `status --json` (filesNeedingReindex,
  filesWithParseErrors) report indexed files whose symbols are missing
  although their content is current (#2336), and `files --json` carries
  each file's recorded errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(mcp): a session whose own install changed never claims the writer lock (#2335)

A daemon now exits once its install is upgraded or removed, and nobody
holds the writer slot for a successor at that point. Its sessions run the
same replaced code and serve themselves in-process the moment it goes; one
that found the slot free claimed it as its writer even though its engine
could not load (a real npm upgrade: "Cannot find module
'./sqlite-adapter'"), and so kept a daemon from the current install from
starting. Such a session now falls back read-only and says to restart it.

Also adds the CHANGELOG entries for the install check and grammar-load
failures (#2335) and for the status report (#2336).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:08:14 +00:00

84 lines
3.1 KiB
TypeScript

/**
* A daemon whose install was upgraded or deleted underneath it exits (#2335).
*
* The daemon loads grammars (and the native kernel, and worker scripts) lazily
* from the directory it was started from. An upgrade replaces or deletes that
* directory while the daemon keeps running, so every later load fails; the
* 1.6.1 daemon in #2335 went on re-indexing files as empty for as long as it
* lived. It now checks its own package.json periodically and makes way for a
* daemon started from the current install. The check is pure and the Daemon's
* reaction is tested with it injected; neither starts a real daemon.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { Daemon, installChangedReason } from '../src/mcp/daemon';
describe('installChangedReason', () => {
let dir: string;
let pkg: string;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-install-check-'));
pkg = path.join(dir, 'package.json');
fs.writeFileSync(pkg, JSON.stringify({ name: '@colbymchenry/codegraph', version: '1.6.2' }));
});
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
it('is null while the install still carries the running version', () => {
expect(installChangedReason(pkg, '1.6.2')).toBeNull();
});
it('reports an install upgraded in place', () => {
fs.writeFileSync(pkg, JSON.stringify({ version: '1.6.3' }));
expect(installChangedReason(pkg, '1.6.2')).toMatch(/replaced by v1\.6\.3/);
});
it('reports an install that was deleted, file or whole directory', () => {
fs.rmSync(pkg);
expect(installChangedReason(pkg, '1.6.2')).toMatch(/removed/);
fs.rmSync(dir, { recursive: true, force: true });
expect(installChangedReason(pkg, '1.6.2')).toMatch(/removed/);
});
it('waits for a package.json caught mid-write instead of exiting on it', () => {
fs.writeFileSync(pkg, '{"name": "@colbymchenry/codeg');
expect(installChangedReason(pkg, '1.6.2')).toBeNull();
fs.writeFileSync(pkg, '');
expect(installChangedReason(pkg, '1.6.2')).toBeNull();
});
});
describe('Daemon.checkInstall', () => {
// A Daemon is safe to construct (nothing binds until start()); stop() is
// replaced so a positive check can't tear down the test process.
const makeDaemon = () => {
const d = new Daemon('/tmp/codegraph-install-check-unit-test', { idleTimeoutMs: 0 }) as any;
d.stop = vi.fn(async () => {});
return d;
};
it('stops the daemon when its install changed', () => {
const d = makeDaemon();
expect(d.checkInstall(() => 'Install removed')).toBe(true);
expect(d.stop).toHaveBeenCalledWith('install changed');
});
it('keeps running while the install is unchanged', () => {
const d = makeDaemon();
expect(d.checkInstall(() => null)).toBe(false);
expect(d.stop).not.toHaveBeenCalled();
});
it('does nothing once the daemon is already stopping', () => {
const d = makeDaemon();
d.stopping = true;
expect(d.checkInstall(() => 'Install removed')).toBe(false);
expect(d.stop).not.toHaveBeenCalled();
});
});