Skip to content

Commit 022883e

Browse files
authored
Merge pull request #626 from nix-community/notashelf/push-zlyknnslyqoz
Fix output visibility for nix copy commands after subprocess update
2 parents 676e70e + b2043a7 commit 022883e

3 files changed

Lines changed: 168 additions & 38 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ serde_json = "1.0.145"
4848
serial_test = "3.2.0"
4949
shlex = "1.3.0"
5050
signal-hook = "0.4.1"
51-
subprocess = "1.0.0"
51+
subprocess = "1.0.3"
5252
supports-hyperlinks = "3.1.0"
5353
system-configuration = "0.7.0"
5454
tempfile = "3.23.0"

crates/nh-core/src/command.rs

Lines changed: 142 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use std::{
22
collections::HashMap,
33
convert::Infallible,
44
ffi::{OsStr, OsString},
5+
io::{Read, Write},
56
path::PathBuf,
67
str::FromStr,
78
sync::{Mutex, OnceLock},
@@ -19,6 +20,118 @@ use which::which;
1920

2021
use crate::{args::NixBuildPassthroughArgs, installable::Installable};
2122

23+
/// Execute a command, streaming output to stdout/stderr while optionally
24+
/// capturing it for error reporting.
25+
///
26+
/// # Arguments
27+
///
28+
/// * `capture_output` - When `true`, stdout and stderr are accumulated and
29+
/// returned as strings. When `false`, output is streamed but not captured.
30+
///
31+
/// # Returns
32+
///
33+
/// Returns the exit status and captured stdout/stderr (or empty strings if
34+
/// `capture_output` is `false`), or an error.
35+
///
36+
/// # Errors
37+
///
38+
/// Returns an error if:
39+
///
40+
/// - The command fails to start
41+
/// - stdout or stderr cannot be captured
42+
/// - The command fails to complete
43+
/// - Either output thread panics
44+
pub fn exec_with_streaming(
45+
cmd: Exec,
46+
capture_output: bool,
47+
) -> Result<(subprocess::ExitStatus, String, String)> {
48+
let mut job = cmd
49+
.stdout(Redirection::Pipe)
50+
.start()
51+
.wrap_err("Failed to start command")?;
52+
53+
let stdout_pipe = job
54+
.stdout
55+
.take()
56+
.ok_or_else(|| eyre::eyre!("Failed to capture stdout"))?;
57+
58+
let stdout_thread = std::thread::spawn(move || {
59+
let mut stdout_reader = std::io::BufReader::new(stdout_pipe);
60+
let mut stdout_bytes = Vec::new();
61+
let mut stdout_buf = [0u8; 4096];
62+
63+
loop {
64+
match stdout_reader.read(&mut stdout_buf) {
65+
Ok(0) => break,
66+
Ok(n) => {
67+
let _ = std::io::stdout().write_all(&stdout_buf[..n]);
68+
let _ = std::io::stdout().flush();
69+
if capture_output {
70+
stdout_bytes.extend_from_slice(&stdout_buf[..n]);
71+
}
72+
},
73+
Err(e) => {
74+
debug!("stdout read error: {e}");
75+
break;
76+
},
77+
}
78+
}
79+
80+
if capture_output {
81+
String::from_utf8_lossy(&stdout_bytes).into_owned()
82+
} else {
83+
String::new()
84+
}
85+
});
86+
87+
let stderr_thread = if let Some(stderr_pipe) = job.stderr.take() {
88+
Some(std::thread::spawn(move || {
89+
let mut stderr_reader = std::io::BufReader::new(stderr_pipe);
90+
let mut stderr_bytes = Vec::new();
91+
let mut stderr_buf = [0u8; 4096];
92+
93+
loop {
94+
match stderr_reader.read(&mut stderr_buf) {
95+
Ok(0) => break,
96+
Ok(n) => {
97+
let _ = std::io::stderr().write_all(&stderr_buf[..n]);
98+
let _ = std::io::stderr().flush();
99+
if capture_output {
100+
stderr_bytes.extend_from_slice(&stderr_buf[..n]);
101+
}
102+
},
103+
Err(e) => {
104+
debug!("stderr read error: {e}");
105+
break;
106+
},
107+
}
108+
}
109+
110+
if capture_output {
111+
String::from_utf8_lossy(&stderr_bytes).into_owned()
112+
} else {
113+
String::new()
114+
}
115+
}))
116+
} else {
117+
None
118+
};
119+
120+
let exit_status = job
121+
.wait()
122+
.wrap_err("Failed to wait for command completion")?;
123+
124+
let stdout_output = stdout_thread
125+
.join()
126+
.map_err(|_| eyre::eyre!("Stdout thread panicked"))?;
127+
let stderr_output = stderr_thread
128+
.map(|t| t.join().map_err(|_| eyre::eyre!("Stderr thread panicked")))
129+
.transpose()?
130+
.unwrap_or_default();
131+
132+
Ok((exit_status, stdout_output, stderr_output))
133+
}
134+
22135
static PASSWORD_CACHE: OnceLock<Mutex<HashMap<String, SecretString>>> =
23136
OnceLock::new();
24137

@@ -729,26 +842,38 @@ impl Command {
729842
.message
730843
.clone()
731844
.unwrap_or_else(|| "Command failed".to_string());
732-
let res = cmd.capture();
733-
match res {
734-
Ok(capture) => {
735-
let status = &capture.exit_status;
736-
if !status.success() {
737-
let stderr = capture.stderr_str();
738-
if stderr.trim().is_empty() {
845+
846+
if self.show_output {
847+
let exit_status = cmd.join().wrap_err(msg.clone())?;
848+
if !exit_status.success() {
849+
return Err(eyre::eyre!(format!(
850+
"{} (exit status {:?})",
851+
msg, exit_status
852+
)));
853+
}
854+
Ok(())
855+
} else {
856+
let res = cmd.capture();
857+
match res {
858+
Ok(capture) => {
859+
let status = &capture.exit_status;
860+
if !status.success() {
861+
let stderr = capture.stderr_str();
862+
if stderr.trim().is_empty() {
863+
return Err(eyre::eyre!(format!(
864+
"{} (exit status {:?})",
865+
msg, status
866+
)));
867+
}
739868
return Err(eyre::eyre!(format!(
740-
"{} (exit status {:?})",
741-
msg, status
869+
"{} (exit status {:?})\nstderr:\n{}",
870+
msg, status, stderr
742871
)));
743872
}
744-
return Err(eyre::eyre!(format!(
745-
"{} (exit status {:?})\nstderr:\n{}",
746-
msg, status, stderr
747-
)));
748-
}
749-
Ok(())
750-
},
751-
Err(e) => Err(e).wrap_err(msg),
873+
Ok(())
874+
},
875+
Err(e) => Err(e).wrap_err(msg),
876+
}
752877
}
753878
}
754879

crates/nh-remote/src/remote.rs

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,12 @@ use color_eyre::{
1818
};
1919
use indicatif::{ProgressBar, ProgressStyle};
2020
use nh_core::{
21-
command::{ElevationStrategy, cache_password, get_cached_password},
21+
command::{
22+
ElevationStrategy,
23+
cache_password,
24+
exec_with_streaming,
25+
get_cached_password,
26+
},
2227
installable::Installable,
2328
util::NixVariant,
2429
};
@@ -821,15 +826,14 @@ fn copy_closure_to(
821826

822827
debug!(?cmd, "nix-copy-closure --to");
823828

824-
let capture = cmd
825-
.capture()
829+
let (exit_status, _stdout, _stderr) = exec_with_streaming(cmd, false)
826830
.wrap_err("Failed to copy closure to remote host")?;
827831

828-
if !capture.exit_status.success() {
832+
if !exit_status.success() {
829833
bail!(
830-
"nix-copy-closure --to '{}' failed:\n{}",
834+
"nix-copy-closure --to '{}' failed (exit status: {:?})",
831835
host,
832-
capture.stderr_str()
836+
exit_status
833837
);
834838
}
835839

@@ -974,15 +978,14 @@ fn copy_closure_from(
974978

975979
debug!(?cmd, "nix-copy-closure --from");
976980

977-
let capture = cmd
978-
.capture()
981+
let (exit_status, _stdout, _stderr) = exec_with_streaming(cmd, false)
979982
.wrap_err("Failed to copy closure from remote host")?;
980983

981-
if !capture.exit_status.success() {
984+
if !exit_status.success() {
982985
bail!(
983-
"nix-copy-closure --from '{}' failed:\n{}",
986+
"nix-copy-closure --from '{}' failed (exit status: {:?})",
984987
host,
985-
capture.stderr_str()
988+
exit_status
986989
);
987990
}
988991

@@ -1056,17 +1059,20 @@ pub fn copy_to_remote(
10561059
spinner.set_message(format!("Copying closure to remote host '{host}'..."));
10571060
spinner.enable_steady_tick(Duration::from_millis(80));
10581061

1059-
let capture = cmd
1060-
.capture()
1062+
let (exit_status, _stdout, _stderr) = exec_with_streaming(cmd, false)
10611063
.wrap_err("Failed to copy closure to remote host")?;
10621064

10631065
// We finish and *clear*, because the log line needs to come next. If we try
10641066
// to make the spinner change the text, we cannot reliably match the `info!`
10651067
// or `error!` style.
10661068
spinner.finish_and_clear();
1067-
if !capture.exit_status.success() {
1069+
if !exit_status.success() {
10681070
error!("Failed to copy closure to remote host '{host}'");
1069-
bail!("nix copy --to '{}' failed:\n{}", host, capture.stderr_str());
1071+
bail!(
1072+
"nix copy --to '{}' failed (exit status: {:?})",
1073+
host,
1074+
exit_status
1075+
);
10701076
}
10711077
info!("Copied closure to remote host '{host}'");
10721078

@@ -1099,16 +1105,15 @@ fn copy_closure_between_remotes(
10991105

11001106
debug!(?cmd, "nix copy between remotes");
11011107

1102-
let capture = cmd
1103-
.capture()
1108+
let (exit_status, _stdout, _stderr) = exec_with_streaming(cmd, false)
11041109
.wrap_err("Failed to copy closure between remote hosts")?;
11051110

1106-
if !capture.exit_status.success() {
1111+
if !exit_status.success() {
11071112
bail!(
1108-
"nix copy from '{}' to '{}' failed:\n{}",
1113+
"nix copy from '{}' to '{}' failed (exit status: {:?})",
11091114
from_host,
11101115
to_host,
1111-
capture.stderr_str()
1116+
exit_status
11121117
);
11131118
}
11141119

0 commit comments

Comments
 (0)