From d5065b92c1903c4f30bb47d5a413490121b0ab28 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Mon, 3 Aug 2026 12:31:07 -0400 Subject: [PATCH 1/5] Preserve broken pipe errors in JSON output --- CHANGELOG.md | 1 + crates/relayburn-cli/src/commands/stamps.rs | 12 ++- crates/relayburn-cli/src/commands/update.rs | 2 +- crates/relayburn-cli/src/render/error.rs | 39 ++++++++- crates/relayburn-cli/src/render/json.rs | 35 +++++++- .../relayburn-cli/tests/json_broken_pipe.rs | 86 +++++++++++++++++++ 6 files changed, 162 insertions(+), 13 deletions(-) create mode 100644 crates/relayburn-cli/tests/json_broken_pipe.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index b1e6d95a..49e19728 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Cross-package release notes for relayburn. Package changelogs contain package-le ## [Unreleased] +- `burn --json` commands now exit quietly when a downstream pipe closes early, matching human output instead of reporting the pipe closure as a generic error. - Pricing recognizes Claude 5 and GPT-5.6 models, prefers first-party tariffs over reseller duplicates, and applies long-context price tiers. - `burn hotspots --findings` surfaces unknown model pricing explicitly and ranks unpriced sessions by token volume instead of treating them as $0.00. diff --git a/crates/relayburn-cli/src/commands/stamps.rs b/crates/relayburn-cli/src/commands/stamps.rs index bcaac5ca..39d0ef09 100644 --- a/crates/relayburn-cli/src/commands/stamps.rs +++ b/crates/relayburn-cli/src/commands/stamps.rs @@ -11,6 +11,7 @@ use std::fs::File; use std::io::{self, BufWriter, Write}; +use anyhow::Context; use relayburn_sdk::{ExportStampsOptions, Ledger, LedgerOpenOptions}; use crate::cli::{GlobalArgs, StampsArgs}; @@ -85,14 +86,11 @@ fn write_jsonl>( let mut count: usize = 0; for val in iter { serde_json::to_writer(&mut *writer, &val) - .map_err(|err| anyhow::anyhow!("failed to serialize stamp: {}", err))?; - writer - .write_all(b"\n") - .map_err(|err| anyhow::anyhow!("failed to write stamp: {}", err))?; + .map_err(crate::render::json::serde_error_to_io) + .context("failed to serialize stamp")?; + writer.write_all(b"\n").context("failed to write stamp")?; count += 1; } - writer - .flush() - .map_err(|err| anyhow::anyhow!("failed to flush output: {}", err))?; + writer.flush().context("failed to flush output")?; Ok(count) } diff --git a/crates/relayburn-cli/src/commands/update.rs b/crates/relayburn-cli/src/commands/update.rs index cdd533dc..01e2ebea 100644 --- a/crates/relayburn-cli/src/commands/update.rs +++ b/crates/relayburn-cli/src/commands/update.rs @@ -150,7 +150,7 @@ fn unknown_channel_error() -> anyhow::Error { fn print_json(value: &serde_json::Value) -> std::io::Result<()> { use std::io::Write; let mut out = std::io::stdout().lock(); - serde_json::to_writer(&mut out, value).map_err(std::io::Error::other)?; + serde_json::to_writer(&mut out, value).map_err(crate::render::json::serde_error_to_io)?; out.write_all(b"\n") } diff --git a/crates/relayburn-cli/src/render/error.rs b/crates/relayburn-cli/src/render/error.rs index 371ad683..d0f1c564 100644 --- a/crates/relayburn-cli/src/render/error.rs +++ b/crates/relayburn-cli/src/render/error.rs @@ -10,8 +10,8 @@ //! stack. Always falls through to a generic `2` exit code with the //! `Display` form of the error on stderr. //! - `std::io::Error` — broken pipe / write-to-stdout failures from -//! the rendering helpers. Mapped to exit code `2`, EPIPE silenced -//! (matches Unix tools-as-citizen conventions). +//! the rendering helpers. EPIPE exits quietly with code `0` (matching +//! Unix pipeline conventions); other I/O failures map to code `2`. //! //! Every helper here writes to stderr in human mode and writes a //! `{"error": "..."}` envelope to stdout in `--json` mode, then returns @@ -25,6 +25,7 @@ #![allow(dead_code)] +use std::any::Any; use std::io::{self, Write}; use serde_json::json; @@ -53,10 +54,31 @@ pub fn report_ledger_error(err: &LedgerError, globals: &GlobalArgs) -> i32 { /// Map any other error (anyhow, io, etc.) to a stderr message + exit /// code. Use this when the error comes from a non-SDK boundary or when /// the command handler chose to propagate as `anyhow::Error`. -pub fn report_error(err: &E, globals: &GlobalArgs) -> i32 { +pub fn report_error(err: &E, globals: &GlobalArgs) -> i32 { + if is_broken_pipe(err) { + return 0; + } report(globals, &err.to_string(), EXIT_GENERIC_ERROR) } +/// Recognize output pipes closed by an early-exiting consumer. Presenter +/// functions return either a direct `io::Error` or an `anyhow::Error` that +/// retains it in its chain, so cover both shapes at the shared boundary. +fn is_broken_pipe(err: &E) -> bool { + let err = err as &dyn Any; + if let Some(err) = err.downcast_ref::() { + return err.kind() == io::ErrorKind::BrokenPipe; + } + if let Some(err) = err.downcast_ref::() { + return err.chain().any(|cause| { + cause + .downcast_ref::() + .is_some_and(|err| err.kind() == io::ErrorKind::BrokenPipe) + }); + } + false +} + /// `not yet implemented` exit path used by every command stub in this /// scaffold PR. Keeps the message format consistent across the /// subcommands so the smoke test can assert on it without each command @@ -98,7 +120,7 @@ fn report(globals: &GlobalArgs, message: &str, code: i32) -> i32 { fn write_json_envelope(value: &serde_json::Value) -> io::Result<()> { let stdout = io::stdout(); let mut handle = stdout.lock(); - serde_json::to_writer(&mut handle, value).map_err(io::Error::other)?; + serde_json::to_writer(&mut handle, value).map_err(crate::render::json::serde_error_to_io)?; handle.write_all(b"\n")?; handle.flush() } @@ -144,6 +166,15 @@ mod tests { assert_eq!(report_error(&err, &human_globals()), EXIT_GENERIC_ERROR); } + #[test] + fn broken_pipe_exits_zero_without_reporting() { + let direct = io::Error::from(io::ErrorKind::BrokenPipe); + assert_eq!(report_error(&direct, &human_globals()), 0); + + let wrapped = anyhow::Error::from(io::Error::from(io::ErrorKind::BrokenPipe)); + assert_eq!(report_error(&wrapped, &json_globals()), 0); + } + #[test] fn ledger_error_uses_exit_three() { let err = LedgerError::Other("ledger boom".into()); diff --git a/crates/relayburn-cli/src/render/json.rs b/crates/relayburn-cli/src/render/json.rs index bd654614..500884e2 100644 --- a/crates/relayburn-cli/src/render/json.rs +++ b/crates/relayburn-cli/src/render/json.rs @@ -20,16 +20,42 @@ use serde::Serialize; pub fn render_json(value: &T) -> io::Result<()> { let stdout = io::stdout(); let mut handle = stdout.lock(); - serde_json::to_writer_pretty(&mut handle, value).map_err(io::Error::other)?; + write_json_pretty(&mut handle, value)?; handle.write_all(b"\n")?; handle.flush() } +fn write_json_pretty(writer: &mut W, value: &T) -> io::Result<()> { + serde_json::to_writer_pretty(writer, value).map_err(serde_error_to_io) +} + +/// Convert a serde failure back to an I/O error without erasing the error +/// kind reported by the writer. In particular, callers rely on +/// `BrokenPipe` to treat an early-closing pipeline as a successful exit. +pub(crate) fn serde_error_to_io(err: serde_json::Error) -> io::Error { + match err.io_error_kind() { + Some(kind) => io::Error::new(kind, err), + None => io::Error::other(err), + } +} + #[cfg(test)] mod tests { use super::*; use serde_json::json; + struct BrokenPipeWriter; + + impl Write for BrokenPipeWriter { + fn write(&mut self, _buf: &[u8]) -> io::Result { + Err(io::Error::from(io::ErrorKind::BrokenPipe)) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + // Smoke test: the helper should accept anything `Serialize` and // not panic. Real I/O assertions live in the integration smoke // test under `tests/smoke.rs` which drives the binary end-to-end. @@ -37,4 +63,11 @@ mod tests { fn render_json_accepts_arbitrary_serialize_input() { assert!(render_json(&json!({ "ok": true, "rows": [1, 2, 3] })).is_ok()); } + + #[test] + fn json_writer_preserves_broken_pipe_kind() { + let err = write_json_pretty(&mut BrokenPipeWriter, &json!({ "ok": true })) + .expect_err("writer should close early"); + assert_eq!(err.kind(), io::ErrorKind::BrokenPipe); + } } diff --git a/crates/relayburn-cli/tests/json_broken_pipe.rs b/crates/relayburn-cli/tests/json_broken_pipe.rs new file mode 100644 index 00000000..9ff3a45d --- /dev/null +++ b/crates/relayburn-cli/tests/json_broken_pipe.rs @@ -0,0 +1,86 @@ +//! End-to-end coverage for JSON output piped to an early-closing consumer. + +use std::io::Read; +use std::process::{Command, Stdio}; + +use relayburn_sdk::{Ledger, LedgerOpenOptions, SourceKind, TurnRecord, Usage}; + +const SESSION_COUNT: usize = 4_096; + +fn turn(index: usize) -> TurnRecord { + // Keep every session id long as well as unique. The resulting JSON is + // several MiB, so it cannot fit in a pipe buffer before the reader closes. + let suffix = "x".repeat(512); + TurnRecord { + v: 1, + source: SourceKind::ClaudeCode, + session_id: format!("session_{index:04}_{suffix}"), + session_path: None, + message_id: format!("message_{index:04}"), + turn_index: 0, + ts: "2026-08-03T00:00:00.000Z".into(), + model: "claude-sonnet-4-6".into(), + project: Some(format!("/tmp/project/{index:04}")), + project_key: None, + usage: Usage { + input: 100, + output: 50, + reasoning: 0, + cache_read: 0, + cache_create_5m: 0, + cache_create_1h: 0, + }, + tool_calls: Vec::new(), + files_touched: None, + subagent: None, + stop_reason: None, + activity: None, + retries: None, + has_edits: None, + fidelity: None, + } +} + +#[test] +fn large_json_output_exits_zero_when_consumer_closes_early() { + let home = tempfile::TempDir::new().expect("temporary ledger home"); + let mut ledger = Ledger::open(LedgerOpenOptions::with_home(home.path())).expect("open ledger"); + let turns: Vec<_> = (0..SESSION_COUNT).map(turn).collect(); + ledger + .raw_mut() + .append_turns(&turns) + .expect("seed large session list"); + drop(ledger); + + let mut child = Command::new(env!("CARGO_BIN_EXE_burn")) + .args([ + "--json", + "--ledger-path", + home.path().to_str().expect("UTF-8 temp path"), + "sessions", + "list", + "--since", + "1970-01-01T00:00:00Z", + "--limit", + &SESSION_COUNT.to_string(), + ]) + .env("NO_COLOR", "1") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn burn"); + + let mut stdout = child.stdout.take().expect("piped stdout"); + let mut prefix = [0_u8; 2]; + stdout.read_exact(&mut prefix).expect("read JSON prefix"); + assert_eq!(&prefix, b"{\n"); + drop(stdout); + + let output = child.wait_with_output().expect("wait for burn"); + assert!( + output.status.success(), + "early pipe closure should exit 0, got {:?}; stderr: {}", + output.status.code(), + String::from_utf8_lossy(&output.stderr), + ); +} From 0666a8123a6a98fe5d4aecbc6dd2b3f1693fd316 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Mon, 3 Aug 2026 12:41:01 -0400 Subject: [PATCH 2/5] Scope broken pipe handling to JSON stdout --- CHANGELOG.md | 2 +- Cargo.toml | 2 +- crates/relayburn-cli/src/commands/update.rs | 5 ++- crates/relayburn-cli/src/render/error.rs | 37 +++++++++++++------ crates/relayburn-cli/src/render/json.rs | 40 +++++++++++++++++++-- 5 files changed, 70 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49e19728..83280f06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ Cross-package release notes for relayburn. Package changelogs contain package-le ## [Unreleased] -- `burn --json` commands now exit quietly when a downstream pipe closes early, matching human output instead of reporting the pipe closure as a generic error. +- `burn --json` commands now exit quietly when a downstream pipe closes early instead of reporting the pipe closure as a generic error. - Pricing recognizes Claude 5 and GPT-5.6 models, prefers first-party tariffs over reseller duplicates, and applies long-context price tiers. - `burn hotspots --findings` surfaces unknown model pricing explicitly and ranks unpriced sessions by token volume instead of treating them as $0.00. diff --git a/Cargo.toml b/Cargo.toml index eeec6760..f9876506 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ repository = "https://github.com/AgentWorkforce/burn" [workspace.dependencies] serde = { version = "1", features = ["derive"] } -serde_json = "1" +serde_json = "1.0.140" uuid = { version = "1", features = ["v4"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "io-util", "time"] } anyhow = "1" diff --git a/crates/relayburn-cli/src/commands/update.rs b/crates/relayburn-cli/src/commands/update.rs index 01e2ebea..283561e1 100644 --- a/crates/relayburn-cli/src/commands/update.rs +++ b/crates/relayburn-cli/src/commands/update.rs @@ -150,8 +150,11 @@ fn unknown_channel_error() -> anyhow::Error { fn print_json(value: &serde_json::Value) -> std::io::Result<()> { use std::io::Write; let mut out = std::io::stdout().lock(); - serde_json::to_writer(&mut out, value).map_err(crate::render::json::serde_error_to_io)?; + serde_json::to_writer(&mut out, value) + .map_err(crate::render::json::serde_error_to_io) + .map_err(crate::render::json::stdout_error)?; out.write_all(b"\n") + .map_err(crate::render::json::stdout_error) } fn now_unix() -> i64 { diff --git a/crates/relayburn-cli/src/render/error.rs b/crates/relayburn-cli/src/render/error.rs index d0f1c564..2ccc12f6 100644 --- a/crates/relayburn-cli/src/render/error.rs +++ b/crates/relayburn-cli/src/render/error.rs @@ -61,19 +61,20 @@ pub fn report_error(err: &E, globals: &GlobalArg report(globals, &err.to_string(), EXIT_GENERIC_ERROR) } -/// Recognize output pipes closed by an early-exiting consumer. Presenter -/// functions return either a direct `io::Error` or an `anyhow::Error` that -/// retains it in its chain, so cover both shapes at the shared boundary. +/// Recognize stdout pipes closed by an early-exiting consumer. JSON writers +/// mark those errors before returning either a direct `io::Error` or an +/// `anyhow::Error` that retains it in its chain. File/FIFO errors lack that +/// marker and remain failures. fn is_broken_pipe(err: &E) -> bool { let err = err as &dyn Any; if let Some(err) = err.downcast_ref::() { - return err.kind() == io::ErrorKind::BrokenPipe; + return crate::render::json::is_stdout_broken_pipe(err); } if let Some(err) = err.downcast_ref::() { return err.chain().any(|cause| { cause .downcast_ref::() - .is_some_and(|err| err.kind() == io::ErrorKind::BrokenPipe) + .is_some_and(crate::render::json::is_stdout_broken_pipe) }); } false @@ -120,9 +121,13 @@ fn report(globals: &GlobalArgs, message: &str, code: i32) -> i32 { fn write_json_envelope(value: &serde_json::Value) -> io::Result<()> { let stdout = io::stdout(); let mut handle = stdout.lock(); - serde_json::to_writer(&mut handle, value).map_err(crate::render::json::serde_error_to_io)?; - handle.write_all(b"\n")?; - handle.flush() + serde_json::to_writer(&mut handle, value) + .map_err(crate::render::json::serde_error_to_io) + .map_err(crate::render::json::stdout_error)?; + handle + .write_all(b"\n") + .map_err(crate::render::json::stdout_error)?; + handle.flush().map_err(crate::render::json::stdout_error) } #[cfg(test)] @@ -168,13 +173,25 @@ mod tests { #[test] fn broken_pipe_exits_zero_without_reporting() { - let direct = io::Error::from(io::ErrorKind::BrokenPipe); + let direct = crate::render::json::stdout_error(io::Error::from(io::ErrorKind::BrokenPipe)); assert_eq!(report_error(&direct, &human_globals()), 0); - let wrapped = anyhow::Error::from(io::Error::from(io::ErrorKind::BrokenPipe)); + let wrapped = anyhow::Error::from(crate::render::json::stdout_error(io::Error::from( + io::ErrorKind::BrokenPipe, + ))); assert_eq!(report_error(&wrapped, &json_globals()), 0); } + #[test] + fn non_stdout_broken_pipe_remains_an_error() { + let err = io::Error::from(io::ErrorKind::BrokenPipe); + assert_eq!(report_error(&err, &human_globals()), EXIT_GENERIC_ERROR); + + let wrapped = anyhow::Error::from(io::Error::from(io::ErrorKind::BrokenPipe)) + .context("failed to write export file"); + assert_eq!(report_error(&wrapped, &human_globals()), EXIT_GENERIC_ERROR); + } + #[test] fn ledger_error_uses_exit_three() { let err = LedgerError::Other("ledger boom".into()); diff --git a/crates/relayburn-cli/src/render/json.rs b/crates/relayburn-cli/src/render/json.rs index 500884e2..b2cc7f28 100644 --- a/crates/relayburn-cli/src/render/json.rs +++ b/crates/relayburn-cli/src/render/json.rs @@ -10,6 +10,8 @@ //! whole-valued `f64`s print as bare integers) should run their value //! through [`crate::render::format::coerce_whole_f64_to_int`] first. +use std::error::Error; +use std::fmt; use std::io::{self, Write}; use serde::Serialize; @@ -21,12 +23,44 @@ pub fn render_json(value: &T) -> io::Result<()> { let stdout = io::stdout(); let mut handle = stdout.lock(); write_json_pretty(&mut handle, value)?; - handle.write_all(b"\n")?; - handle.flush() + handle.write_all(b"\n").map_err(stdout_error)?; + handle.flush().map_err(stdout_error) } fn write_json_pretty(writer: &mut W, value: &T) -> io::Result<()> { - serde_json::to_writer_pretty(writer, value).map_err(serde_error_to_io) + serde_json::to_writer_pretty(writer, value) + .map_err(serde_error_to_io) + .map_err(stdout_error) +} + +#[derive(Debug)] +struct StdoutError(io::Error); + +impl fmt::Display for StdoutError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(formatter) + } +} + +impl Error for StdoutError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + Some(&self.0) + } +} + +/// Mark an I/O error as originating from the process stdout renderer while +/// retaining its kind. The marker lets shared reporting distinguish a normal +/// early-closing pipeline from an EPIPE raised by a file or FIFO writer. +pub(crate) fn stdout_error(err: io::Error) -> io::Error { + let kind = err.kind(); + io::Error::new(kind, StdoutError(err)) +} + +pub(crate) fn is_stdout_broken_pipe(err: &io::Error) -> bool { + err.kind() == io::ErrorKind::BrokenPipe + && err + .get_ref() + .is_some_and(|source| source.is::()) } /// Convert a serde failure back to an I/O error without erasing the error From 2fec7ab201286a0766034b1a89f241cdcaf279dd Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Mon, 3 Aug 2026 12:46:31 -0400 Subject: [PATCH 3/5] Handle broken pipes in stamp stdout exports --- crates/relayburn-cli/src/commands/stamps.rs | 33 ++++++++++-- crates/relayburn-cli/src/render/json.rs | 6 +-- .../relayburn-cli/tests/json_broken_pipe.rs | 52 ++++++++++++++++++- 3 files changed, 82 insertions(+), 9 deletions(-) diff --git a/crates/relayburn-cli/src/commands/stamps.rs b/crates/relayburn-cli/src/commands/stamps.rs index 39d0ef09..d145d528 100644 --- a/crates/relayburn-cli/src/commands/stamps.rs +++ b/crates/relayburn-cli/src/commands/stamps.rs @@ -21,6 +21,12 @@ use crate::render::progress::TaskProgress; /// Default output is stdout ("-") const DEFAULT_OUT: &str = "-"; +#[derive(Clone, Copy)] +enum OutputTarget { + Stdout, + File, +} + pub fn run(globals: &GlobalArgs, args: StampsArgs) -> i32 { match args.command { crate::cli::StampsSubcommand::Export(export_args) => run_export(globals, export_args), @@ -55,10 +61,14 @@ fn run_export(globals: &GlobalArgs, args: crate::cli::StampsExportArgs) -> i32 { let out_path = args.out.as_deref().unwrap_or(DEFAULT_OUT); let result = if out_path == "-" { let stdout = io::stdout(); - write_jsonl(&mut BufWriter::new(stdout.lock()), iter) + write_jsonl( + &mut BufWriter::new(stdout.lock()), + iter, + OutputTarget::Stdout, + ) } else { match File::create(out_path) { - Ok(file) => write_jsonl(&mut BufWriter::new(file), iter), + Ok(file) => write_jsonl(&mut BufWriter::new(file), iter, OutputTarget::File), Err(err) => Err(anyhow::anyhow!("failed to open output file: {}", err)), } }; @@ -82,15 +92,30 @@ fn run_export(globals: &GlobalArgs, args: crate::cli::StampsExportArgs) -> i32 { fn write_jsonl>( writer: &mut W, iter: I, + target: OutputTarget, ) -> anyhow::Result { let mut count: usize = 0; for val in iter { serde_json::to_writer(&mut *writer, &val) .map_err(crate::render::json::serde_error_to_io) + .map_err(|err| mark_stdout(err, target)) .context("failed to serialize stamp")?; - writer.write_all(b"\n").context("failed to write stamp")?; + writer + .write_all(b"\n") + .map_err(|err| mark_stdout(err, target)) + .context("failed to write stamp")?; count += 1; } - writer.flush().context("failed to flush output")?; + writer + .flush() + .map_err(|err| mark_stdout(err, target)) + .context("failed to flush output")?; Ok(count) } + +fn mark_stdout(err: io::Error, target: OutputTarget) -> io::Error { + match target { + OutputTarget::Stdout => crate::render::json::stdout_error(err), + OutputTarget::File => err, + } +} diff --git a/crates/relayburn-cli/src/render/json.rs b/crates/relayburn-cli/src/render/json.rs index b2cc7f28..3b684639 100644 --- a/crates/relayburn-cli/src/render/json.rs +++ b/crates/relayburn-cli/src/render/json.rs @@ -22,15 +22,13 @@ use serde::Serialize; pub fn render_json(value: &T) -> io::Result<()> { let stdout = io::stdout(); let mut handle = stdout.lock(); - write_json_pretty(&mut handle, value)?; + write_json_pretty(&mut handle, value).map_err(stdout_error)?; handle.write_all(b"\n").map_err(stdout_error)?; handle.flush().map_err(stdout_error) } fn write_json_pretty(writer: &mut W, value: &T) -> io::Result<()> { - serde_json::to_writer_pretty(writer, value) - .map_err(serde_error_to_io) - .map_err(stdout_error) + serde_json::to_writer_pretty(writer, value).map_err(serde_error_to_io) } #[derive(Debug)] diff --git a/crates/relayburn-cli/tests/json_broken_pipe.rs b/crates/relayburn-cli/tests/json_broken_pipe.rs index 9ff3a45d..461db1bc 100644 --- a/crates/relayburn-cli/tests/json_broken_pipe.rs +++ b/crates/relayburn-cli/tests/json_broken_pipe.rs @@ -3,7 +3,9 @@ use std::io::Read; use std::process::{Command, Stdio}; -use relayburn_sdk::{Ledger, LedgerOpenOptions, SourceKind, TurnRecord, Usage}; +use relayburn_sdk::{ + Enrichment, Ledger, LedgerOpenOptions, SourceKind, Stamp, StampSelector, TurnRecord, Usage, +}; const SESSION_COUNT: usize = 4_096; @@ -84,3 +86,51 @@ fn large_json_output_exits_zero_when_consumer_closes_early() { String::from_utf8_lossy(&output.stderr), ); } + +#[test] +fn large_jsonl_export_exits_zero_when_consumer_closes_early() { + let home = tempfile::TempDir::new().expect("temporary ledger home"); + let mut ledger = Ledger::open(LedgerOpenOptions::with_home(home.path())).expect("open ledger"); + for index in 0..2_048 { + let mut enrichment = Enrichment::new(); + enrichment.insert("payload".into(), "x".repeat(2_048)); + let stamp = Stamp::new( + "2026-08-03T00:00:00.000Z", + StampSelector { + session_id: Some(format!("session_{index:04}")), + ..Default::default() + }, + enrichment, + ) + .expect("valid stamp"); + ledger.raw_mut().append_stamp(&stamp).expect("append stamp"); + } + drop(ledger); + + let mut child = Command::new(env!("CARGO_BIN_EXE_burn")) + .args([ + "--ledger-path", + home.path().to_str().expect("UTF-8 temp path"), + "stamps", + "export", + ]) + .env("NO_COLOR", "1") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn burn"); + + let mut stdout = child.stdout.take().expect("piped stdout"); + let mut prefix = [0_u8; 2]; + stdout.read_exact(&mut prefix).expect("read JSONL prefix"); + assert_eq!(&prefix, b"{\""); + drop(stdout); + + let output = child.wait_with_output().expect("wait for burn"); + assert!( + output.status.success(), + "early JSONL pipe closure should exit 0, got {:?}; stderr: {}", + output.status.code(), + String::from_utf8_lossy(&output.stderr), + ); +} From 057332d09d4fef4d026dc1c0a3bf43b129a05cce Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Mon, 3 Aug 2026 12:49:10 -0400 Subject: [PATCH 4/5] Document stamp export pipe handling --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83280f06..7ba86a9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ Cross-package release notes for relayburn. Package changelogs contain package-le ## [Unreleased] -- `burn --json` commands now exit quietly when a downstream pipe closes early instead of reporting the pipe closure as a generic error. +- `burn --json` commands and stdout `burn stamps export` streams now exit quietly when a downstream pipe closes early instead of reporting the pipe closure as a generic error. - Pricing recognizes Claude 5 and GPT-5.6 models, prefers first-party tariffs over reseller duplicates, and applies long-context price tiers. - `burn hotspots --findings` surfaces unknown model pricing explicitly and ranks unpriced sessions by token volume instead of treating them as $0.00. From a67039e3262356cb307adb796616b758d8efc294 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Mon, 3 Aug 2026 13:09:00 -0400 Subject: [PATCH 5/5] Harden JSON pipe regression fixture --- crates/relayburn-cli/src/commands/update.rs | 5 +---- crates/relayburn-cli/tests/json_broken_pipe.rs | 5 +++-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/crates/relayburn-cli/src/commands/update.rs b/crates/relayburn-cli/src/commands/update.rs index 283561e1..01e2ebea 100644 --- a/crates/relayburn-cli/src/commands/update.rs +++ b/crates/relayburn-cli/src/commands/update.rs @@ -150,11 +150,8 @@ fn unknown_channel_error() -> anyhow::Error { fn print_json(value: &serde_json::Value) -> std::io::Result<()> { use std::io::Write; let mut out = std::io::stdout().lock(); - serde_json::to_writer(&mut out, value) - .map_err(crate::render::json::serde_error_to_io) - .map_err(crate::render::json::stdout_error)?; + serde_json::to_writer(&mut out, value).map_err(crate::render::json::serde_error_to_io)?; out.write_all(b"\n") - .map_err(crate::render::json::stdout_error) } fn now_unix() -> i64 { diff --git a/crates/relayburn-cli/tests/json_broken_pipe.rs b/crates/relayburn-cli/tests/json_broken_pipe.rs index 461db1bc..32d62eff 100644 --- a/crates/relayburn-cli/tests/json_broken_pipe.rs +++ b/crates/relayburn-cli/tests/json_broken_pipe.rs @@ -20,7 +20,7 @@ fn turn(index: usize) -> TurnRecord { session_path: None, message_id: format!("message_{index:04}"), turn_index: 0, - ts: "2026-08-03T00:00:00.000Z".into(), + ts: format!("2026-08-03T00:00:00.{index:09}Z"), model: "claude-sonnet-4-6".into(), project: Some(format!("/tmp/project/{index:04}")), project_key: None, @@ -48,10 +48,11 @@ fn large_json_output_exits_zero_when_consumer_closes_early() { let home = tempfile::TempDir::new().expect("temporary ledger home"); let mut ledger = Ledger::open(LedgerOpenOptions::with_home(home.path())).expect("open ledger"); let turns: Vec<_> = (0..SESSION_COUNT).map(turn).collect(); - ledger + let appended = ledger .raw_mut() .append_turns(&turns) .expect("seed large session list"); + assert_eq!(appended, SESSION_COUNT, "fixture rows must not deduplicate"); drop(ledger); let mut child = Command::new(env!("CARGO_BIN_EXE_burn"))