fix(extensions): honor configured graph directory during recovery

This commit is contained in:
Forge
2026-09-22 04:58:58 +00:00
parent 258cd005fe
commit 70d24ab276
5 changed files with 36 additions and 30 deletions
+2
View File
@@ -154,6 +154,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixes
- Extension installation and interrupted-operation recovery now honor `CODEGRAPH_DIR` without creating or modifying the default data directory.
- Managed extensions remain trusted when a project is opened with different path capitalization on Windows and macOS.
- Publisher forms preserve entered descriptions and source links while package metadata finishes loading.
+19 -18
View File
@@ -5,7 +5,7 @@ const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const { channel } = require('node:diagnostics_channel');
const { randomUUID, createHash } = require('node:crypto');
const { CodeGraph } = require('../../dist');
const { CodeGraph, getCodeGraphDir, codeGraphDirName } = require('../../dist');
const { ExtensionManager } = require('../../dist/plugins/manager');
const { recoverExtensions } = require('../../dist/plugins/recovery');
const { packageDigest } = require('../../dist/plugins/package');
@@ -16,9 +16,9 @@ function artifact(version = '1.0.0') {
files: { 'index.cjs': `module.exports=()=>({frameworks:[{name:'routes',languages:['python'],detect:()=>true,resolve:()=>null,extract(file){return {nodes:[{id:'plugin:recovery-demo:'+file,kind:'route',name:'/${version}',qualifiedName:file+'::route',filePath:file,language:'python',startLine:1,endLine:1,startColumn:0,endColumn:1,updatedAt:0}],references:[]}}}]});` } }));
}
function config(root) { return fs.existsSync(path.join(root, 'codegraph.json')) ? fs.readFileSync(path.join(root, 'codegraph.json'), 'utf8') : null; }
function trust(root) { const file = path.join(root, '.codegraph/plugins/trust.json'); return fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null; }
function trust(root) { const file = path.join(getCodeGraphDir(root), 'plugins/trust.json'); return fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null; }
function inspect(root) {
const dbPath = path.join(root, '.codegraph/codegraph.db');
const dbPath = path.join(getCodeGraphDir(root), 'codegraph.db');
let routes = [];
if (fs.existsSync(dbPath)) {
const db = new DatabaseSync(dbPath, { readOnly: true });
@@ -96,10 +96,11 @@ if (process.argv[2] === '--child') {
return root;
}
function clean(root) {
const folder = path.join(root, '.codegraph/plugins');
if (codeGraphDirName() !== '.codegraph') assert.equal(fs.existsSync(path.join(root, '.codegraph')), false, 'override must not create default data directory');
const folder = path.join(getCodeGraphDir(root), 'plugins');
assert.ok(!fs.existsSync(path.join(folder, 'transaction.json')));
assert.ok(!fs.existsSync(path.join(folder, 'operation.lock')));
assert.ok(!fs.readdirSync(path.join(root, '.codegraph')).some(n => n.startsWith('extension-stage-')));
assert.ok(!fs.readdirSync(getCodeGraphDir(root)).some(n => n.startsWith('extension-stage-')));
assert.ok(!fs.readdirSync(folder).some(n => n.startsWith('stage-')));
assert.equal(fs.readFileSync(path.join(root, 'keep.txt'), 'utf8'), 'unrelated bytes');
const entries = JSON.parse(config(root) || '{}').plugins || [], trusted = JSON.parse(trust(root) || '{}');
@@ -133,7 +134,7 @@ if (process.argv[2] === '--child') {
for (const boundary of ['package_ready', 'trust_written']) {
const root = await project(true), before = inspect(root);
await killAt(root, 'update', boundary); await run(root); assert.deepEqual(inspect(root), before); clean(root);
const packages = fs.readdirSync(path.join(root, '.codegraph/plugins/packages')); assert.equal(packages.length, 1);
const packages = fs.readdirSync(path.join(getCodeGraphDir(root), 'plugins/packages')); assert.equal(packages.length, 1);
receipt({ action: 'update', boundary, decision: 'rollback and discard new package/trust', passed: true });
}
const first = await project(false, false);
@@ -146,13 +147,13 @@ if (process.argv[2] === '--child') {
receipt({ action: 'kill recovery itself', boundary: 'recovery_config', decision: 'idempotent rollback', passed: true });
const live = await project(true), c = child(live, 'update', 'config_written');
await Promise.race([c.reached, c.done.then(r => { throw new Error('Live-owner fixture exited early: ' + r.stderr); })]);
const lock = path.join(live, '.codegraph/plugins/operation.lock'); fs.utimesSync(lock, 1, 1);
const lock = path.join(getCodeGraphDir(live), 'plugins/operation.lock'); fs.utimesSync(lock, 1, 1);
for (const action of ['install', 'inspect', 'index']) {
const result = await run(live, action, 1); assert.match(result.stderr, /operation is active/);
}
assert.ok(processes.has(c.processChild)); c.processChild.kill('SIGKILL'); await c.done; await run(live); clean(live);
receipt({ action: 'live aged owner', decision: 'mutation/read/index excluded; no timeout stealing', passed: true });
const reused = await project(true), owner = path.join(reused, '.codegraph/plugins/operation.lock');
const reused = await project(true), owner = path.join(getCodeGraphDir(reused), 'plugins/operation.lock');
fs.writeFileSync(owner, JSON.stringify({ format: 'codegraph-operation-lock-1', id: randomUUID(), pid: process.pid, startedAt: 1 }));
await run(reused); clean(reused);
fs.writeFileSync(owner, String(process.pid));
@@ -161,31 +162,31 @@ if (process.argv[2] === '--child') {
fs.writeFileSync(owner, String(deadPid)); await run(reused); clean(reused);
receipt({ action: 'PID reuse and legacy owners', decision: 'SQLite ownership authoritative; live legacy blocked, dead legacy reclaimed', passed: true });
const torn = await project(true); await killAt(torn, 'update', 'config_written');
const journal = path.join(torn, '.codegraph/plugins/transaction.json'), original = fs.readFileSync(journal);
fs.writeFileSync(path.join(torn, '.codegraph/plugins/operation.lock'), '{');
const journal = path.join(getCodeGraphDir(torn), 'plugins/transaction.json'), original = fs.readFileSync(journal);
fs.writeFileSync(path.join(getCodeGraphDir(torn), 'plugins/operation.lock'), '{');
fs.writeFileSync(journal, '{'); const state = inspect(torn);
assert.match((await run(torn, 'inspect', 1)).stderr, /invalid transaction record/); assert.deepEqual(inspect(torn), state);
fs.writeFileSync(journal, original); await run(torn); clean(torn);
fs.writeFileSync(path.join(torn, '.codegraph/plugins/operation.lock'), '{');
assert.match((await run(torn, 'inspect', 1)).stderr, /torn\/unknown owner/); fs.unlinkSync(path.join(torn, '.codegraph/plugins/operation.lock'));
fs.writeFileSync(path.join(getCodeGraphDir(torn), 'plugins/operation.lock'), '{');
assert.match((await run(torn, 'inspect', 1)).stderr, /torn\/unknown owner/); fs.unlinkSync(path.join(getCodeGraphDir(torn), 'plugins/operation.lock'));
receipt({ action: 'torn records', decision: 'invalid journal/unknown owner fail closed; valid journal recovers torn owner', passed: true });
for (const boundary of ['config_written', 'graph_committed']) {
const edited = await project(true); await killAt(edited, 'update', boundary);
const cfg = JSON.parse(config(edited)); cfg.userNote = 'preserve this edit'; fs.writeFileSync(path.join(edited, 'codegraph.json'), JSON.stringify(cfg));
const t = JSON.parse(trust(edited)); t['unrelated-user-key'] = 'preserve'; fs.writeFileSync(path.join(edited, '.codegraph/plugins/trust.json'), JSON.stringify(t));
const t = JSON.parse(trust(edited)); t['unrelated-user-key'] = 'preserve'; fs.writeFileSync(path.join(getCodeGraphDir(edited), 'plugins/trust.json'), JSON.stringify(t));
await run(edited); assert.equal(JSON.parse(config(edited)).userNote, 'preserve this edit'); assert.equal(JSON.parse(trust(edited))['unrelated-user-key'], 'preserve'); clean(edited);
receipt({ action: 'unrelated config/trust edits', boundary, decision: 'preserved', passed: true });
const conflict = await project(true); await killAt(conflict, 'update', boundary);
const cfg2 = JSON.parse(config(conflict)); cfg2.plugins[0].options = { userEdit: true }; const editedRaw = JSON.stringify(cfg2);
fs.writeFileSync(path.join(conflict, 'codegraph.json'), editedRaw);
assert.match((await run(conflict, 'inspect', 1)).stderr, /plugins were edited externally/); assert.equal(config(conflict), editedRaw);
const r = JSON.parse(JSON.parse(fs.readFileSync(path.join(conflict, '.codegraph/plugins/transaction.json'))).payload);
const r = JSON.parse(JSON.parse(fs.readFileSync(path.join(getCodeGraphDir(conflict), 'plugins/transaction.json'))).payload);
cfg2.plugins = JSON.parse(boundary === 'graph_committed' ? r.nextConfig : r.previousConfig).plugins; cfg2.resolvedByUser = true;
fs.writeFileSync(path.join(conflict, 'codegraph.json'), JSON.stringify(cfg2));
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');
const graphLocked = await project(true), graphLock = path.join(getCodeGraphDir(graphLocked), '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);
@@ -201,10 +202,10 @@ if (process.argv[2] === '--child') {
receipt({ action: 'graph-affecting external config edit', decision: 'preserved and blocked with repair instructions', passed: true });
const tamper = await project(true); await killAt(tamper, 'update', 'graph_committed');
const entry = JSON.parse(config(tamper)).plugins[0];
const entryFile = path.join(tamper, '.codegraph/plugins/packages', entry.integrity, 'index.cjs'), originalEntry = fs.readFileSync(entryFile);
const entryFile = path.join(getCodeGraphDir(tamper), 'plugins/packages', entry.integrity, 'index.cjs'), originalEntry = fs.readFileSync(entryFile);
fs.appendFileSync(entryFile, '\n// external change');
assert.match((await run(tamper, 'inspect', 1)).stderr, /installed package managed:recovery-demo is missing or changed/);
assert.ok(fs.existsSync(path.join(tamper, '.codegraph/plugins/transaction.json')));
assert.ok(fs.existsSync(path.join(getCodeGraphDir(tamper), 'plugins/transaction.json')));
fs.writeFileSync(entryFile, originalEntry); await run(tamper); clean(tamper);
receipt({ action: 'changed installed package', decision: 'trust revalidated; blocked until bytes restored', passed: true });
const cached = await project(true), reader = await CodeGraph.open(cached);
@@ -214,7 +215,7 @@ if (process.argv[2] === '--child') {
assert.equal(reader.getNode(node.id).name, '/2.0.0');
} finally { reader.close(); }
receipt({ action: 'long-lived reader after fresh-process recovery', decision: 'old cached node invalidated by commit marker', passed: true });
fs.writeFileSync(path.join(out, 'result.json'), JSON.stringify({ platform: process.platform, arch: process.arch, node: process.version, termination: process.platform === 'win32' ? 'Node child.kill(SIGKILL) / TerminateProcess' : 'SIGKILL', rows, commands: commands.length }, null, 2));
fs.writeFileSync(path.join(out, 'result.json'), JSON.stringify({ platform: process.platform, arch: process.arch, node: process.version, dataDirectory: codeGraphDirName(), termination: process.platform === 'win32' ? 'Node child.kill(SIGKILL) / TerminateProcess' : 'SIGKILL', rows, commands: commands.length }, null, 2));
} finally {
for (const c of processes) c.kill('SIGKILL');
if (processes.size) await new Promise(resolve => setTimeout(resolve, 300));
+5 -4
View File
@@ -19,13 +19,13 @@ const env = { ...process.env, CODEGRAPH_TELEMETRY: '0', CODEGRAPH_WASM_RELAUNCHE
const playwright = process.env.PLAYWRIGHT_MODULE || path.join(root, '.qa/browser/node_modules/playwright');
env.PLAYWRIGHT_MODULE = playwright;
env.CHROME_PATH = process.env.CHROME_PATH || require(playwright).chromium.executablePath();
async function run(name, command, args, timeout = 360000) {
const step = { name, command: [command, ...args], cwd: root, revision: version, started: new Date().toISOString(), status: 'running' };
async function run(name, command, args, timeout = 360000, overrides = {}) {
const step = { name, command: [command, ...args], cwd: root, revision: version, overrides, started: new Date().toISOString(), status: 'running' };
metadata.steps.push(step); save(); console.log('START', name);
const logFile = path.join(out, name + '.log'); const log = fs.openSync(logFile, 'w');
const started = Date.now();
const result = await new Promise(resolve => {
const child = spawn(command, args, { cwd: root, env, stdio: ['ignore', log, log], timeout });
const child = spawn(command, args, { cwd: root, env: { ...env, ...overrides }, stdio: ['ignore', log, log], timeout });
let error;
child.on('error', e => error = String(e));
child.on('close', (exit, signal) => resolve({ exit, signal, error }));
@@ -42,12 +42,13 @@ async function run(name, command, args, timeout = 360000) {
if (built) {
await run('marketplace-build', ...npmCommand(['run', 'build', '--prefix', 'marketplace']));
await run('focused', process.execPath, ['node_modules/vitest/vitest.mjs', 'run',
'__tests__/extension-trust.test.ts', '__tests__/extension-releases.test.ts', '__tests__/extension-marketplace.test.ts', '__tests__/extension-author.test.ts', '__tests__/plugins.test.ts', '__tests__/extension-explore.test.ts',
'__tests__/foundation.test.ts', '__tests__/extension-trust.test.ts', '__tests__/extension-releases.test.ts', '__tests__/extension-marketplace.test.ts', '__tests__/extension-author.test.ts', '__tests__/plugins.test.ts', '__tests__/extension-explore.test.ts',
'__tests__/db-reopen-on-replace.test.ts', '__tests__/status-json.test.ts', '__tests__/sync.test.ts', '__tests__/concurrent-locking.test.ts',
'--maxWorkers=2', '--minWorkers=1', '--reporter=default', '--reporter=json', `--outputFile.json=${path.join(out, 'focused.json')}`]);
await run('compiled-workers', process.execPath, ['--test', 'scripts/validation/extensions-runtime.test.cjs']);
await run('native-paths', process.execPath, ['scripts/validation/native-paths.cjs']);
await run('process-recovery', process.execPath, ['scripts/validation/extensions-recovery.cjs'], 600000);
await run('process-recovery-override', process.execPath, ['scripts/validation/extensions-recovery.cjs'], 600000, { CODEGRAPH_DIR: '.codegraph-native', RECOVERY_OUTPUT: path.join(out, 'recovery-override') });
await run('external-author', process.execPath, ['scripts/validation/extension-author.cjs'], 600000);
await run('compatibility', process.execPath, ['scripts/validation/extensions-compatibility.cjs']);
await run('browser', process.execPath, ['scripts/validation/marketplace-browser.cjs']);
+2 -1
View File
@@ -1,5 +1,6 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { getCodeGraphDir } from '../directory';
import { createRequire } from 'node:module';
import { pathToFileURL } from 'node:url';
import { NODE_KINDS, EDGE_KINDS, LANGUAGES, type Node, type Edge } from '../types';
@@ -11,7 +12,7 @@ import { loadPluginEntries } from '../project-config';
import { version as engineVersion } from '../../package.json';
const importESM = new Function('url', 'return import(url)') as (url: string) => Promise<unknown>;
export const pluginDirectory = (root: string): string => path.join(root, '.codegraph', 'plugins');
export const pluginDirectory = (root: string): string => path.join(getCodeGraphDir(root), 'plugins');
function resolveEntry(root: string, entry: PluginEntry): ResolvedPlugin {
let packageRoot: string;
+8 -7
View File
@@ -1,6 +1,7 @@
/** Process-death recovery for managed extensions; no PID/age based lock stealing. */
import * as fs from 'node:fs';
import * as path from 'node:path';
import { getCodeGraphDir } from '../directory';
import { randomUUID } from 'node:crypto';
import { AsyncLocalStorage } from 'node:async_hooks';
import { channel } from 'node:diagnostics_channel';
@@ -10,7 +11,7 @@ import { sha256, packageDigest, trustedPackageDigest, type ExtensionPackage } fr
const context = new AsyncLocalStorage<string>();
const transitions = channel('codegraph.extension.transaction');
const dir = (root: string) => path.join(root, '.codegraph', 'plugins');
const dir = (root: string) => path.join(getCodeGraphDir(root), 'plugins');
const recordPath = (root: string) => path.join(dir(root), 'transaction.json');
const ownerPath = (root: string) => path.join(dir(root), 'operation.lock');
const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
@@ -102,7 +103,7 @@ 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();
const lock = path.join(getCodeGraphDir(root), '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)) {
@@ -113,7 +114,7 @@ function checkGraphOwner(root: string, transactionPid?: number): void {
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');
const file = path.join(getCodeGraphDir(root), 'codegraph.db');
if (!fs.existsSync(file)) return null;
const { db } = createDatabase(file);
try {
@@ -129,7 +130,7 @@ function replaceObserved(root: string, file: string, observed: string | null, ta
if (target === null) remove(file); else durableWrite(file, target);
}
function cleanup(root: string, r: RecordV1, committed: boolean): void {
const stage = path.join(root, '.codegraph', `extension-stage-${r.id}.db`);
const stage = path.join(getCodeGraphDir(root), `extension-stage-${r.id}.db`);
for (const suffix of ['', '-wal', '-shm', '.lock']) remove(stage + suffix);
fs.rmSync(path.join(dir(root), `stage-${r.id}`), { recursive: true, force: true });
if (!committed && r.package && !r.package.existed) {
@@ -140,9 +141,9 @@ function cleanup(root: string, r: RecordV1, committed: boolean): void {
// All modern graph writers use the coordinator. Only remove the killed
// transaction's own old graph lock; never someone else's legacy lock.
if (!committed && !r.graphExisted) {
for (const suffix of ['', '-wal', '-shm']) remove(path.join(root, '.codegraph', 'codegraph.db') + suffix);
for (const suffix of ['', '-wal', '-shm']) remove(path.join(getCodeGraphDir(root), 'codegraph.db') + suffix);
}
const graphLock = path.join(root, '.codegraph', 'codegraph.lock');
const graphLock = path.join(getCodeGraphDir(root), 'codegraph.lock');
if (read(graphLock)?.trim() === String(r.pid)) remove(graphLock);
}
/** Validate the version/trust side before removing the only recovery record. */
@@ -229,7 +230,7 @@ export class ExtensionTransaction {
readonly record: RecordV1;
constructor(readonly root: string, nextConfig: string, pkg?: { contents: ExtensionPackage; integrity: string }) {
if (fs.existsSync(recordPath(root))) throw new Error('An extension transaction is already active');
this.record = { format: 1, id: randomUUID(), pid: process.pid, graphExisted: fs.existsSync(path.join(root, '.codegraph', 'codegraph.db')),
this.record = { format: 1, id: randomUUID(), pid: process.pid, graphExisted: fs.existsSync(path.join(getCodeGraphDir(root), 'codegraph.db')),
previousConfig: read(path.join(root, 'codegraph.json')), nextConfig,
previousTrust: read(path.join(dir(root), 'trust.json')), nextTrust: read(path.join(dir(root), 'trust.json')),
...(pkg ? { package: { integrity: pkg.integrity, existed: fs.existsSync(path.join(dir(root), 'packages', pkg.integrity)) } } : {}) };