mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-19 06:22:38 +08:00
70fe0ecc52
On Windows a worker thread that ends - terminate(), its own process.exit(), or the process exiting - while V8's concurrent marker is marking its heap crashes the whole process (exit 3221225477 / 0xC0000005, no error, no dump). Bisected on the Windows 11 ARM64 VM: --no-concurrent-marking alone stops it (18/480 -> 0/480; no other concurrency flag does), but it costs about a third of indexing speed, so CodeGraph avoids the dangerous moments instead: - src/worker-teardown.ts: owners terminate a worker only once it has started (its first message, posted after its modules load - mid-load is by far the worst moment), and a worker about to exit runs a full collection first, which finishes any marking in flight (gc reached at runtime, never global). - Resolver pool / store writer: the worker collects before exiting on 'close' (the normal path - main 21/480 crashed on the VM, now 2/1200), and the 5 s close fallback never terminates a worker still starting. - codegraph_status change counts: the worker posts 'loaded' after its heavy require and collects before answering; a deadline answers unknown at once but terminates only once loaded (3/720 -> 0/1440), and MCPEngine.stop() waits for measurements in flight. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
132 lines
5.7 KiB
TypeScript
132 lines
5.7 KiB
TypeScript
/**
|
|
* On Windows, a worker thread that ends while V8's concurrent marker is
|
|
* marking its heap can crash the whole process (0xC0000005, no error, no
|
|
* dump) — most of all while it is still loading its modules. Owners wait for a
|
|
* worker's first message before terminating it, and workers collect garbage
|
|
* before exiting (worker-teardown.ts). These pin both halves, then end real
|
|
* resolver and store workers right after starting them and record whether
|
|
* each had started when it was terminated.
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { EventEmitter } from 'events';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { Worker } from 'worker_threads';
|
|
import CodeGraph from '../src/index';
|
|
import { terminateOnceStarted, workerStarted } from '../src/worker-teardown';
|
|
|
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
class FakeWorker extends EventEmitter {
|
|
terminate = vi.fn(async () => 0);
|
|
}
|
|
|
|
describe('terminating a worker once it has started', () => {
|
|
it('waits for the first message', async () => {
|
|
const w = new FakeWorker();
|
|
const started = workerStarted(w as unknown as Worker);
|
|
const ending = terminateOnceStarted(w, started);
|
|
await sleep(30);
|
|
expect(w.terminate).not.toHaveBeenCalled();
|
|
w.emit('message', { type: 'ready' });
|
|
await ending;
|
|
expect(w.terminate).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('counts an error or an exit as started', async () => {
|
|
for (const event of ['error', 'exit'] as const) {
|
|
const w = new FakeWorker();
|
|
w.on('error', () => {});
|
|
const started = workerStarted(w as unknown as Worker);
|
|
w.emit(event, event === 'error' ? new Error('boom') : 1);
|
|
await terminateOnceStarted(w, started);
|
|
expect(w.terminate).toHaveBeenCalledOnce();
|
|
}
|
|
});
|
|
|
|
it('stops waiting after the cap, and never rejects', async () => {
|
|
const w = new FakeWorker();
|
|
w.terminate.mockRejectedValue(new Error('already gone'));
|
|
const t0 = Date.now();
|
|
await terminateOnceStarted(w, new Promise<void>(() => {}), 60);
|
|
expect(w.terminate).toHaveBeenCalledOnce();
|
|
expect(Date.now() - t0).toBeGreaterThanOrEqual(55);
|
|
});
|
|
});
|
|
|
|
describe('collecting garbage before a worker exits', () => {
|
|
it('runs a full collection in a real worker, and leaves no global gc behind', async () => {
|
|
const teardown = path.resolve(__dirname, '../dist/worker-teardown.js');
|
|
const run = (code: string) => new Promise<unknown>((resolve, reject) => {
|
|
const w = new Worker(code, { eval: true });
|
|
w.once('message', resolve);
|
|
w.once('error', reject);
|
|
});
|
|
const ran = await run(`
|
|
const { collectBeforeExit } = require(${JSON.stringify(teardown)});
|
|
let junk = Array.from({ length: 200000 }, (_, i) => ({ i, s: 'x'.repeat(32) }));
|
|
junk = null;
|
|
const before = process.memoryUsage().heapUsed;
|
|
const collected = collectBeforeExit();
|
|
const after = process.memoryUsage().heapUsed;
|
|
require('worker_threads').parentPort.postMessage({ collected, freed: before - after, gc: typeof globalThis.gc });
|
|
`) as { collected: boolean; freed: number; gc: string };
|
|
expect(ran.collected).toBe(true);
|
|
expect(ran.freed).toBeGreaterThan(0);
|
|
expect(ran.gc).toBe('undefined');
|
|
// A worker created afterwards doesn't get a global gc either.
|
|
expect(await run(`require('worker_threads').parentPort.postMessage(typeof globalThis.gc)`)).toBe('undefined');
|
|
}, 30_000);
|
|
});
|
|
|
|
describe('real workers are never terminated before they have started', () => {
|
|
let root: string;
|
|
let dbPath: string;
|
|
const seen = new WeakSet<object>();
|
|
const startedWhenEnded: boolean[] = [];
|
|
|
|
beforeEach(async () => {
|
|
root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-worker-start-')));
|
|
fs.writeFileSync(path.join(root, 'app.ts'), 'export function alpha() { return beta(); }\nexport function beta() { return 1; }\n');
|
|
const cg = await CodeGraph.init(root);
|
|
try { await cg.indexAll(); } finally { cg.close(); }
|
|
dbPath = path.join(root, '.codegraph', 'codegraph.db');
|
|
startedWhenEnded.length = 0;
|
|
// A worker re-emits each message it posts; note which have, and what each
|
|
// had done by the time it was terminated.
|
|
const emit = Worker.prototype.emit;
|
|
vi.spyOn(Worker.prototype, 'emit').mockImplementation(function (this: Worker, event: string | symbol, ...args: unknown[]) {
|
|
if (event === 'message') seen.add(this);
|
|
return emit.call(this, event, ...args);
|
|
});
|
|
const terminate = Worker.prototype.terminate;
|
|
vi.spyOn(Worker.prototype, 'terminate').mockImplementation(function (this: Worker) {
|
|
startedWhenEnded.push(seen.has(this));
|
|
return terminate.call(this);
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
vi.unstubAllEnvs();
|
|
fs.rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
|
});
|
|
|
|
it('the resolver pool, torn down while its workers boot', async () => {
|
|
vi.stubEnv('CODEGRAPH_RESOLVE_WORKERS', '2');
|
|
const { ResolverPool } = require('../dist/resolution/resolver-pool') as typeof import('../src/resolution/resolver-pool');
|
|
const pool = ResolverPool.tryCreate(dbPath, root);
|
|
expect(pool).not.toBeNull();
|
|
await pool!.destroy(0); // the close fallback fires at once, mid-boot
|
|
expect(startedWhenEnded).toEqual([true, true]);
|
|
}, 30_000);
|
|
|
|
it('the store writer, closed while its worker boots', async () => {
|
|
const { StoreWriter } = require('../dist/extraction/store-writer') as typeof import('../src/extraction/store-writer');
|
|
const writer = new StoreWriter(path.resolve(__dirname, '../dist/extraction/store-worker.js'), dbPath, false);
|
|
await writer.close(0);
|
|
expect(startedWhenEnded.every(Boolean)).toBe(true);
|
|
}, 30_000);
|
|
});
|