From 9f48b7bc2abc92581aba2f802e09b7e5ee49c450 Mon Sep 17 00:00:00 2001 From: qiansc Date: Mon, 26 Oct 2026 06:57:04 +0000 Subject: [PATCH] fix(cli): see same-tick directory additions; run fake host CLIs from an executable temp dir Command-local directory listings were keyed on the directory's dev, inode, size, mtime and ctime. File systems advance timestamps in coarse ticks (ext4 by kernel tick), so adding an entry in the same tick as an earlier change left the stamp equal and a later read in the same command missed the new file. Like Git's racily-clean rule, a listing whose directory changed within the last second is now never reused. File reads keep their reuse: Context replaces files atomically, so a change gets a new inode. Plugin install tests put fake claude/codex executables on PATH under the system temp directory, which hardened hosts mount noexec. They now probe once and fall back to the ignored package-local .tmp/. Co-Authored-By: Claude Opus 5.5 --- .../context-cli/src/__tests__/cli.test.ts | 29 +++++++++++++++++-- .../src/__tests__/statusReadCost.test.ts | 17 +++++++++++ .../src/project/commandReadCache.ts | 20 +++++++++++-- 3 files changed, 60 insertions(+), 6 deletions(-) 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;