Skip to content

Commit 8a0a09f

Browse files
Liubov Dmitrievameta-codesync[bot]
authored andcommitted
Don't treat non-empty stderr as mergebase failure
Summary: **What:** - Drop the `|| !output.stderr().is_empty()` clause in `get_mergebase_details_impl` (`fbcode/eden/fs/cli_rs/sapling-client/src/mergebase.rs:110`). - Keep stderr (and now the exit code) in the genuine-failure error string so callers still get diagnostic context. **Why:** - A successful `hg log` can legitimately write to stderr — any caller that sets `SL_LOG` triggers it (sapling emits `clienttelemetry: client_entry_point="sapling"` on stderr even at quiet levels). - Exit status is the source of truth. Meerkat's own `vcs/hg.rs:run_hg_command` correctly checks only `status.success()` — that is the right contract for a Rust subprocess wrapper. **How it surfaced:** - D106483065 set `SL_LOG=info` on the inner atlasenv container; meerkat called `get_mergebase`. - The spurious failure cascaded: meerkat exit 1 → `atlas-preparer` setup exit 3 (`HookResult::FAILURE`) → `cogwheel_atlas_www` retry loop until the harness gave up. - D106494042 landed as a temporary safety net (commented out SL_LOG) and can be backed out once this fix is in the dev-docker image. **Related:** - Buck2's copy at `fbcode/buck2/app/buck2_file_watcher/src/edenfs/sapling.rs:78` has the same shape; `T219988735` already tracks consolidating it into this crate. Differential Revision: D106493970 fbshipit-source-id: 9f4b4ed1db494d9b02d2b2615137ca4d0cfa88b0
1 parent c827eb2 commit 8a0a09f

2 files changed

Lines changed: 95 additions & 9 deletions

File tree

eden/fs/cli_rs/sapling-client/src/mergebase.rs

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,9 +107,12 @@ where
107107
]);
108108
let output = spawner.output(&mut command).await?;
109109

110-
if !output.status().success() || !output.stderr().is_empty() {
110+
// Only treat a non-zero exit status as failure. `hg log` may write to stderr on
111+
// success (e.g. when `SL_LOG` is set in the caller's environment).
112+
if !output.status().success() {
111113
Err(SaplingError::Other(format!(
112-
"Failed to obtain mergebase:\n{}",
114+
"Failed to obtain mergebase (exit status {:?}):\n{}",
115+
output.status().code(),
113116
String::from_utf8(output.stderr().to_vec())
114117
.unwrap_or("Failed to parse stderr".to_string())
115118
)))
@@ -154,6 +157,7 @@ fn parse_mergebase_details(mergebase_details: Vec<u8>) -> Result<Option<Mergebas
154157
mod tests {
155158
use crate::mergebase::*;
156159
use crate::utils::tests::get_mock_spawner;
160+
use crate::utils::tests::get_mock_spawner_with_stderr;
157161

158162
// the format is {node}\n{date}\n{global_rev}
159163
const DETAILS: &str = "0000111122223333444455556666777788889999\n1234567890.012345\n9876543210";
@@ -192,6 +196,58 @@ mod tests {
192196
Ok(())
193197
}
194198

199+
#[tokio::test]
200+
async fn test_get_mergebase_details_succeeds_with_non_empty_stderr() -> Result<()> {
201+
// A successful `hg log` can still write to stderr — for example when the caller's
202+
// environment sets `SL_LOG=info`, or when sapling emits routine WARN lines like
203+
// the `cats` preminted-key fallback. Exit status is the source of truth; non-empty
204+
// stderr alone must NOT be treated as failure.
205+
let stdout = DETAILS.to_owned();
206+
let stderr = "2026-05-27T10:19:36Z INFO clienttelemetry: client_entry_point=\"sapling\"\n\
207+
2026-05-27T10:19:36Z WARN run: cats: wanted-key not found, falling back\n";
208+
let spawner = get_mock_spawner_with_stderr(
209+
get_sapling_executable_path(),
210+
Some((
211+
0,
212+
Some(stdout.as_bytes().to_vec()),
213+
Some(stderr.as_bytes().to_vec()),
214+
)),
215+
);
216+
217+
let details = get_mergebase_details_impl(&spawner, ".", COMMIT_ID, MERGEBASE_WITH)
218+
.await?
219+
.expect("mergebase should parse successfully despite stderr output");
220+
assert_eq!(details.mergebase, COMMIT_ID);
221+
Ok(())
222+
}
223+
224+
#[tokio::test]
225+
async fn test_get_mergebase_details_errors_on_non_zero_exit() -> Result<()> {
226+
// The mirror case: real sapling failures (non-zero exit) must still surface as
227+
// an error, with the exit code and stderr included in the message so the caller
228+
// can diagnose. Pick a different commit/mergebase pair than the success test to
229+
// bypass the module-level LRU cache.
230+
let stderr = "abort: unknown revision\n";
231+
let spawner = get_mock_spawner_with_stderr(
232+
get_sapling_executable_path(),
233+
Some((255, None, Some(stderr.as_bytes().to_vec()))),
234+
);
235+
236+
let err = get_mergebase_details_impl(&spawner, ".", "bogus-commit", "bogus-mergebase")
237+
.await
238+
.expect_err("non-zero exit must propagate as an error");
239+
let msg = err.to_string();
240+
assert!(
241+
msg.contains("Failed to obtain mergebase"),
242+
"missing context in error: {msg}"
243+
);
244+
assert!(
245+
msg.contains("abort: unknown revision"),
246+
"stderr missing from error: {msg}"
247+
);
248+
Ok(())
249+
}
250+
195251
#[test]
196252
fn test_parse_mergebase_details() -> Result<()> {
197253
let details = parse_mergebase_details(DETAILS.as_bytes().to_vec())?.unwrap();

eden/fs/cli_rs/sapling-client/src/utils.rs

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ pub(crate) mod tests {
219219
MockCommandSpawner::with_callback(move |cmd| match cmd.program.to_str() {
220220
Some(cmd_program) if cmd_program == program && output.is_some() => {
221221
let (exit_code, stdout_lines) = output.clone().unwrap();
222-
Ok(mock_child(exit_code, stdout_lines))
222+
Ok(mock_child(exit_code, stdout_lines, None))
223223
}
224224
program => Err(Error::other(anyhow::anyhow!(
225225
"Not expected program: {:?}",
@@ -228,14 +228,44 @@ pub(crate) mod tests {
228228
})
229229
}
230230

231-
fn mock_child(exit_code: i32, stdout_lines: Option<Vec<u8>>) -> MockChild {
231+
pub(crate) fn get_mock_spawner_with_stderr(
232+
program: String,
233+
output: Option<(i32, Option<Vec<u8>>, Option<Vec<u8>>)>,
234+
) -> MockCommandSpawner {
235+
MockCommandSpawner::with_callback(move |cmd| match cmd.program.to_str() {
236+
Some(cmd_program) if cmd_program == program && output.is_some() => {
237+
let (exit_code, stdout_lines, stderr_lines) = output.clone().unwrap();
238+
Ok(mock_child(exit_code, stdout_lines, stderr_lines))
239+
}
240+
program => Err(Error::other(anyhow::anyhow!(
241+
"Not expected program: {:?}",
242+
program
243+
))),
244+
})
245+
}
246+
247+
fn mock_child(
248+
exit_code: i32,
249+
stdout_lines: Option<Vec<u8>>,
250+
stderr_lines: Option<Vec<u8>>,
251+
) -> MockChild {
232252
let handle = MockChildHandle::new();
233253
handle.set_status(Ok(Some(MockExitStatus::new(Some(exit_code)))));
234-
if let Some(stdout_lines) = stdout_lines {
235-
let stdout = MockIoBuilder::new().read(&stdout_lines).build();
236-
MockChild::with_stdio(handle, Some(io::sink()), Some(stdout), Some(io::empty()))
237-
} else {
238-
MockChild::new(handle)
254+
match (stdout_lines, stderr_lines) {
255+
(None, None) => MockChild::new(handle),
256+
(Some(stdout_lines), None) => {
257+
let stdout = MockIoBuilder::new().read(&stdout_lines).build();
258+
MockChild::with_stdio(handle, Some(io::sink()), Some(stdout), Some(io::empty()))
259+
}
260+
(None, Some(stderr_lines)) => {
261+
let stderr = MockIoBuilder::new().read(&stderr_lines).build();
262+
MockChild::with_stdio(handle, Some(io::sink()), Some(io::empty()), Some(stderr))
263+
}
264+
(Some(stdout_lines), Some(stderr_lines)) => {
265+
let stdout = MockIoBuilder::new().read(&stdout_lines).build();
266+
let stderr = MockIoBuilder::new().read(&stderr_lines).build();
267+
MockChild::with_stdio(handle, Some(io::sink()), Some(stdout), Some(stderr))
268+
}
239269
}
240270
}
241271
}

0 commit comments

Comments
 (0)