Skip to content

Commit c27bbd0

Browse files
georgyiakylehgc
authored andcommitted
fix(runner): surface a failing tool's stderr under stdout-only filtering
`uv run rtk pytest` produced no output at all when pytest was missing from the venv: exit 1, a single newline on stdout, nothing on stderr. A test run that prints nothing and reports no error reads as a quiet pass, leaving the exit code as the only signal. The trigger is uv, but the defect is in the runner. Filters that compress stdout pass RunOptions::stdout_only, which keeps stderr out of the filter input — and run_captured_filter only ever emitted stderr inside its skip_filter_on_failure branch. Callers that pair stdout_only with early_exit_on_failure (ls, tree) hit that branch and print stderr; callers that don't (pytest, ruff, prettier, go test, golangci-lint, phpunit, phpstan, pint, rubocop) captured stderr and dropped it on the floor. Standalone `rtk pytest` looks fine only because python is absent there: the spawn fails outright and the anyhow error prints. Under `uv run` the venv supplies python, so rtk falls back to `python -m pytest`, the spawn succeeds, and the child's "No module named pytest" goes to the stderr that was being discarded. Emit stderr when the child fails. Only on failure: on a successful run stderr is the incidental noise RTK exists to suppress, and filtered stdout already carries the result. Fixes rtk-ai#3026
1 parent 1018b8b commit c27bbd0

2 files changed

Lines changed: 78 additions & 0 deletions

File tree

‎src/core/runner.rs‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,15 @@ where
122122
return Ok(exit_code);
123123
}
124124

125+
// `filter_stdout_only` keeps stderr out of the filter input, so nothing
126+
// downstream can surface it — a failing child's diagnostics would be dropped
127+
// and the exit code left as the only signal (#3026). Emit them here. Only on
128+
// failure: on a successful run stderr is the incidental noise RTK exists to
129+
// suppress, and the filtered stdout already carries the result.
130+
if opts.filter_stdout_only && exit_code != 0 && !result.raw_stderr.trim().is_empty() {
131+
eprint!("{}", result.raw_stderr);
132+
}
133+
125134
let text_to_filter = if opts.filter_stdout_only {
126135
raw_stdout
127136
} else {

‎tests/stderr_passthrough_test.rs‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
#![cfg(unix)]
2+
//! A failing tool's stderr must reach the user (#3026). Filters that compress
3+
//! stdout run with `RunOptions::stdout_only`, which keeps stderr out of the filter
4+
//! input — so unless the runner emits it, the diagnostics are dropped and the exit
5+
//! code becomes the only surviving signal.
6+
7+
use std::fs;
8+
use std::os::unix::fs::PermissionsExt;
9+
use std::process::Command;
10+
11+
const DIAGNOSTIC: &str = "shim-diagnostic: dependency is missing";
12+
13+
/// A fake `ruff` that fails the way real tools do: message on stderr, nothing on
14+
/// stdout, nonzero exit. `rtk ruff` filters stdout only, which is the path under
15+
/// test; `resolved_command` resolves via PATH, so the shim takes precedence.
16+
fn shim(exit_code: i32) -> tempfile::TempDir {
17+
let dir = tempfile::tempdir().unwrap();
18+
let bin = dir.path().join("ruff");
19+
fs::write(
20+
&bin,
21+
format!("#!/bin/sh\necho '{DIAGNOSTIC}' >&2\nexit {exit_code}\n"),
22+
)
23+
.unwrap();
24+
fs::set_permissions(&bin, fs::Permissions::from_mode(0o755)).unwrap();
25+
dir
26+
}
27+
28+
fn rtk_ruff(shim_dir: &tempfile::TempDir, db: &tempfile::TempDir) -> std::process::Output {
29+
let path = format!(
30+
"{}:{}",
31+
shim_dir.path().display(),
32+
std::env::var("PATH").unwrap_or_default()
33+
);
34+
Command::new(env!("CARGO_BIN_EXE_rtk"))
35+
.args(["ruff", "check", "."])
36+
.env("PATH", path)
37+
// Keep tracking off the developer's real database.
38+
.env("RTK_DB_PATH", db.path().join("t.db"))
39+
.output()
40+
.expect("rtk")
41+
}
42+
43+
#[test]
44+
fn failing_tool_stderr_reaches_the_user() {
45+
let db = tempfile::tempdir().unwrap();
46+
let out = rtk_ruff(&shim(1), &db);
47+
48+
assert_eq!(out.status.code(), Some(1), "exit code must propagate");
49+
assert!(
50+
String::from_utf8_lossy(&out.stderr).contains(DIAGNOSTIC),
51+
"diagnostic swallowed — stderr was {:?}, stdout was {:?}",
52+
String::from_utf8_lossy(&out.stderr),
53+
String::from_utf8_lossy(&out.stdout)
54+
);
55+
}
56+
57+
#[test]
58+
fn successful_tool_stderr_stays_suppressed() {
59+
// On success stderr is the incidental noise RTK exists to compress away, and
60+
// the filtered stdout already carries the result.
61+
let db = tempfile::tempdir().unwrap();
62+
let out = rtk_ruff(&shim(0), &db);
63+
64+
assert_eq!(out.status.code(), Some(0));
65+
assert!(
66+
!String::from_utf8_lossy(&out.stderr).contains(DIAGNOSTIC),
67+
"successful run must not leak tool stderr"
68+
);
69+
}

0 commit comments

Comments
 (0)