From fb1c57673d909192d9edad1bc7d100cb007770dc Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Mon, 5 Oct 2026 12:26:09 -0600 Subject: [PATCH 1/3] feat(runtime): expose coordinated promotion control --- .github/workflows/daemon-lifecycle.yml | 6 +- CHANGELOG.md | 2 + README.md | 11 + __tests__/fixtures/runtime-control-daemon.cjs | 15 ++ __tests__/runtime-control.test.ts | 205 ++++++++++++++++++ site/src/content/docs/reference/api.md | 46 +++- src/index.ts | 1 + src/mcp/daemon-registry.ts | 5 +- src/mcp/writer-lock.ts | 64 +++++- src/runtime-control.ts | 150 +++++++++++++ 10 files changed, 493 insertions(+), 12 deletions(-) create mode 100644 __tests__/fixtures/runtime-control-daemon.cjs create mode 100644 __tests__/runtime-control.test.ts create mode 100644 src/runtime-control.ts diff --git a/.github/workflows/daemon-lifecycle.yml b/.github/workflows/daemon-lifecycle.yml index 214a9669c1..a6e82d974e 100644 --- a/.github/workflows/daemon-lifecycle.yml +++ b/.github/workflows/daemon-lifecycle.yml @@ -4,12 +4,16 @@ on: branches: [fork/consolidated] paths: - 'src/mcp/**' + - 'src/runtime-control.ts' + - 'src/index.ts' - 'src/extraction/**' - 'src/codegraph.ts' - 'codegraph-kernel/**' - '__tests__/daemon-*.test.ts' - '__tests__/mcp-*.test.ts' - '__tests__/writer-lock.test.ts' + - '__tests__/runtime-control.test.ts' + - '__tests__/fixtures/runtime-control-daemon.cjs' - '__tests__/writer-handover-ordering.test.ts' - '__tests__/fixtures/writer-lock-ordering.cjs' - '__tests__/query-pool.test.ts' @@ -54,4 +58,4 @@ jobs: - run: npm ci --ignore-scripts - run: npm run build:kernel - run: npm run build - - run: npx vitest run __tests__/daemon-install-check.test.ts __tests__/daemon-older-version.test.ts __tests__/cli-version.test.ts __tests__/daemon-registry.test.ts __tests__/daemon-pid-reuse.test.ts __tests__/daemon-lock-refresh.test.ts __tests__/mcp-daemon.test.ts __tests__/daemon-client-liveness.test.ts __tests__/daemon-bind-failure.test.ts __tests__/daemon-socket-fallback.test.ts __tests__/daemon-rendezvous-case.test.ts __tests__/writer-lock.test.ts __tests__/writer-handover-ordering.test.ts __tests__/mcp-writer-lock.test.ts __tests__/mcp-projectpath-lifecycle.test.ts __tests__/query-pool.test.ts __tests__/status-index-health.test.ts --maxWorkers=2 --minWorkers=1 + - run: npx vitest run __tests__/runtime-control.test.ts __tests__/daemon-install-check.test.ts __tests__/daemon-older-version.test.ts __tests__/cli-version.test.ts __tests__/daemon-registry.test.ts __tests__/daemon-pid-reuse.test.ts __tests__/daemon-lock-refresh.test.ts __tests__/mcp-daemon.test.ts __tests__/daemon-client-liveness.test.ts __tests__/daemon-bind-failure.test.ts __tests__/daemon-socket-fallback.test.ts __tests__/daemon-rendezvous-case.test.ts __tests__/writer-lock.test.ts __tests__/writer-handover-ordering.test.ts __tests__/mcp-writer-lock.test.ts __tests__/mcp-projectpath-lifecycle.test.ts __tests__/query-pool.test.ts __tests__/status-index-health.test.ts --maxWorkers=2 --minWorkers=1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1759dd2257..ffbe2678eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### New Features +- Installers can verify daemon readiness and safely hand writer ownership across build promotion and rollback through a supported runtime-control API. + - `codegraph_explore` now finds quoted prose in script strings and template text through a capped source scan, ignoring case and punctuation without requiring a re-index. - After a complete scan of indexed source without skipped files, `codegraph_explore` lists query names it cannot find, so an agent can catch a guessed name. diff --git a/README.md b/README.md index 5c494744f5..a96e1fdbe3 100644 --- a/README.md +++ b/README.md @@ -837,6 +837,17 @@ Lower-level building blocks are exported from the same entry point for callers that drive the graph directly: `DatabaseConnection`, `QueryBuilder`, `getDatabasePath`, `initGrammars` / `loadGrammarsForLanguages`, and `FileLock`. +### Installer runtime control + +Installers can use the supported `dist/runtime-control.js` module without loading +the graph API. Its functions are also exported from the package entry point. +`RUNTIME_CONTROL_PROTOCOL` is `1`. The API verifies daemon identity and readiness, +reports stop outcomes, and reserves the writer slot for a live updater. A +replacement bootstrap claims that reservation before running its CLI in the same +process. Keep the coordination module available separately from the executable +being replaced, including during rollback. Live legacy writers require explicit +session quiescence before cutover. See the [runtime control contract](site/src/content/docs/reference/api.md#installer-runtime-control). + **Embedding requirements** - Install from npm (`npm i @colbymchenry/codegraph`) so the matching diff --git a/__tests__/fixtures/runtime-control-daemon.cjs b/__tests__/fixtures/runtime-control-daemon.cjs new file mode 100644 index 0000000000..69bffe0de7 --- /dev/null +++ b/__tests__/fixtures/runtime-control-daemon.cjs @@ -0,0 +1,15 @@ +const fs = require('node:fs'); +const net = require('node:net'); +const path = require('node:path'); +const [root, socketPath, protocol, writerModule] = process.argv.slice(2); +const writer = require(writerModule); +writer.tryAcquireWriterLock(root, 'daemon'); +writer.markWriterReady(root); +fs.writeFileSync(path.join(root, '.codegraph', 'daemon.pid'), JSON.stringify({pid:process.pid,version:'test-build',socketPath,startedAt:Date.now()})); +const server = net.createServer(socket => socket.end(JSON.stringify({protocol:1,pid:process.pid,codegraph:'test-build',...(protocol === 'legacy' ? {} : {writerProtocol:1})}) + '\n')); +server.listen(socketPath, () => process.send('ready')); +process.on('SIGTERM', () => { + writer.releaseWriterLock(root); + fs.rmSync(path.join(root, '.codegraph', 'daemon.pid'), {force:true}); + server.close(() => process.exit(0)); +}); diff --git a/__tests__/runtime-control.test.ts b/__tests__/runtime-control.test.ts new file mode 100644 index 0000000000..87c4b00075 --- /dev/null +++ b/__tests__/runtime-control.test.ts @@ -0,0 +1,205 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as net from 'node:net'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { spawn, spawnSync, type ChildProcess } from 'node:child_process'; +import { once } from 'node:events'; +import { + getRuntimeIdentity, checkRuntimeReady, reserveRuntimeWriter, claimRuntimeWriter, releaseRuntimeWriter, + stopRuntime, type WriterReservation, +} from '../src/runtime-control'; +import { readWriterLock, releaseWriterLock, markWriterReady, tryAcquireWriterLock } from '../src/mcp/writer-lock'; +import { ToolHandler } from '../src/mcp/tools'; +import { getDaemonPidPath, getDaemonSocketPath, encodeLockInfo } from '../src/mcp/daemon-paths'; + +let root: string; +let server: net.Server | null = null; +let statusProjectPath: unknown; +const actors: ChildProcess[] = []; +beforeEach(() => { + statusProjectPath = null; + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-runtime-')); + fs.mkdirSync(path.join(root, '.codegraph')); +}); +afterEach(async () => { + for (const actor of actors.splice(0)) { + if (actor.exitCode === null && actor.signalCode === null) { + const exited = once(actor, 'exit'); + actor.kill('SIGKILL'); + await exited; + } + } + if (server) await new Promise((resolve) => server!.close(() => resolve())); + server = null; + releaseWriterLock(root); + fs.rmSync(root, { recursive: true, force: true }); +}); + +describe('runtime writer reservations', () => { + it('hands a serialized updater reservation to a bootstrap process without freeing the slot', () => { + const reservation = reserveRuntimeWriter(root, process.pid)!; + expect(reservation).not.toBeNull(); + expect(reserveRuntimeWriter(root, process.pid)).toBeNull(); + const actor = spawnSync(process.execPath, ['-e', ` + const api = require(process.argv[1]); + const writer = require(process.argv[2]); + const root = process.argv[3], lease = JSON.parse(process.argv[4]); + if (!api.claimRuntimeWriter(root, lease)) process.exit(2); + // Both the deployed legacy CLI and current daemon accept an existing own-PID record. + const acquired = writer.tryAcquireWriterLock(root, 'daemon'); + console.log(JSON.stringify({pid:process.pid, acquired:acquired.kind, info:writer.readWriterLock(root)})); + `, path.resolve(__dirname, '../dist/runtime-control.js'), path.resolve(__dirname, '../dist/mcp/writer-lock.js'), root, JSON.stringify(reservation)], { encoding: 'utf8', timeout: 10000 }); + expect(actor.status, actor.stderr).toBe(0); + const result = JSON.parse(actor.stdout); + expect(result.acquired).toBe('acquired'); + expect(result.info).toMatchObject({ pid: result.pid, mode: 'daemon', ready: false }); + expect(releaseRuntimeWriter(root, reservation)).toBe(true); + expect(readWriterLock(root)?.pid).toBe(result.pid); + }); + + it('does not let a released lease claim or remove another reservation by the same PID', () => { + const previous = reserveRuntimeWriter(root, process.pid)!; + expect(releaseRuntimeWriter(root, previous)).toBe(true); + const next = reserveRuntimeWriter(root, process.pid)!; + expect(claimRuntimeWriter(root, previous)).toBe(false); + expect(releaseRuntimeWriter(root, previous)).toBe(true); + expect(readWriterLock(root)).toMatchObject(next); + expect(claimRuntimeWriter(root, next)).toBe(true); + markWriterReady(root); + expect(readWriterLock(root)).toMatchObject({ pid: process.pid, mode: 'daemon', ready: true }); + }); + + it('reports an unreadable reservation as incomplete release, preserving the record', () => { + const actor = spawnSync(process.execPath, ['-e', ` + const fs = require('node:fs'), path = require('node:path'); + const api = require(process.argv[1]), root = process.argv[2]; + const lease = api.reserveRuntimeWriter(root, process.pid); + const lock = path.join(root, '.codegraph', 'writer.pid'); + const original = fs.readFileSync; + const before = original(lock, 'utf8'); + fs.readFileSync = function(file, ...args) { + if (file === lock) throw Object.assign(new Error('injected read failure'), {code:'EIO'}); + return original.call(this, file, ...args); + }; + const released = api.releaseRuntimeWriter(root, lease); + fs.readFileSync = original; + console.log(JSON.stringify({released, unchanged:original(lock, 'utf8') === before})); + `, path.resolve(__dirname, '../dist/runtime-control.js'), root], { encoding: 'utf8', timeout: 10000 }); + expect(actor.status, actor.stderr).toBe(0); + expect(JSON.parse(actor.stdout)).toEqual({ released: false, unchanged: true }); + }); + + it('rejects a dead updater before creating ownership', () => { + const actor = spawnSync(process.execPath, ['-e', 'console.log(process.pid)'], { encoding: 'utf8' }); + expect(actor.status).toBe(0); + expect(() => reserveRuntimeWriter(root, Number(actor.stdout))).toThrow('live holder PID'); + expect(readWriterLock(root)).toBeNull(); + }); + + it('preserves ownership across every four-event ordering of stale release, claim, reservation and readiness', () => { + const events = ['reserve', 'claim', 'release', 'old-release', 'ready', 'fallback'] as const; + const failures: string[] = []; + let protectedSuccessors = 0; + function check(sequence: typeof events[number][]): void { + releaseWriterLock(root); + let lease: WriterReservation | null = null; + let old: WriterReservation | null = null; + for (const event of sequence) { + const before = readWriterLock(root); + if (event === 'reserve') { + const candidate = reserveRuntimeWriter(root, process.pid); + if (candidate) { if (lease) old = lease; lease = candidate; } + } else if (event === 'claim' && lease) claimRuntimeWriter(root, lease); + else if (event === 'release' && lease) { releaseRuntimeWriter(root, lease); old = lease; lease = null; } + else if (event === 'old-release' && old) releaseRuntimeWriter(root, old); + else if (event === 'ready') markWriterReady(root); + else if (event === 'fallback') tryAcquireWriterLock(root, 'fallback'); + const after = readWriterLock(root); + if (event === 'old-release' && old && before && before.mode !== old.mode) { + protectedSuccessors++; + if (JSON.stringify(before) !== JSON.stringify(after)) failures.push(sequence.join(' -> ')); + } + if (event === 'fallback' && before && after?.mode !== before.mode) failures.push(sequence.join(' -> ')); + } + } + for (const a of events) for (const b of events) for (const c of events) for (const d of events) check([a, b, c, d]); + expect(protectedSuccessors).toBeGreaterThan(0); + expect(failures).toEqual([]); + }); +}); + +async function fakeDaemon(options: { wrongHello?: boolean; wrongBuild?: boolean; statusError?: boolean; guidance?: boolean; closeEarly?: boolean; legacy?: boolean; changeRecord?: boolean } = {}): Promise { + const guidance = options.guidance ? await new ToolHandler(null).execute('codegraph_status', {}) : null; + const socketPath = getDaemonSocketPath(root); + const info = { pid: process.pid, version: 'test-build', socketPath, startedAt: Date.now() }; + fs.writeFileSync(getDaemonPidPath(root), encodeLockInfo(info)); + server = net.createServer((socket) => { + socket.setEncoding('utf8'); + socket.write(JSON.stringify({ protocol: 1, pid: process.pid, codegraph: options.wrongHello ? 'other' : 'test-build', ...(options.legacy ? {} : { writerProtocol: 1 }) }) + '\n'); + let buffer = ''; + socket.on('data', (chunk) => { + buffer += chunk; + let end: number; + while ((end = buffer.indexOf('\n')) >= 0) { + const msg = JSON.parse(buffer.slice(0, end)); buffer = buffer.slice(end + 1); + if (msg.method === 'initialize') { + if (options.closeEarly) { socket.end(); return; } + socket.write(JSON.stringify({ jsonrpc: '2.0', id: 1, result: { serverInfo: { version: options.wrongBuild ? 'other' : 'test-build' } } }) + '\n'); + } else if (msg.id === 2) { + statusProjectPath = msg.params?.arguments?.projectPath; + if (options.changeRecord) fs.writeFileSync(getDaemonPidPath(root), encodeLockInfo({ ...info, version: 'other' })); + socket.write(JSON.stringify({ jsonrpc: '2.0', id: 2, result: { isError: !!options.statusError, content: [{ type: 'text', text: options.guidance ? guidance!.content[0].text : '**CodeGraph Status**\n**Server build:** test-build\n**Files indexed:** 1\n**Total nodes:** 2\n**Total edges:** 1' }] } }) + '\n'); + } + } + }); + }); + await new Promise((resolve, reject) => { server!.once('error', reject); server!.listen(socketPath, resolve); }); +} + +describe('runtime identity and readiness', () => { + it('verifies a daemon build through hello, MCP initialization and status', async () => { + await fakeDaemon(); + expect(await getRuntimeIdentity(root)).toEqual({ pid: process.pid, version: 'test-build' }); + expect(await checkRuntimeReady(root, { pid: process.pid, version: 'test-build' }, 1000)).toEqual({ pid: process.pid, version: 'test-build' }); + }); + + it('resolves a relative caller root before sending status to a daemon with another working directory', async () => { + await fakeDaemon(); + await checkRuntimeReady(path.relative(process.cwd(), root), { pid: process.pid, version: 'test-build' }, 1000); + expect(path.isAbsolute(statusProjectPath as string)).toBe(true); + expect(fs.realpathSync.native(statusProjectPath as string)).toBe(fs.realpathSync.native(root)); + }); + + it.each(['wrongHello', 'wrongBuild', 'statusError', 'guidance', 'closeEarly', 'changeRecord'] as const)( + 'refuses readiness when %s invalidates the expected daemon', async (failure) => { + await fakeDaemon({ [failure]: true }); + await expect(checkRuntimeReady(root, { pid: process.pid, version: 'test-build' }, 1000)).rejects.toThrow(); + }, + ); + + it('preserves a legacy daemon by default, then stops it only after explicit quiescence', async () => { + const actor = spawn(process.execPath, [path.resolve(__dirname, 'fixtures/runtime-control-daemon.cjs'), + root, getDaemonSocketPath(root), 'legacy', path.resolve(__dirname, '../dist/mcp/writer-lock.js')], + { stdio: ['ignore', 'ignore', 'pipe', 'ipc'] }); + actors.push(actor); + await once(actor, 'message'); + const before = fs.readFileSync(getDaemonPidPath(root), 'utf8'); + expect(await stopRuntime(root)).toMatchObject({ pid: actor.pid, outcome: 'legacy-writer' }); + expect(actor.exitCode).toBeNull(); + expect(fs.readFileSync(getDaemonPidPath(root), 'utf8')).toBe(before); + const exited = once(actor, 'exit'); + expect(await stopRuntime(root, { legacyQuiescent: true })).toMatchObject({ pid: actor.pid, outcome: 'term' }); + await exited; + const lease = reserveRuntimeWriter(root, process.pid); + expect(lease).not.toBeNull(); + expect(readWriterLock(root)).toMatchObject(lease!); + }); + + it('preserves an unverified live daemon rather than authorizing an artifact swap', async () => { + await fakeDaemon({ wrongHello: true }); + const before = fs.readFileSync(getDaemonPidPath(root), 'utf8'); + expect(await stopRuntime(root)).toMatchObject({ pid: process.pid, outcome: 'unverified' }); + expect(fs.readFileSync(getDaemonPidPath(root), 'utf8')).toBe(before); + }); +}); diff --git a/site/src/content/docs/reference/api.md b/site/src/content/docs/reference/api.md index 86a199eb65..204ebd8bcd 100644 --- a/site/src/content/docs/reference/api.md +++ b/site/src/content/docs/reference/api.md @@ -3,7 +3,7 @@ title: API description: Use CodeGraph as a TypeScript library. --- -CodeGraph ships a TypeScript API. The public surface is the `CodeGraph` class. +CodeGraph ships a TypeScript API. The package exports the `CodeGraph` class, storage primitives and installer runtime control. ```typescript import CodeGraph from '@colbymchenry/codegraph'; @@ -68,3 +68,47 @@ import { - **Install from npm** (`npm i @colbymchenry/codegraph`) so the matching per-platform package — which carries the compiled library — is fetched alongside the shim. - The API runs on **your** runtime, so it needs **Node 22.13+** or **Bun 1.4.0+** for the built-in `node:sqlite` module (an Electron main process qualifies when its bundled Node is 22.13+). The CLI and MCP server are unaffected — they ship with a self-contained bundled runtime and need no Node at all. - TypeScript types ship with the package. Keep `@types/node` available and `skipLibCheck: true` (the common default). + +## Installer runtime control + +Installers may load the supported `dist/runtime-control.js` module from a +validated build. The same functions are exported from the package entry point. +`RUNTIME_CONTROL_PROTOCOL` is `1`. + +| Function | Result | +|---|---| +| `getRuntimeIdentity(root)` | Hello-verified `{pid, version}`, or `null` | +| `stopRuntime(root, options?)` | `{root, pid, outcome, version?}` with verified signalling | +| `reserveRuntimeWriter(root, holderPid)` | Opaque JSON reservation for a live updater, or `null` when the slot is occupied | +| `claimRuntimeWriter(root, reservation)` | `true` when this bootstrap process claims that exact reservation | +| `releaseRuntimeWriter(root, reservation)` | `true` when the reservation is gone; preserves successor ownership | +| `checkRuntimeReady(root, identity, timeoutMs?)` | The expected identity after hello, MCP initialization and status verification | + +Only `term`, `kill`, `not-running` and `no-daemon` stop outcomes allow a +reservation attempt. `unverified`, `still-running` and `legacy-writer` require +standing down. A successful stop does not itself reserve the slot. Reserve +before moving artifacts, and stand down if another writer has claimed it. + +By default, stop preserves a live daemon without writer coordination protocol +`1`. Set `{legacyQuiescent: true}` only after confirming all legacy MCP sessions +and direct or fallback writers for that project are stopped. Daemon death alone +does not establish that quiescence. A restored legacy daemon needs the same +explicit cutover before another promotion. + +Keep the reservation value unchanged. Its generation distinguishes successive +reservations held by one PID. Claim it in the daemon bootstrap, then run the +selected CLI entry in that same process. Spawning another CLI child after claim +would hand ownership to the wrong PID. The bootstrap must use the validated +coordination module even when rollback selects an older CLI. + +Keep `coordinationDir` separate from `executableDir`. Retain the coordination +build and its native kernel until promotion or rollback has finished. Host +process launch, service limits, artifact swaps and rollback policy remain with +the installer. Never reconstruct writer-file mutations in the adapter. + +Release is idempotent and preserves a successor. A `false` result means release +could not complete; retry within a bounded deadline and report failure rather +than claiming cleanup succeeded. For example, retry every 100 ms for five +seconds. Do not move artifacts or start another replacement after failed claim, +unverified identity or failed readiness. Readiness rejects a changed build, +failed status, expected-condition guidance, closed socket or timeout; its default timeout is 120 seconds. diff --git a/src/index.ts b/src/index.ts index 90db2883e6..d13faa9cc4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,6 +41,7 @@ export { export { Mutex, FileLock, processInBatches, debounce, throttle, MemoryMonitor } from './utils'; export { FileWatcher, WatchOptions, PendingFile, LockUnavailableError } from './sync'; export { MCPServer } from './mcp'; +export * from './runtime-control'; import * as extractionStack from './extraction'; import * as resolutionStack from './resolution'; import * as syncStack from './sync'; diff --git a/src/mcp/daemon-registry.ts b/src/mcp/daemon-registry.ts index e9f3ba681f..28b6ab87b3 100644 --- a/src/mcp/daemon-registry.ts +++ b/src/mcp/daemon-registry.ts @@ -268,7 +268,7 @@ export interface StopResult { */ export async function stopDaemonAt( root: string, - options: { preserveUnverified?: boolean; shutdownGraceMs?: number } = {}, + options: { preserveUnverified?: boolean; shutdownGraceMs?: number; requireWriterProtocol?: boolean } = {}, ): Promise { let pid: number | null = null; let identity: DaemonLockInfo | null = null; @@ -306,6 +306,9 @@ export async function stopDaemonAt( const removed = cleanupDaemonArtifacts(root, lockContents); return { root, pid, outcome: removed ? 'not-running' : 'unverified' }; } + if (options.requireWriterProtocol && !await probeDaemonIdentity(identity, undefined, true)) { + return { root, pid, version: identity.version, outcome: 'legacy-writer' }; + } return stopVerifiedDaemon(root, identity, lockContents, options.shutdownGraceMs); } diff --git a/src/mcp/writer-lock.ts b/src/mcp/writer-lock.ts index 0e15880301..f545a8fc55 100644 --- a/src/mcp/writer-lock.ts +++ b/src/mcp/writer-lock.ts @@ -15,6 +15,7 @@ import * as fs from 'fs'; import * as path from 'path'; +import { randomUUID } from 'crypto'; import { getCodeGraphDir } from '../directory'; import { requireKernel } from '../extraction/kernel/loader'; /** Signal-0 liveness (EPERM ⇒ alive). Local copy to avoid a daemon↔writer cycle. */ @@ -115,6 +116,27 @@ export function tryAcquireWriterLock( projectRoot: string, mode: string, lockName: 'writer.pid' | 'rebuild.pid' = 'writer.pid', +): WriterAcquireResult { + return acquireWriterRecord(projectRoot, { + pid: process.pid, mode, startedAt: Date.now(), ready: false, + }, lockName); +} + +/** Reserve for a live external updater. A unique mode distinguishes repeated leases by one PID. */ +export function reserveWriterLock(projectRoot: string, holderPid: number): WriterAcquireResult { + if (!Number.isSafeInteger(holderPid) || holderPid <= 0 || !isProcessAlive(holderPid)) { + throw new Error('Writer reservation requires a live holder PID.'); + } + return acquireWriterRecord(projectRoot, { + pid: holderPid, mode: `promotion:${randomUUID()}`, startedAt: Date.now(), ready: false, + }, 'writer.pid', false); +} + +function acquireWriterRecord( + projectRoot: string, + info: WriterLockInfo, + lockName: 'writer.pid' | 'rebuild.pid', + reuseOwnRecord = true, ): WriterAcquireResult { const pidPath = getWriterPidPath(projectRoot, lockName); fs.mkdirSync(path.dirname(pidPath), { recursive: true }); @@ -122,12 +144,7 @@ export function tryAcquireWriterLock( const mutation = lockWriterMutation(pidPath); if (!mutation) return { kind: 'taken', existing: readWriterLock(projectRoot, lockName), pidPath }; try { - const info: WriterLockInfo = { - pid: process.pid, - mode, - startedAt: Date.now(), - ready: false, - }; + if (!isProcessAlive(info.pid)) return { kind: 'taken', existing: readWriterLock(projectRoot, lockName), pidPath }; const attempt = (): WriterAcquireResult => { const tmp = `${pidPath}.${process.pid}.tmp`; @@ -173,7 +190,7 @@ export function tryAcquireWriterLock( }; let result = attempt(); - if (result.kind === 'taken' && result.existing && result.existing.pid === process.pid) { + if (reuseOwnRecord && result.kind === 'taken' && result.existing && result.existing.pid === process.pid) { // Same process already holds it (daemon acquired before engine watch). cancelPendingRelease(pidPath); return { kind: 'acquired', pidPath: result.pidPath, info: result.existing }; @@ -264,13 +281,42 @@ const SWAP_RETRY_DELAYS_MS = [25, 50, 100, 200]; * section. Callers check whether the record was replaced. Never throws. */ export function swapWriterLock(projectRoot: string, fromPid: number, next: WriterLockInfo): boolean { + return updateWriterLock(projectRoot, fromPid, next); +} + +/** Compare a reservation generation before transfer or release; old leases cannot affect successors. */ +export function updateWriterLock( + projectRoot: string, + expected: number | WriterLockInfo, + next: WriterLockInfo | null, +): boolean { const pidPath = getWriterPidPath(projectRoot); const tmp = `${pidPath}.${process.pid}.swap.tmp`; let mutation: { release(): void } | null = null; try { mutation = lockWriterMutation(pidPath); if (!mutation) return false; - if (readWriterLock(projectRoot)?.pid !== fromPid) return false; + const matches = (): boolean => { + let current: WriterLockInfo | null; + try { + current = decodeWriterLockInfo(fs.readFileSync(pidPath, 'utf8')); + if (!current) throw new Error('Writer ownership record is invalid.'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; + throw error; + } + return typeof expected === 'number' + ? current?.pid === expected + : current?.pid === expected.pid && current.mode === expected.mode && current.startedAt === expected.startedAt; + }; + if (!matches()) return next === null; + if (typeof expected !== 'number' && next && !isProcessAlive(expected.pid)) return false; + if (!next) { + fs.unlinkSync(pidPath); + cancelPendingReadiness(pidPath); + cancelPendingRelease(pidPath); + return true; + } fs.writeFileSync(tmp, encode(next), { mode: 0o600 }); for (let attempt = 0; ; attempt++) { try { @@ -285,7 +331,7 @@ export function swapWriterLock(projectRoot: string, fromPid: number, next: Write return false; } Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay); - if (readWriterLock(projectRoot)?.pid !== fromPid) return false; + if (!matches()) return false; } } } catch { diff --git a/src/runtime-control.ts b/src/runtime-control.ts new file mode 100644 index 0000000000..083d09886a --- /dev/null +++ b/src/runtime-control.ts @@ -0,0 +1,150 @@ +/** Supported daemon control for installers. Process launch and artifact swaps belong to the caller. */ +import * as fs from 'fs'; +import * as net from 'net'; +import { canonicalProjectRoot } from './directory'; +import { getDaemonPidPath, decodeLockInfo, probeDaemonIdentity } from './mcp/daemon-paths'; +import { isProcessAlive, stopDaemonAt, type StopResult } from './mcp/daemon-registry'; +import { reserveWriterLock, updateWriterLock, type WriterLockInfo } from './mcp/writer-lock'; + +export const RUNTIME_CONTROL_PROTOCOL = 1; +export interface RuntimeIdentity { pid: number; version: string } +/** Opaque, JSON-serializable reservation. Retain the exact value until claim or release. */ +export interface WriterReservation { pid: number; mode: string; startedAt: number } + +export async function getRuntimeIdentity(projectRoot: string): Promise { + projectRoot = canonicalProjectRoot(projectRoot); + let raw: string; + try { raw = fs.readFileSync(getDaemonPidPath(projectRoot), 'utf8'); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + } + const info = decodeLockInfo(raw); + if (!info || !await probeDaemonIdentity(info)) return null; + // A probe awaits I/O; a successor may have replaced the record meanwhile. + try { + if (fs.readFileSync(getDaemonPidPath(projectRoot), 'utf8') !== raw) return null; + } catch { return null; } + return { pid: info.pid, version: info.version }; +} + +/** Refuse live uncoordinated writers unless the caller has explicitly quiesced legacy sessions. */ +export async function stopRuntime( + projectRoot: string, + options: { legacyQuiescent?: boolean } = {}, +): Promise { + projectRoot = canonicalProjectRoot(projectRoot); + return stopDaemonAt(projectRoot, { + preserveUnverified: true, requireWriterProtocol: !options.legacyQuiescent, + }); +} + +/** Claim a free or stale slot for a live updater, after terminal stop and legacy-session quiescence. */ +export function reserveRuntimeWriter(projectRoot: string, holderPid: number): WriterReservation | null { + projectRoot = canonicalProjectRoot(projectRoot); + const result = reserveWriterLock(projectRoot, holderPid); + if (result.kind !== 'acquired') return null; + const { pid, mode, startedAt } = result.info; + return { pid, mode, startedAt }; +} + +function validReservation(value: WriterReservation): boolean { + return Number.isSafeInteger(value.pid) && value.pid > 0 + && typeof value.mode === 'string' && /^promotion:[0-9a-f-]{36}$/.test(value.mode) + && Number.isSafeInteger(value.startedAt) && value.startedAt > 0; +} + +/** Run in the replacement daemon bootstrap, then execute the selected CLI in this same process. */ +export function claimRuntimeWriter(projectRoot: string, reservation: WriterReservation): boolean { + projectRoot = canonicalProjectRoot(projectRoot); + if (!validReservation(reservation) || !isProcessAlive(reservation.pid)) return false; + const next: WriterLockInfo = { pid: process.pid, mode: 'daemon', startedAt: Date.now(), ready: false }; + return updateWriterLock(projectRoot, reservation, next); +} + +/** Idempotently retire a reservation, preserving successors. Retry false before ending the updater. */ +export function releaseRuntimeWriter(projectRoot: string, reservation: WriterReservation): boolean { + projectRoot = canonicalProjectRoot(projectRoot); + return validReservation(reservation) && updateWriterLock(projectRoot, reservation, null); +} + +/** Verify the hello, MCP build and successful status response of an already discovered daemon. */ +export async function checkRuntimeReady( + projectRoot: string, + expected: RuntimeIdentity, + timeoutMs = 120_000, +): Promise { + projectRoot = canonicalProjectRoot(projectRoot); + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error('Readiness timeout must be positive.'); + const raw = fs.readFileSync(getDaemonPidPath(projectRoot), 'utf8'); + const info = decodeLockInfo(raw); + if (!info || info.pid !== expected.pid || info.version !== expected.version) { + throw new Error('Daemon identity changed before readiness.'); + } + await new Promise((resolve, reject) => { + const socket = net.createConnection(info.socketPath); + let buffer = ''; + let phase = 'hello'; + let done = false; + const finish = (error?: Error): void => { + if (done) return; + done = true; + clearTimeout(timer); + socket.destroy(); + if (error) reject(error); else resolve(); + }; + const timer = setTimeout(() => finish(new Error('MCP readiness timed out.')), timeoutMs); + socket.setEncoding('utf8'); + socket.on('error', finish); + socket.on('close', () => finish(new Error('Daemon closed before readiness.'))); + socket.on('data', (chunk) => { + buffer += String(chunk); + if (buffer.length > 1024 * 1024) return finish(new Error('MCP readiness response exceeds the limit.')); + let end: number; + while (!done && (end = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, end); + buffer = buffer.slice(end + 1); + let msg; + try { msg = JSON.parse(line); } + catch { return finish(new Error('Invalid MCP readiness response.')); } + if (!msg || typeof msg !== 'object') return finish(new Error('Invalid MCP readiness response.')); + if (phase === 'hello') { + if (msg.protocol !== 1 || msg.pid !== expected.pid || msg.codegraph !== expected.version) { + return finish(new Error('Daemon identity changed during readiness.')); + } + phase = 'initialize'; + socket.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { + protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'codegraph-runtime-control', version: '1' }, + } }) + '\n'); + } else if (phase === 'initialize' && msg.id === 1) { + if (msg.error || msg.result?.serverInfo?.version !== expected.version) { + return finish(new Error('MCP readiness build mismatch.')); + } + phase = 'status'; + socket.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n'); + socket.write(JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { + name: 'codegraph_status', arguments: { projectPath: projectRoot }, + } }) + '\n'); + } else if (phase === 'status' && msg.id === 2) { + const text = Array.isArray(msg.result?.content) + ? msg.result.content.filter((item: { type?: string; text?: unknown }) => item?.type === 'text' && typeof item.text === 'string') + .map((item: { text: string }) => item.text).join('\n') : ''; + // Expected-condition guidance is success-shaped too. Require the real + // project status body, not merely a successful RPC envelope. + const status = text.includes('**CodeGraph Status**') + && text.includes(`**Server build:** ${expected.version}`) + && /^\*\*Files indexed:\*\* \d+$/m.test(text) + && /^\*\*Total nodes:\*\* \d+$/m.test(text) + && /^\*\*Total edges:\*\* \d+$/m.test(text); + finish(msg.error || msg.result?.isError || !status + ? new Error('MCP status failed readiness.') : undefined); + } + } + }); + }); + const after = await getRuntimeIdentity(projectRoot); + if (after?.pid !== expected.pid || after.version !== expected.version) { + throw new Error('Daemon identity changed after readiness.'); + } + return expected; +} From 87ccfd8dbfd9a7f16d8780d3790592701a485903 Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Mon, 5 Oct 2026 12:42:07 -0600 Subject: [PATCH 2/3] test(runtime): canonicalize read-failure injection across platforms --- __tests__/runtime-control.test.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/__tests__/runtime-control.test.ts b/__tests__/runtime-control.test.ts index 87c4b00075..42ebcdecfe 100644 --- a/__tests__/runtime-control.test.ts +++ b/__tests__/runtime-control.test.ts @@ -71,23 +71,31 @@ describe('runtime writer reservations', () => { }); it('reports an unreadable reservation as incomplete release, preserving the record', () => { + const alias = path.join(root, 'alias'); + fs.symlinkSync(root, alias, 'junction'); const actor = spawnSync(process.execPath, ['-e', ` const fs = require('node:fs'), path = require('node:path'); const api = require(process.argv[1]), root = process.argv[2]; const lease = api.reserveRuntimeWriter(root, process.pid); - const lock = path.join(root, '.codegraph', 'writer.pid'); + const lock = fs.realpathSync(path.join(root, '.codegraph', 'writer.pid')); const original = fs.readFileSync; const before = original(lock, 'utf8'); + let injectedReads = 0; fs.readFileSync = function(file, ...args) { - if (file === lock) throw Object.assign(new Error('injected read failure'), {code:'EIO'}); + if (file === lock) { + injectedReads++; + throw Object.assign(new Error('injected read failure'), {code:'EIO'}); + } return original.call(this, file, ...args); }; const released = api.releaseRuntimeWriter(root, lease); fs.readFileSync = original; - console.log(JSON.stringify({released, unchanged:original(lock, 'utf8') === before})); - `, path.resolve(__dirname, '../dist/runtime-control.js'), root], { encoding: 'utf8', timeout: 10000 }); + console.log(JSON.stringify({released, injectedReads, unchanged:original(lock, 'utf8') === before})); + `, path.resolve(__dirname, '../dist/runtime-control.js'), alias], { encoding: 'utf8', timeout: 10000 }); expect(actor.status, actor.stderr).toBe(0); - expect(JSON.parse(actor.stdout)).toEqual({ released: false, unchanged: true }); + const { injectedReads, ...result } = JSON.parse(actor.stdout); + expect(result).toEqual({ released: false, unchanged: true }); + expect(injectedReads).toBe(1); }); it('rejects a dead updater before creating ownership', () => { From b2beb1c0ec7c890604355f912df53a6dff8e455a Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Mon, 5 Oct 2026 12:56:04 -0600 Subject: [PATCH 3/3] test(runtime): resolve injected Windows paths with native realpath --- __tests__/runtime-control.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/__tests__/runtime-control.test.ts b/__tests__/runtime-control.test.ts index 42ebcdecfe..be9c3bec07 100644 --- a/__tests__/runtime-control.test.ts +++ b/__tests__/runtime-control.test.ts @@ -77,7 +77,7 @@ describe('runtime writer reservations', () => { const fs = require('node:fs'), path = require('node:path'); const api = require(process.argv[1]), root = process.argv[2]; const lease = api.reserveRuntimeWriter(root, process.pid); - const lock = fs.realpathSync(path.join(root, '.codegraph', 'writer.pid')); + const lock = fs.realpathSync.native(path.join(root, '.codegraph', 'writer.pid')); const original = fs.readFileSync; const before = original(lock, 'utf8'); let injectedReads = 0;