Skip to content

Commit f287b68

Browse files
fix: tool commands no longer lose a big answer on a pipe (#1687)
* fix: `tool run --json` no longer loses a big answer on a pipe `console.log` left what the pipe couldn't take in a buffer that `process.exit()` threw away: a slow reader got 64 KiB of a 354 KB answer. The answer is now written whole before the command returns. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YNQjkaiykbC1WtASw6Yegs * fix: `tool list --json` writes whole too, and a reader that leaves early isn't an error `tool list --json` is over 64 KiB and could be cut the same way. Stopping at EPIPE keeps `| head` exiting 0, as it did before the whole-answer write. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YNQjkaiykbC1WtASw6Yegs * test: the pipe tests skip on Windows, where there is no POSIX shell Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YNQjkaiykbC1WtASw6Yegs * fix: every `tool` output that can pass a pipe's buffer writes whole Covers text-mode `tool run` (a workspace tool's output has no cap), `tool context` and `tool schema`. Docs say to read stdout while the command runs. Tests now check the CLI's own exit code, cover failed writes and clean up. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YNQjkaiykbC1WtASw6Yegs * docs: the read-while-it-runs guidance covers every `tool` command Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YNQjkaiykbC1WtASw6Yegs --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent cc12329 commit f287b68

3 files changed

Lines changed: 135 additions & 9 deletions

File tree

‎docs/docs/usage/tool.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ description: "Call Altimate Code data tools from any shell, agent or CI job with
77

88
Expose the data tools -- warehouse, SQL, schema, lineage, impact, PII, data-diff, FinOps and dbt -- as plain commands. The local data tools use no model or API key, so Claude Code, Codex and CI jobs can call them directly. Linked workspace tools authenticate to Altimate and `workspace_execute` may use backend models.
99

10+
Read stdout while a `tool` command runs. These commands write all of their output before they exit, so a caller that waits for exit before reading can block forever on output larger than a pipe holds. A reader that closes the pipe early doesn't change the exit code. A write that fails, such as on a full disk, exits non-zero.
11+
1012
## List tools
1113

1214
```bash

‎packages/opencode/src/cli/cmd/tool.ts‎

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
// that keeps warehouse connections and the schema cache warm between calls. The tool modules are
55
// loaded lazily so the rest of the CLI does not pay for them at startup.
66
import type { Argv } from "yargs"
7-
import { readFileSync, statSync } from "node:fs"
7+
import { readFileSync, statSync, writeSync } from "node:fs"
88
import path from "node:path"
99
import { randomUUID } from "node:crypto"
1010
import { InstallationVersion } from "@opencode-ai/core/installation/version"
@@ -128,7 +128,7 @@ const ToolListCommand = cmd({
128128
const workspaceTools = await resolveWorkspaceTools(args["workspace-tools"], process.cwd())
129129
const tools = await withRunner(process.cwd(), args.connections, workspaceTools, (runner) => runner.list())
130130
if (args.json) {
131-
console.log(JSON.stringify(tools, null, 2))
131+
writeAll(JSON.stringify(tools, null, 2) + "\n")
132132
return
133133
}
134134
const width = Math.max(...tools.map((tool) => tool.id.length))
@@ -159,8 +159,8 @@ const ToolContextCommand = cmd({
159159
await Registry.closeAll()
160160
}
161161
})
162-
if (args.json) console.log(JSON.stringify({ instructions, connections }, null, 2))
163-
else console.log(instructions)
162+
if (args.json) writeAll(JSON.stringify({ instructions, connections }, null, 2) + "\n")
163+
else writeAll(instructions + "\n")
164164
},
165165
})
166166

@@ -181,7 +181,7 @@ const ToolSchemaCommand = cmd({
181181
process.exitCode = 1
182182
return
183183
}
184-
console.log(JSON.stringify(tool, null, 2))
184+
writeAll(JSON.stringify(tool, null, 2) + "\n")
185185
},
186186
})
187187

@@ -233,11 +233,11 @@ const ToolRunCommand = cmd({
233233
if (result.status === "error" && ARGUMENT_ERROR.test(result.output))
234234
result = { ...result, output: `${result.output}\nInput schema: altimate-code tool schema ${name}` }
235235
if (args.json) {
236-
console.log(JSON.stringify({ status: result.status, title: result.title, output: result.output, metadata: result.metadata }))
236+
writeAll(JSON.stringify({ status: result.status, title: result.title, output: result.output, metadata: result.metadata }) + "\n")
237237
} else if (result.status === "error") {
238238
console.error(result.output)
239239
} else {
240-
console.log(result.output)
240+
writeAll(result.output + "\n")
241241
}
242242
if (result.status === "error") process.exitCode = 1
243243
},
@@ -296,6 +296,25 @@ const ToolDaemonCommand = cmd({
296296
},
297297
})
298298

299+
/**
300+
* All of an answer on stdout before returning. The CLI exits right after a command, and console.log leaves
301+
* what a pipe can't take yet in a buffer that exit throws away, which cuts off anything over the pipe's
302+
* 64 KiB when its reader is slow: `metadata`, the tool list, or a workspace tool's output, which has no cap.
303+
*/
304+
function writeAll(text: string) {
305+
const bytes = Buffer.from(text)
306+
for (let at = 0; at < bytes.length; ) {
307+
try {
308+
at += writeSync(1, bytes, at)
309+
} catch (error) {
310+
const code = (error as NodeJS.ErrnoException).code
311+
if (code === "EPIPE") return // The reader left, so nobody is waiting for the rest.
312+
if (code !== "EAGAIN") throw error
313+
Bun.sleepSync(2)
314+
}
315+
}
316+
}
317+
299318
export const ToolCommand = cmd({
300319
command: "tool",
301320
describe: "run altimate-code data tools directly, without a model",

‎packages/opencode/test/altimate/tool-cli/cli.test.ts‎

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,74 @@
1-
import { describe, expect, test } from "bun:test"
2-
import { mkdir, mkdtemp, writeFile } from "node:fs/promises"
1+
import { afterAll, describe, expect, test } from "bun:test"
2+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
33
import { tmpdir } from "node:os"
44
import path from "node:path"
55

66
const entry = path.join(import.meta.dir, "../../../src/index.ts")
7+
const cliEnv = { ...process.env, ALTIMATE_TELEMETRY_DISABLED: "1", ALTIMATE_TOOL_DAEMON: "0" }
8+
// These drive the CLI through a shell pipeline, which is what makes the reader late.
9+
const posixOnly = test.skipIf(process.platform === "win32")
10+
11+
const pipeDirs: string[] = []
12+
async function pipeDir() {
13+
const dir = await mkdtemp(path.join(tmpdir(), "tool-cli-"))
14+
pipeDirs.push(dir)
15+
return dir
16+
}
17+
afterAll(() => Promise.all(pipeDirs.map((dir) => rm(dir, { recursive: true, force: true }))))
18+
19+
/** Runs a shell line with the CLI as `"$0" --conditions=browser "$1"`, and returns the CLI's exit code from code.txt. */
20+
async function inShell(dir: string, line: string) {
21+
const proc = Bun.spawn(["sh", "-c", line, process.execPath, entry], { cwd: dir, stderr: "ignore", env: cliEnv, timeout: 50_000 })
22+
try {
23+
await proc.exited
24+
return Number((await Bun.file(path.join(dir, "code.txt")).text()).trim())
25+
} finally {
26+
proc.kill()
27+
}
28+
}
29+
30+
/** A manifest whose hub has 450 readers, so its `dbt_lineage` graph is far over a pipe's 64 KiB. */
31+
async function bigAnswerProject() {
32+
const dir = await pipeDir()
33+
const model = (name: string, deps: string[]) => ({
34+
resource_type: "model",
35+
unique_id: `model.shop.${name}`,
36+
name,
37+
alias: name,
38+
database: "shop",
39+
schema: "main",
40+
original_file_path: `models/${name}.sql`,
41+
depends_on: { nodes: deps.map((dep) => `model.shop.${dep}`) },
42+
config: { materialized: "table" },
43+
compiled_code: "select 1 as id",
44+
columns: {},
45+
})
46+
const readers = Array.from({ length: 450 }, (_, i) => model(`reader_with_a_longish_name_${i}`, ["hub"]))
47+
const nodes = [model("hub", []), ...readers]
48+
const manifest = path.join(dir, "manifest.json")
49+
await writeFile(
50+
manifest,
51+
JSON.stringify({
52+
metadata: { adapter_type: "duckdb" },
53+
nodes: Object.fromEntries(nodes.map((node) => [node.unique_id, node])),
54+
sources: {},
55+
exposures: {},
56+
}),
57+
)
58+
await writeFile(path.join(dir, "args.json"), JSON.stringify({ manifest_path: manifest, model: "hub", include_graph: true }))
59+
return dir
60+
}
61+
62+
/**
63+
* Runs the CLI with a reader that waits for it to exit, up to 20 s, as a busy event loop can: the pipe is full
64+
* until then. A CLI that drops what the pipe can't take exits at once and leaves 64 KiB; one that writes it all waits.
65+
*/
66+
function lateReader(dir: string, command: string) {
67+
return inShell(
68+
dir,
69+
`{ "$0" --conditions=browser "$1" ${command}; echo $? > code.txt; touch done; } | { i=0; until [ -e done ] || [ $i -ge 200 ]; do sleep 0.1; i=$((i+1)); done; cat > out.json; }`,
70+
)
71+
}
772

873
async function cli(args: string[], stdin?: string) {
974
const dir = await mkdtemp(path.join(tmpdir(), "tool-cli-"))
@@ -28,6 +93,46 @@ describe("tool CLI", () => {
2893
expect(JSON.parse(stdout).output).toBe("SELECT a FROM t")
2994
}, 60000)
3095

96+
// Past the 64 KiB a pipe holds, a process that exits with the rest still buffered loses it.
97+
posixOnly("a big --json answer reaches a reader that starts late whole", async () => {
98+
const dir = await bigAnswerProject()
99+
expect(await lateReader(dir, "tool run dbt_lineage --args - --json < args.json")).toBe(0)
100+
const stdout = await Bun.file(path.join(dir, "out.json")).text()
101+
expect(stdout.length).toBeGreaterThan(100_000)
102+
expect(Object.keys(JSON.parse(stdout).metadata.graph.nodes).length).toBe(451)
103+
}, 60000)
104+
105+
posixOnly("tool list --json reaches a reader that starts late whole", async () => {
106+
const dir = await pipeDir()
107+
expect(await lateReader(dir, "tool list --json")).toBe(0)
108+
const stdout = await Bun.file(path.join(dir, "out.json")).text()
109+
expect(stdout.endsWith("]\n")).toBe(true)
110+
expect((JSON.parse(stdout) as unknown[]).length).toBeGreaterThan(0)
111+
}, 60000)
112+
113+
posixOnly("a reader that leaves early is not an error", async () => {
114+
const dir = await bigAnswerProject()
115+
const code = await inShell(
116+
dir,
117+
`{ "$0" --conditions=browser "$1" tool run dbt_lineage --args - --json < args.json 2> err.txt; echo $? > code.txt; } | head -c 10 > /dev/null`,
118+
)
119+
expect(code).toBe(0)
120+
expect(await Bun.file(path.join(dir, "err.txt")).text()).toBe("")
121+
}, 60000)
122+
123+
posixOnly("an error stays an error when the reader has left", async () => {
124+
const dir = await pipeDir()
125+
const code = await inShell(dir, `{ "$0" --conditions=browser "$1" tool run nope --args '{}' --json 2> err.txt; echo $? > code.txt; } | true`)
126+
expect(code).toBe(1)
127+
}, 60000)
128+
129+
posixOnly("a write that really fails exits non-zero and says why", async () => {
130+
const dir = await pipeDir()
131+
const code = await inShell(dir, `"$0" --conditions=browser "$1" tool list --json 1</dev/null 2> err.txt; echo $? > code.txt`)
132+
expect(code).toBe(1)
133+
expect(await Bun.file(path.join(dir, "err.txt")).text()).toContain("EBADF")
134+
}, 60000)
135+
31136
test("rejects --args - with empty stdin", async () => {
32137
const { stdout, code } = await cli(["tool", "run", "sql_format", "--args", "-", "--json"], "")
33138
expect(code).toBe(1)

0 commit comments

Comments
 (0)