Skip to content

fix(cli): a failed docker render names the container's last error - #4168

Merged
miguel-heygen merged 1 commit into
mainfrom
fix/cli-docker-render-stderr
Sep 19, 2026
Merged

miguel-heygen merged 1 commit into
mainfrom
fix/cli-docker-render-stderr

Conversation

@miguel-heygen

Copy link
Copy Markdown
Collaborator

Summary

  • When a --docker render failed, the wrapper only reported Docker render exited with code N, and the container's own error was never captured, so neither the failure message nor the error report could say what went wrong inside the container.
  • The wrapper now pipes the container's stderr, still echoes it live, keeps a bounded tail, and fails with Docker render exited with code N: <last error line>.
  • The last error line is the last line that reads like an error, ignoring stack frames, splitting carriage-return progress output, and capped in length. It sits inside the message, which is what the error report carries (the report shortens messages, so a longer tail would not survive it).

Test plan

  • Unit tests on the tail buffer bound, error-line choice (error over trailing noise, stack frames skipped, \r progress split, long line capped) and the final message shape.
  • render.test.ts and the new file pass on a Linux box (100 tests); tsc --noEmit clean.

Not covered by tests: the spawn wiring inside renderDocker (needs a real docker binary). Known trade-off: the docker CLI's own stderr is now a pipe rather than a terminal, so its live progress bars and colour are plain; container output is unchanged (no -t).

Review

Independent adversarial pass found one blocking issue (a tail attached to the error report is cut off by its own shortening, so it never arrived); fixed by carrying the cause in the message instead. Its should-fix items on \r progress, stack-frame picking and long lines are fixed with a test; the rest are noted above.

@terencecho terencecho left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVING at f12f3ece

Small, tight surface — 3 files, 125 lines. Verified the three specific claims:

(a) Correctly captures + extracts the container's last error line; handles empty/truncated stderr gracefully

  • createStderrTail() — buffer = (buffer + chunk).slice(-MAX_TAIL_CHARS) — bounded to 2000 chars (not bytes, so no mid-multibyte split), FIFO. tail() returns trimmed. Straightforward, no race window.
  • lastErrorLine(tail):
    • Splits by /[\r\n]+/ — handles \r-terminated progress bars (docker's per-frame output) as line boundaries.
    • Trims + filters empty + filters stack frames via /^at\s/.
    • Reverses the surviving lines and picks the first one matching /error|fail|exception|cannot|not found|denied|killed|no space/i — i.e., the last error-looking line in original order.
    • Falls back to the last non-empty non-stack line via ?? lines.at(-1).
    • Falls back to "" if no lines.
    • Slices to 160 chars.
  • DockerRenderExitError composes safely for empty stderr:
    super(`Docker render exited with code ${exitCode}${last ? `: ${last}` : ""}`);
    Empty tail → no suffix, no misleading ": " trailer. Test-pinned:
    expect(new DockerRenderExitError(1, "").message).toBe("Docker render exited with code 1");
  • Truncation case tested (5000-char push kept ≤2000, "END" preserved as the tail's end). Progress-bar \r case tested ("10%\r50%\rfailed to launch" → "failed to launch"). Stack-frame filter tested. Long-line cap tested (" ".repeat(500) → message length < 200).

(b) Live echo preserved

Old: stdio: options.quiet ? ["pipe", "pipe", "inherit"] : "inherit" — stderr inherited in both modes.
New: stdio: options.quiet ? ["pipe", "pipe", "pipe"] : ["inherit", "inherit", "pipe"] — stderr piped in both modes, but explicitly re-echoed:

child.stderr?.on("data", (chunk: Buffer) => {
  process.stderr.write(chunk);       // ← live echo, unchanged output stream
  stderrTail.push(chunk.toString());  // ← accumulate for the error message
});

Every chunk hits process.stderr.write synchronously before the tail buffer sees it. Same bytes on the same stream as before; only difference is the buffering side-effect. Trade-off called out in the PR body: docker's own CLI progress no longer sees a TTY (loses colour/progress bars), but container output is unaffected (no -t was ever passed to docker run anyway).

(c) No obvious sensitive-data leak into telemetry

  • Only the message field of DockerRenderExitError ships to telemetry — the constructor stores nothing else (no .stderrTail property, no .fullBuffer). The 2000-char rolling buffer is discarded once the error object is built.
  • The message carries exactly one line, capped at 160 chars, and that line is filtered to match error-shaped patterns (or a last-non-empty fallback). The full stderr never leaves the CLI process except via the same process.stderr write that always happened.
  • The keyword filter (error|fail|exception|cannot|not found|denied|killed|no space) is generic enough that a container-render error line like "Error: font file not found /workspace/assets/x.ttf" may contain a workspace-relative path — that's the intended surface (users need to know what failed) and it was already visible on stderr before this PR. No new class of data reaches telemetry that wasn't already on the operator's terminal.
  • No path in the diff writes the tail to disk or ships the full buffer over the network.

Fit + scope

  • Only renderDocker in packages/cli/src/commands/render.ts changed at call-site (2 hunks: the stdio choice + swapping the plain Error for DockerRenderExitError). child.on("error", ...) for spawn failures unchanged. child.on("close") (not "exit") — correct so stderr flushes before the reject fires.
  • New util + its test are packages/cli/src/utils/dockerStderrTail.{ts,test.ts}. Self-contained, no imports beyond stdlib.
  • PR body acknowledges the untested gap (spawn wiring needs a real docker binary) — fair; the utility is fully unit-tested and the wiring is a 2-line data listener.

Non-blocking nits

  • chunk.toString() uses default UTF-8; extremely rare edge case where a multi-byte char is split across chunk boundaries would produce a U+FFFD. Since these are error strings, cosmetic.

CI

Green at head (in-progress at post-time; approval binds to f12f3ece and the CI-settle poll will notify separately for reference).

Stamp mechanics

hf-oss require_last_push_approval=true → this approval binds only to f12f3ece. Miguel miguel-heygen on trusted stamp list; approving to merge per dispatch.

— Review by tai (pr-review)

@miguel-heygen
miguel-heygen merged commit 45f92e5 into main Sep 19, 2026
51 checks passed
@miguel-heygen
miguel-heygen deleted the fix/cli-docker-render-stderr branch September 19, 2026 11:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants