diff --git a/packages/context-cli/src/__tests__/cli.test.ts b/packages/context-cli/src/__tests__/cli.test.ts index 05517b09..80f40546 100644 --- a/packages/context-cli/src/__tests__/cli.test.ts +++ b/packages/context-cli/src/__tests__/cli.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -8,6 +9,28 @@ import { cli_main, createCliProgram, handleCliFailure, isDirectCliInvocation } f import { ContextError } from "../lib/errors.js"; import { ExitCode } from "../types/exitCode.js"; +let executableTempRoot: string | undefined; + +/** + * Fixtures that put fake host CLIs on PATH need a directory that allows + * execution. Hardened hosts mount the system temp directory noexec, so fall + * back to the ignored package-local .tmp/ when a probe script cannot run. + */ +function executableTmpdir(): string { + if (executableTempRoot !== undefined) return executableTempRoot; + const probeDir = mkdtempSync(join(tmpdir(), "ctx-cli-exec-probe-")); + try { + const probe = join(probeDir, "probe"); + writeFileSync(probe, "#!/bin/sh\nexit 0\n", "utf8"); + chmodSync(probe, 0o755); + executableTempRoot = spawnSync(probe).status === 0 ? tmpdir() : resolve(".tmp", "cli-exec-tests"); + } finally { + rmSync(probeDir, { recursive: true, force: true }); + } + mkdirSync(executableTempRoot, { recursive: true }); + return executableTempRoot; +} + const CLI_MODULE = resolve(import.meta.dir, "..", "cli.ts"); async function runShell( @@ -489,7 +512,7 @@ describe("CLI error handling", () => { }); test("plugin install refreshes the manifest-version Codex cache before pruning old versions", async () => { - const cwd = mkdtempSync(join(tmpdir(), "ctx-cli-plugin-versioned-cache-")); + const cwd = mkdtempSync(join(executableTmpdir(), "ctx-cli-plugin-versioned-cache-")); const pluginRoot = join(cwd, "plugins"); const codexHome = join(cwd, "codex-home"); const binDir = join(cwd, "bin"); @@ -533,7 +556,7 @@ describe("CLI error handling", () => { }); test("plugin install continues available agents when codex is missing", async () => { - const cwd = mkdtempSync(join(tmpdir(), "ctx-cli-plugin-partial-")); + const cwd = mkdtempSync(join(executableTmpdir(), "ctx-cli-plugin-partial-")); const pluginRoot = join(cwd, "plugins"); const binDir = join(cwd, "bin"); try { @@ -568,7 +591,7 @@ describe("CLI error handling", () => { }); test("plugin install replaces an existing Claude plugin and removes superseded caches", async () => { - const cwd = mkdtempSync(join(tmpdir(), "ctx-cli-plugin-update-")); + const cwd = mkdtempSync(join(executableTmpdir(), "ctx-cli-plugin-update-")); const pluginRoot = join(cwd, "plugins"); const binDir = join(cwd, "bin"); const commandLog = join(cwd, "claude-commands.log"); diff --git a/packages/context-cli/src/__tests__/statusReadCost.test.ts b/packages/context-cli/src/__tests__/statusReadCost.test.ts index f1d71e9e..6cc365ba 100644 --- a/packages/context-cli/src/__tests__/statusReadCost.test.ts +++ b/packages/context-cli/src/__tests__/statusReadCost.test.ts @@ -87,6 +87,23 @@ test("command reads share bytes but detect additions, replacement, deletion and } finally { spy.mockRestore(); } }); +test("command reads see additions made within the same file system timestamp tick", async () => { + const parent = resolve(".tmp/approved-read-tests"); + await fs.mkdir(parent, { recursive: true }); + const root = await fs.mkdtemp(join(parent, "case-")); roots.push(root); + await fs.mkdir(join(root, "knowledge/architecture"), { recursive: true }); + await fs.writeFile(join(root, "knowledge/architecture/a.md"), "first"); + await withCommandReadCache(async () => { + // Coarse timestamps (ext4 advances by kernel tick) usually leave the + // directory stamp unchanged across these additions; each must still appear. + for (let index = 0; index < 50; index++) { + expect(await readApprovedMarkdownFiles(root)).toHaveLength(index + 1); + await fs.writeFile(join(root, `knowledge/architecture/added-${index}.md`), "added"); + } + expect(await readApprovedMarkdownFiles(root)).toHaveLength(51); + }); +}); + test("new investigation keeps its actual route without re-auditing previously delivered prose", async () => { const root = await initialRevisionKnowledge(roots); await resumeWorkspaceTask(root); diff --git a/packages/context-cli/src/project/commandReadCache.ts b/packages/context-cli/src/project/commandReadCache.ts index ed2941d8..68974100 100644 --- a/packages/context-cli/src/project/commandReadCache.ts +++ b/packages/context-cli/src/project/commandReadCache.ts @@ -6,17 +6,25 @@ import { DerivedValueCache } from "./derivedValueCache.js"; interface CachedRead { stamp: string; + /** The stamp was taken too soon after a change to prove later changes. */ + racy: boolean; value: Promise; } const commandReads = new AsyncLocalStorage>(); const MAX_READS = 64; +/** File systems advance timestamps in coarse ticks (ext4 by kernel tick, some + * by whole seconds). Adding a directory entry in the same tick as an earlier + * change leaves dev/ino/size/mtime/ctime equal, so, like Git's racily-clean + * rule, a listing whose directory changed within this window is never reused. + * Files need no such guard: Context replaces them atomically (new inode). */ +const RACY_WINDOW_NS = 1_000_000_000n; const commandValues = new AsyncLocalStorage(); /** Cache one directory listing, never the entire subtree. Every traversal * checks each child directory so nested additions/removals remain visible. */ export async function readCommandDirectory(path: string) { - const entries = await reuseCommandFileRead({ key: "directory-entries", paths: [path], + const entries = await reuseCommandFileRead({ key: "directory-entries", paths: [path], racyWindow: true, read: () => readdir(path, { withFileTypes: true }) }); return [...entries]; } @@ -32,6 +40,8 @@ export function withCommandReadCache(action: () => Promise): Promise { export async function reuseCommandFileRead(input: { key: string; paths: readonly string[]; + /** Never reuse a read whose paths changed within the timestamp window. */ + racyWindow?: boolean; read: () => Promise; }): Promise { const cache = commandReads.getStore(); @@ -39,16 +49,20 @@ export async function reuseCommandFileRead(input: { const paths = input.paths.map((path) => resolve(path)); const key = JSON.stringify([input.key, paths]); let stamp: string; + let racy = false; try { + const takenNs = BigInt(Date.now()) * 1_000_000n; stamp = JSON.stringify(await Promise.all(paths.map(async (path) => { const file = await stat(path, { bigint: true }); + if (input.racyWindow && file.mtimeNs + RACY_WINDOW_NS > takenNs) racy = true; return [file.dev, file.ino, file.size, file.mtimeNs, file.ctimeNs].map(String); }))); } catch { cache.delete(key); return input.read(); } - const previous = cache.get(key); + const cached = cache.get(key); + const previous = cached?.racy ? undefined : cached; const projectRoot = currentDebugProjectRoot(); if (projectRoot) await recordContextDebugPerformance({ projectRoot, operation: "command.read-cache", durationMs: 0, outcome: "success", counters: { paths_checked: paths.length, cache_hits: previous?.stamp === stamp ? 1 : 0, @@ -58,7 +72,7 @@ export async function reuseCommandFileRead(input: { const value = Promise.resolve().then(input.read); cache.delete(key); if (cache.size >= MAX_READS) cache.delete(cache.keys().next().value!); - const entry = { stamp, value }; + const entry = { stamp, racy, value }; cache.set(key, entry); try { const resolved = await value;