From 42bd4cd69ab77a21a0f7522c30957453c479ac52 Mon Sep 17 00:00:00 2001 From: Indra Gunawan Date: Mon, 12 Oct 2026 20:52:38 +0700 Subject: [PATCH] 0.1.4: stop truncating stdout at 64 KiB when read through a pipe process.exit() dropped output still queued on the pipe. Set process.exitCode and let the loop drain. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- src/cli.ts | 5 +++-- test/e2e.test.mjs | 39 +++++++++++++++++++++++++++++++++++++-- 5 files changed, 44 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 0b101da..416c849 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ dsh --json -C /repo --mode plan --disallowed-tools bash --timeout 900 -p "$PROMP dsh models --json [--refresh] ``` -Exit codes: `0` completed, `1` failed or waiting_for_user, `130` interrupted (SIGINT/SIGTERM). +Exit codes: `0` completed, `1` failed or waiting_for_user, `130` interrupted (SIGINT/SIGTERM). The result is written in full before exit, so output over 64 KiB read through a pipe stays valid JSON (fixed in 0.1.4; 0.1.2 and earlier cut it at 65,536 bytes). `--timeout ` stops the run, kills its child processes and reports `failed` with `failReason: "timeout ..."`. `-p ` takes the next argument verbatim, also when it starts with `-`. `-e disabled` = `--no-thinking`. diff --git a/package-lock.json b/package-lock.json index e99b1eb..3d6c984 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@coderbuzz/dsh", - "version": "0.1.3", + "version": "0.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@coderbuzz/dsh", - "version": "0.1.3", + "version": "0.1.4", "license": "MIT", "dependencies": { "chalk": "^5.6.2", diff --git a/package.json b/package.json index 3c0b6cc..11a9030 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@coderbuzz/dsh", - "version": "0.1.3", + "version": "0.1.4", "description": "DeepSeek harness for orchestrators: structured JSON SDK and CLI on top of the deepcode-cli engine", "license": "MIT", "repository": { diff --git a/src/cli.ts b/src/cli.ts index bd69829..8ae1871 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -122,9 +122,10 @@ async function readStdin(): Promise { } main().then( - (code) => process.exit(code), + // exitCode, not process.exit(): exit() drops stdout still queued on a pipe (cut at 64 KiB for large results). + (code) => void (process.exitCode = code), (err) => { process.stderr.write(`dsh: ${err instanceof Error ? err.message : String(err)}\n`); - process.exit(1); + process.exitCode = 1; } ); diff --git a/test/e2e.test.mjs b/test/e2e.test.mjs index e6a3e09..3195ec8 100644 --- a/test/e2e.test.mjs +++ b/test/e2e.test.mjs @@ -4,7 +4,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import http from "node:http"; -import { execFile } from "node:child_process"; +import { execFile, spawn } from "node:child_process"; import { promisify } from "node:util"; import fs from "node:fs"; import os from "node:os"; @@ -35,7 +35,10 @@ const server = http.createServer(async (req, res) => { if (last.role === "user" && JSON.stringify(last.content).includes("again")) return sse(res, { role: "assistant", reasoning_content: "thought-3", content: "again-done" }, true); const user = JSON.stringify(json.messages.filter((m) => m.role === "user").at(-1)?.content); if (user.includes("PARALLEL")) return sse(res, { role: "assistant", content: "parallel-done" }, true); - const command = user.includes("SLEEP") + if (user.includes("BIGREPLY")) return sse(res, { role: "assistant", content: "x".repeat(200000) }, true); + const command = user.includes("BIGFAIL") + ? "head -c 200000 /dev/zero | tr '\\0' y; sleep 37.25" + : user.includes("SLEEP") ? "sleep 37.25" : user.includes("ENVCHECK") ? "env | grep -E '^(DEEPCODE_)?(PLUS_)?API_KEY=' | wc -l; echo MARK=$MARK USERVAR=${OTHER_API_KEY:-unset}" @@ -170,3 +173,35 @@ test("agent bash does not see the provider API key, but keeps other env", async // the user's own variables (even ones ending in API_KEY) are untouched. assert.equal(r.toolExecutions[0].process_stdout, "0\nMARK=kept USERVAR=mine\n"); }); + +// Regression: stdout read through a slow pipe was cut at 64 KiB because the CLI called process.exit +// before the pipe drained. The reader here waits before consuming, so the pipe buffer fills. +const viaSlowPipe = (args) => new Promise((resolve) => { + const child = spawn("node", [cli, ...args], { cwd, env, stdio: ["ignore", "pipe", "ignore"] }); + const chunks = []; + child.stdout.pause(); + setTimeout(() => { child.stdout.on("data", (c) => chunks.push(c)); child.stdout.resume(); }, 1500); + child.on("close", (code) => resolve({ code, out: Buffer.concat(chunks).toString() })); +}); + +test("--json output over 64 KiB survives a slow pipe", async () => { + const { code, out } = await viaSlowPipe(["--json", "BIGREPLY"]); + assert.equal(code, 0); + assert.equal(JSON.parse(out).reply.length, 200000); +}); + +test("--stream-json output over 64 KiB survives a slow pipe", async () => { + const { code, out } = await viaSlowPipe(["--stream-json", "BIGREPLY"]); + assert.equal(code, 0); + const lines = out.trimEnd().split("\n"); + assert.ok(out.length > 65536); + for (const l of lines) JSON.parse(l); +}); + +test("failed run (exit 1) with output over 64 KiB stays intact", async () => { + const { code, out } = await viaSlowPipe(["--json", "--timeout", "2", "BIGFAIL"]); + assert.equal(code, 1); + const r = JSON.parse(out); + assert.match(r.failReason, /timeout/); + assert.ok(out.length > 65536); +});