From 398ca092df858050207afd7dedd3333611d822c3 Mon Sep 17 00:00:00 2001 From: garrytan Date: Mon, 26 Oct 2026 03:01:18 +0000 Subject: [PATCH] fix(context-bill): --exact refuses a walked file swapped for a FIFO instead of blocking on open Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- lib/context-bill.ts | 5 +++-- test/context-bill.test.ts | 31 +++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/lib/context-bill.ts b/lib/context-bill.ts index e0552c787..c7f8863d2 100644 --- a/lib/context-bill.ts +++ b/lib/context-bill.ts @@ -891,13 +891,14 @@ export function exactSendSet(root: string): ExactSendSet { * following a final link, and only if it is still the same regular file at * the same realpath. A link retargeted, a file replaced, or a parent directory * swapped for a link after the walk is refused, so nothing the bill did not - * walk can be sent. + * walk can be sent. The open does not block, so a file swapped for a FIFO is + * refused as not a regular file instead of hanging the run. */ function readWalkedFile(real: string, dev: number, ino: number, file: string, root: string): string { const changed = () => new Error(`${file} changed after it was walked; nothing from ${root} was sent. Run again.`); let fd: number; try { - fd = fs.openSync(real, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0)); + fd = fs.openSync(real, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0)); } catch { throw changed(); } diff --git a/test/context-bill.test.ts b/test/context-bill.test.ts index 12b9f0ba2..c4f023b85 100644 --- a/test/context-bill.test.ts +++ b/test/context-bill.test.ts @@ -16,6 +16,7 @@ */ import { describe, it, expect, beforeAll, afterAll } from "bun:test"; import { canRevokeWrites } from "./helpers/fs-caps"; +import { bunIsolationArgs } from "../lib/bun-isolation"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -1194,6 +1195,36 @@ describe("--exact (opt-in measurement; offline here via an injected fetch)", () expect(sameFileMoved.err).toContain("changed after it was walked"); }); + it("refuses a walked file swapped for a FIFO instead of hanging", () => { + if (process.platform === "win32") return; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-fifo-")); + const skills = path.join(tmp, "skills"); + fs.mkdirSync(path.join(skills, "qa"), { recursive: true }); + fs.writeFileSync(path.join(skills, "qa", "SKILL.md"), "---\nname: qa\ndescription: d\n---\n# qa\n"); + fs.writeFileSync(path.join(skills, "qa", "a.md"), "A"); + const script = path.join(tmp, "probe.ts"); + fs.writeFileSync( + script, + [ + `import fs from "node:fs";`, + `import { spawnSync } from "node:child_process";`, + `import { exactSendSet, measureExactTokens } from ${JSON.stringify(path.join(ROOT, "lib", "context-bill.ts"))};`, + `const root = ${JSON.stringify(skills)};`, + `const sendSet = exactSendSet(root);`, + `fs.unlinkSync(${JSON.stringify(path.join(skills, "qa", "a.md"))});`, + `spawnSync("mkfifo", [${JSON.stringify(path.join(skills, "qa", "a.md"))}], { timeout: 5_000 });`, + `const fetchImpl = (async () => ({ ok: true, json: async () => ({ input_tokens: 9 }) })) as never;`, + `try { await measureExactTokens(root, { sendSet, model: "m", apiKey: "sk-test", fetchImpl, egressHome: ${JSON.stringify(tmp)} }); console.log("SENT"); }`, + `catch (error) { console.log("REFUSED " + (error as Error).message); }`, + ].join("\n"), + ); + const result = Bun.spawnSync([process.execPath, ...bunIsolationArgs(), script], { timeout: 15_000 }); + fs.rmSync(tmp, { recursive: true, force: true }); + expect(result.exitCode).toBe(0); + expect(result.stdout.toString()).toContain("REFUSED"); + expect(result.stdout.toString()).toContain("changed after it was walked"); + }); + it("sends files in the root and the linked gstack checkout, and refuses and names anything else a link reaches", async () => { if (process.platform === "win32") return; const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-egress-"));