From 9ab6959d26fc8b1deddecbb136f08e109505e055 Mon Sep 17 00:00:00 2001 From: Forge Date: Tue, 22 Sep 2026 03:49:35 +0000 Subject: [PATCH] Keep legacy graph owners protected and root reads lightweight --- scripts/validation/extensions-recovery.cjs | 6 ++++++ src/index.ts | 3 +-- src/plugins/recovery.ts | 16 ++++++++++++++-- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/scripts/validation/extensions-recovery.cjs b/scripts/validation/extensions-recovery.cjs index 72d5d6775..81625d5a8 100644 --- a/scripts/validation/extensions-recovery.cjs +++ b/scripts/validation/extensions-recovery.cjs @@ -183,6 +183,12 @@ if (process.argv[2] === '--child') { await run(conflict); clean(conflict); assert.equal(JSON.parse(config(conflict)).resolvedByUser, true); receipt({ action: 'conflicting plugin edit', boundary, decision: 'no overwrite; actionable repair then recovery', passed: true }); } + const graphLocked = await project(true), graphLock = path.join(graphLocked, '.codegraph/codegraph.lock'); + fs.writeFileSync(graphLock, String(process.pid)); fs.utimesSync(graphLock, 1, 1); + assert.match((await run(graphLocked, 'update', 1)).stderr, /legacy graph owner/); + assert.equal(fs.readFileSync(graphLock, 'utf8'), String(process.pid)); fs.unlinkSync(graphLock); + fs.writeFileSync(graphLock, '{'); assert.match((await run(graphLocked, 'index', 1)).stderr, /unknown legacy graph lock/); fs.unlinkSync(graphLock); + receipt({ action: 'live aged/torn legacy graph lock', decision: 'no age-based stealing or guessing', passed: true }); const settings = await project(true); await killAt(settings, 'update', 'config_written'); const changedSettings = JSON.parse(config(settings)); changedSettings.exclude = ['handlers.py']; const changedRaw = JSON.stringify(changedSettings); fs.writeFileSync(path.join(settings, 'codegraph.json'), changedRaw); diff --git a/src/index.ts b/src/index.ts index 4a0c48089..2d4832df5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -274,7 +274,7 @@ export class CodeGraph { * file can't be unlinked there, and st_ino is unreliable). */ reopenIfReplaced(): boolean { - this.ensureExtensionState(); + recoverExtensions(this.projectRoot); if (!this.db.isReplacedOnDisk()) return false; const dbPath = this.db.getPath(); // Open the live file FIRST — if that throws (e.g. mid-recreate), the old @@ -503,7 +503,6 @@ export class CodeGraph { * Get the project root directory */ getProjectRoot(): string { - this.ensureExtensionState(); return this.projectRoot; } diff --git a/src/plugins/recovery.ts b/src/plugins/recovery.ts index cba8cf404..e0badd7a2 100644 --- a/src/plugins/recovery.ts +++ b/src/plugins/recovery.ts @@ -101,6 +101,17 @@ function checkOwner(root: string, journal?: RecordV1): void { } fail(root, 'torn/unknown owner record without a valid transaction; confirm no legacy installer is running before archiving the owner record'); } +function checkGraphOwner(root: string, transactionPid?: number): void { + const lock = path.join(root, '.codegraph', 'codegraph.lock'), raw = read(lock)?.trim(); + if (raw === undefined) return; + if (transactionPid && raw === String(transactionPid)) return; // orphan proved by the coordinator + if (/^[1-9]\d*$/.test(raw)) { + try { process.kill(Number(raw), 0); } + catch (error) { if ((error as NodeJS.ErrnoException).code === 'ESRCH') { remove(lock); return; } } + fail(root, `legacy graph owner ${raw} may be live (or PID reused); wait for it to finish before retrying, regardless of lock age`); + } + fail(root, 'unknown legacy graph lock; confirm its owner has stopped before archiving codegraph.lock'); +} function marker(root: string, graphExisted: boolean): string | null { const file = path.join(root, '.codegraph', 'codegraph.db'); if (!fs.existsSync(file)) return null; @@ -153,6 +164,7 @@ function verifyPackages(root: string, config: string | null, trust: string | nul function reconcile(root: string): boolean { const r = loadRecord(root); checkOwner(root, r); if (!r) { remove(ownerPath(root)); return false; } + checkGraphOwner(root, r.pid); const committed = marker(root, r.graphExisted) === r.id; const configFile = path.join(root, 'codegraph.json'), trustFile = path.join(dir(root), 'trust.json'); const configRaw = read(configFile), trustRaw = read(trustFile); @@ -202,7 +214,7 @@ export function withExtensionGuardSync(root: string, work: () => T): T { root = fs.realpathSync(root); if (context.getStore() === root) return work(); const release = acquire(root); - try { return context.run(root, () => { reconcile(root); return work(); }); } + try { return context.run(root, () => { reconcile(root); checkGraphOwner(root); return work(); }); } finally { release(); } } /** Indexers and lifecycle writers share this OS-released lock for their full run. */ @@ -210,7 +222,7 @@ export async function withExtensionGuard(root: string, work: () => Promise root = fs.realpathSync(root); if (context.getStore() === root) return work(); const release = acquire(root); - try { return await context.run(root, async () => { reconcile(root); return work(); }); } + try { return await context.run(root, async () => { reconcile(root); checkGraphOwner(root); return work(); }); } finally { release(); } } export class ExtensionTransaction {