From 2de191cf6d2fb9ff909ba98753e95e73d1e12ef8 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Tue, 29 Sep 2026 15:19:17 +0200 Subject: [PATCH] deb: print raw build output again with --verbose The record-log wrapper provides a sink for every command, which removed the runner's subprocess-inherit fallback -- and with it the raw output of --verbose runs (and of piped stdout, where the live view disables itself): Quiet's None sink got wrapped inside, every line went to build.log, and the terminal showed nothing but pkh's own logs, breaking what --help still promises. When the caller's view has no sink of its own, a raw terminal sink (stdout/stderr writers, one per stream) now takes its place inside the record-log wrapper: the terminal shows the raw lines again while build.log keeps the full tee. A view that consumes lines itself (the live view on a tty) is wrapped exactly as before. --- src/deb/mod.rs | 75 ++++++++++++++++++++++++++++++++--- src/deb/session.rs | 98 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+), 5 deletions(-) diff --git a/src/deb/mod.rs b/src/deb/mod.rs index e738409..48a9b3d 100644 --- a/src/deb/mod.rs +++ b/src/deb/mod.rs @@ -364,11 +364,23 @@ async fn build_binary_package_impl( // Tee every captured build line into the record's log: the wrapper // view hands the combined sink to everything downstream (the chroot // bootstrap, the build phases), so the log is a full tee whatever - // renders the output. A record-less build (non-local base context) - // keeps the plain view. - let record_log: Option, std::io::Error>> = session - .as_ref() - .map(|s| session::RecordLogSink::new(s, opts.view.sink()).map(Arc::new)); + // renders the output. A view that consumes lines itself (the live + // view on a terminal) is wrapped as-is; a view with NO sink (Quiet, + // i.e. --verbose or a piped stdout) would have let the subprocess + // inherit the terminal — the wrapper removes that fallback, so the + // raw lines are written by a terminal sink instead, and --verbose + // keeps showing raw output. A record-less build (non-local base + // context) keeps the plain view. + let record_log: Option, std::io::Error>> = + session.as_ref().map(|s| { + session::RecordLogSink::new( + s, + opts.view + .sink() + .or_else(|| Some(Arc::new(session::RawWriterSink::terminal()))), + ) + .map(Arc::new) + }); let view: &dyn BuildView = match record_log { Some(Ok(log)) => &RecordLogView { inner: opts.view, @@ -1726,4 +1738,57 @@ mod tests { "wrong error: {err}" ); } + + /// The record-log view keeps the terminal alive for sink-less + /// views: when the caller's view has no sink of its own (Quiet — + /// `--verbose` or a piped stdout), the raw terminal sink takes its + /// place, and every captured line lands BOTH in the record's + /// build.log and on the writers (the terminal, in production). + #[test] + fn record_log_view_shows_raw_output_when_the_view_has_no_sink() { + use crate::context::{LineSink, Stream}; + use crate::deb::session::record_fixtures::SharedWriter; + + let (root, _tarball) = sessions_fixture(); + let identity = test_identity(); + let host_tree = root.path().join("tree"); + let record = session::new_record_in(root.path(), &host_tree, &identity, "1.0").unwrap(); + + // The production wiring, with the terminal sink replaced by + // injectable capture buffers: the Quiet view has no sink, so + // the raw writer sink fills the fallback. + let stdout = SharedWriter::new(); + let stderr = SharedWriter::new(); + let quiet = crate::report::Quiet; + let fallback: Option> = quiet.sink().or_else(|| { + Some(Arc::new(session::RawWriterSink::new( + Box::new(stdout.clone()), + Box::new(stderr.clone()), + ))) + }); + let log = Arc::new(session::RecordLogSink::new(&record, fallback).unwrap()); + let view = RecordLogView { inner: &quiet, log }; + + let sink = view + .sink() + .expect("the record-log view always provides one"); + sink.line(Stream::Stdout, "raw stdout line"); + sink.line(Stream::Stderr, "raw stderr line"); + + assert_eq!( + stdout.contents(), + "raw stdout line\n", + "the fallback writer received the raw line" + ); + assert_eq!( + stderr.contents(), + "raw stderr line\n", + "stderr lines reach the stderr writer" + ); + assert_eq!( + std::fs::read_to_string(record.log_file()).unwrap(), + "raw stdout line\nraw stderr line\n", + "build.log keeps the full tee" + ); + } } diff --git a/src/deb/session.rs b/src/deb/session.rs index 800c27d..7a0c54b 100644 --- a/src/deb/session.rs +++ b/src/deb/session.rs @@ -1524,6 +1524,49 @@ impl LineSink for RecordLogSink { } } +/// A sink writing captured lines to two raw writers, one per stream: +/// stdout lines to the first, stderr lines to the second. +/// +/// This is the record-log view's stand-in for the subprocess-inherit +/// fallback: with `--verbose` (or a piped stdout, where the live view +/// disables itself) the caller's view is [`crate::report::Quiet`], whose +/// sink is `None` — the runner would let the subprocess inherit the +/// terminal — but the record-log wrapper always provides a sink, so +/// the fallback is never reached anymore. Wrapping this sink in its +/// place keeps the raw lines on the terminal while the record's +/// build.log keeps the full tee. +pub(crate) struct RawWriterSink { + stdout: Mutex>, + stderr: Mutex>, +} + +impl RawWriterSink { + /// A sink over explicit writer pairs (tests capture into memory). + pub(crate) fn new(stdout: Box, stderr: Box) -> Self { + Self { + stdout: Mutex::new(stdout), + stderr: Mutex::new(stderr), + } + } + + /// A sink over the real terminal: the process's stdout and stderr. + pub(crate) fn terminal() -> Self { + Self::new(Box::new(io::stdout()), Box::new(io::stderr())) + } +} + +impl LineSink for RawWriterSink { + fn line(&self, stream: Stream, line: &str) { + let writer = match stream { + Stream::Stdout => &self.stdout, + Stream::Stderr => &self.stderr, + }; + if let Ok(mut out) = writer.lock() { + let _ = writeln!(out, "{line}"); + } + } +} + // --------------------------------------------------------------------------- // Host-tree snapshot and overlay upperdir sync // @@ -1854,6 +1897,34 @@ pub(crate) fn sync_upper_for_resume(session: &Session, host_parent: &Path) -> io pub(crate) mod record_fixtures { use super::*; + /// A writer copying everything it receives into a shared buffer: + /// lets tests observe what a `Box` sink wrote. + #[derive(Clone)] + pub(crate) struct SharedWriter(pub Arc>>); + + impl SharedWriter { + /// A fresh empty capture buffer. + pub(crate) fn new() -> Self { + Self(Arc::new(parking_lot::Mutex::new(Vec::new()))) + } + + /// What has been written so far. + pub(crate) fn contents(&self) -> String { + String::from_utf8_lossy(&self.0.lock()).into_owned() + } + } + + impl std::io::Write for SharedWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.lock().extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + pub fn test_identity() -> SessionIdentity { SessionIdentity { package: "pkh".to_string(), @@ -2739,6 +2810,33 @@ mod tests { assert_eq!(out.len(), 0); } + /// The raw writer sink routes each stream to its own writer, + /// newline-terminated: this is what keeps --verbose (and piped + /// runs) showing raw output under the record-log wrapper. + #[test] + fn raw_writer_sink_routes_streams_to_their_writers() { + use record_fixtures::SharedWriter; + + let stdout = SharedWriter::new(); + let stderr = SharedWriter::new(); + let sink = RawWriterSink::new(Box::new(stdout.clone()), Box::new(stderr.clone())); + + sink.line(Stream::Stdout, "from stdout"); + sink.line(Stream::Stderr, "from stderr"); + sink.line(Stream::Stdout, "second line"); + + assert_eq!( + stdout.contents(), + "from stdout\nsecond line\n", + "stdout lines land in order, newline-terminated" + ); + assert_eq!( + stderr.contents(), + "from stderr\n", + "stderr lines land on their own writer only" + ); + } + /// Ages render in the coarse units the list promises. #[test] fn age_rendering() {