chore(release): prepare public source release

This commit is contained in:
MercuryToolbox Release
2026-07-18 15:33:01 +08:00
commit 34d6a57f38
510 changed files with 163501 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "runtimekit"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "Shared runtime helpers for Mercury Toolbox runtime commands."
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[dependencies]
common = { path = "../common", default-features = false }
humantime.workspace = true
lexopt.workspace = true
[dev-dependencies]
loom.workspace = true
+827
View File
@@ -0,0 +1,827 @@
//! Shared runtime helpers for Mercury Toolbox runtime-focused commands.
use std::ffi::OsString;
use std::fs::{self, OpenOptions};
use std::io::{self, BufRead, Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use common::CliError;
use lexopt::prelude::Value as ArgValue;
const MAX_CAPTURE_STREAM_BYTES: usize = 4 * 1024 * 1024;
/// Result details from executing a child process probe.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CommandProbe {
/// Child process exit code when available.
pub exit_code: Option<i32>,
/// Whether the process was terminated after crossing the timeout.
pub timed_out: bool,
/// Total wall-clock runtime observed for the process.
pub duration: Duration,
}
/// Captured output from executing a child process probe.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CapturedCommand {
/// Child process exit code when available.
pub exit_code: Option<i32>,
/// Whether the process was terminated after crossing the timeout.
pub timed_out: bool,
/// Total wall-clock runtime observed for the process.
pub duration: Duration,
/// Bounded stdout bytes collected from the child process.
pub stdout: Vec<u8>,
/// Bounded stderr bytes collected from the child process.
pub stderr: Vec<u8>,
}
/// Supported shell launch modes for runtime-oriented commands.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShellMode {
/// Launch the program directly without a shell wrapper.
Raw,
/// Launch the command through a `PowerShell` wrapper.
Pwsh,
/// Launch the command through a cmd.exe wrapper.
Cmd,
}
/// Reads a UTF-8 flag value from lexopt.
///
/// # Errors
///
/// Returns [`CliError::Usage`] when a value is missing or not UTF-8 text.
pub fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result<String, CliError> {
let value = parser
.value()
.map_err(|error| CliError::usage(error.to_string()))?;
os_to_utf8(value, flag)
}
/// Converts an [`OsString`] to UTF-8 text for user-facing parsing paths.
///
/// # Errors
///
/// Returns [`CliError::Usage`] when the value is not UTF-8 text.
pub fn os_to_utf8(value: OsString, context: &str) -> Result<String, CliError> {
value.into_string().map_err(|invalid| {
CliError::usage(format!(
"{context} expects UTF-8 text, got '{}'",
invalid.to_string_lossy()
))
})
}
/// Parses a duration flag using human-friendly units such as `250ms`, `3s`, or `2m`.
///
/// # Errors
///
/// Returns [`CliError::Usage`] when the duration string cannot be parsed.
pub fn parse_duration_flag(flag: &str, value: &str) -> Result<Duration, CliError> {
humantime::parse_duration(value)
.map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}")))
}
/// Parses an `i32` flag value.
///
/// # Errors
///
/// Returns [`CliError::Usage`] when the integer string cannot be parsed.
pub fn parse_i32_flag(flag: &str, value: &str) -> Result<i32, CliError> {
value
.parse::<i32>()
.map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}")))
}
/// Parses a `usize` flag value.
///
/// # Errors
///
/// Returns [`CliError::Usage`] when the integer string cannot be parsed.
pub fn parse_usize_flag(flag: &str, value: &str) -> Result<usize, CliError> {
value
.parse::<usize>()
.map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}")))
}
/// Parses a shell mode flag value.
///
/// # Errors
///
/// Returns [`CliError::Usage`] when the shell name is unsupported.
pub fn parse_shell_mode(flag: &str, value: &str) -> Result<ShellMode, CliError> {
match value {
"raw" => Ok(ShellMode::Raw),
"pwsh" => Ok(ShellMode::Pwsh),
"cmd" => Ok(ShellMode::Cmd),
other => Err(CliError::usage(format!(
"invalid {flag} value '{other}'; expected raw, pwsh, or cmd"
))),
}
}
/// Renders a compact command line for text-mode diagnostics.
#[must_use]
pub fn render_command(command: &[OsString]) -> String {
let mut rendered = String::new();
for item in command {
if !rendered.is_empty() {
rendered.push(' ');
}
rendered.push_str(&quote_for_text(&item.to_string_lossy()));
}
rendered
}
/// Renders the trailing bytes from a captured output stream as lossy UTF-8 text.
#[must_use]
pub fn tail_bytes_to_string(bytes: &[u8], limit: usize) -> String {
let lookbehind = 64;
let start = bytes.len().saturating_sub(limit.saturating_add(lookbehind));
let stripped = strip_ansi_sequences(&String::from_utf8_lossy(&bytes[start..]));
trim_to_tail_bytes(&stripped, limit)
}
fn strip_ansi_sequences(text: &str) -> String {
let mut rendered = String::with_capacity(text.len());
let mut characters = text.chars().peekable();
while let Some(character) = characters.next() {
if character != '\u{1b}' {
rendered.push(character);
continue;
}
match characters.peek().copied() {
Some('[') => {
characters.next();
for next in characters.by_ref() {
if ('@'..='~').contains(&next) {
break;
}
}
}
Some(']') => {
characters.next();
let mut pending_escape = false;
for next in characters.by_ref() {
if pending_escape {
if next == '\\' {
break;
}
pending_escape = next == '\u{1b}';
continue;
}
if next == '\u{7}' {
break;
}
pending_escape = next == '\u{1b}';
}
}
_ => {}
}
}
rendered
}
fn trim_to_tail_bytes(text: &str, limit: usize) -> String {
if text.len() <= limit {
return text.to_string();
}
let mut start = text.len().saturating_sub(limit);
while start < text.len() && !text.is_char_boundary(start) {
start += 1;
}
text[start..].to_string()
}
/// Reads stdin into trimmed line records.
///
/// # Errors
///
/// Returns [`CliError::Runtime`] when stdin cannot be read.
pub fn read_stdin_lines() -> Result<Vec<String>, CliError> {
io::stdin()
.lock()
.lines()
.map(|line| {
line.map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))
})
.collect()
}
/// Executes a command directly and optionally enforces a wall-clock timeout.
///
/// # Errors
///
/// Returns [`CliError::Usage`] when no command is provided and [`CliError::Runtime`] for spawn,
/// polling, or wait failures.
pub fn run_command(
command: &[OsString],
cwd: Option<&Path>,
timeout: Option<Duration>,
) -> Result<CommandProbe, CliError> {
let captured = run_command_capture_with_shell(command, ShellMode::Raw, cwd, timeout)?;
Ok(CommandProbe {
exit_code: captured.exit_code,
timed_out: captured.timed_out,
duration: captured.duration,
})
}
/// Executes a command with an explicit shell mode and captures stdout and stderr.
///
/// # Errors
///
/// Returns [`CliError::Usage`] when no command is provided and [`CliError::Runtime`] for spawn,
/// wrapper creation, polling, or wait failures.
pub fn run_command_capture_with_shell(
command: &[OsString],
shell: ShellMode,
cwd: Option<&Path>,
timeout: Option<Duration>,
) -> Result<CapturedCommand, CliError> {
let prepared = prepare_command(command, shell)?;
let cleanup = prepared.cleanup.clone();
let result = run_prepared_command(&prepared, cwd, timeout);
cleanup_paths(&cleanup);
result
}
/// Consumes positional values from lexopt after an initial command token is seen.
///
/// # Errors
///
/// Returns [`CliError::Usage`] when non-positional tokens appear after command collection starts.
pub fn collect_command_values(
parser: &mut lexopt::Parser,
first: OsString,
) -> Result<Vec<OsString>, CliError> {
let mut command = vec![first];
while let Some(argument) = parser
.next()
.map_err(|error| CliError::usage(error.to_string()))?
{
match argument {
ArgValue(value) => command.push(value),
_ => {
return Err(CliError::usage(
"command arguments must appear after -- and cannot include extra flags",
));
}
}
}
Ok(command)
}
#[derive(Debug, Clone)]
struct PreparedCommand {
program: OsString,
args: Vec<OsString>,
cleanup: Vec<PathBuf>,
}
fn run_prepared_command(
prepared: &PreparedCommand,
cwd: Option<&Path>,
timeout: Option<Duration>,
) -> Result<CapturedCommand, CliError> {
let mut builder = Command::new(&prepared.program);
builder
.args(&prepared.args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(path) = cwd {
builder.current_dir(path);
}
let mut child = builder
.spawn()
.map_err(|error| CliError::runtime(format!("failed to launch command: {error}")))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| CliError::runtime("failed to capture child stdout"))?;
let stderr = child
.stderr
.take()
.ok_or_else(|| CliError::runtime("failed to capture child stderr"))?;
let stdout_handle = thread::spawn(move || read_stream(stdout, "stdout"));
let stderr_handle = thread::spawn(move || read_stream(stderr, "stderr"));
let started = Instant::now();
let (status, timed_out) = if let Some(limit) = timeout {
loop {
if let Some(status) = child.try_wait().map_err(|error| {
CliError::runtime(format!("failed to poll child process: {error}"))
})? {
break (status, false);
}
if started.elapsed() >= limit {
let _ = child.kill();
let status = child.wait().map_err(|error| {
CliError::runtime(format!("failed to wait for timed-out process: {error}"))
})?;
break (status, true);
}
thread::sleep(Duration::from_millis(10));
}
} else {
(
child.wait().map_err(|error| {
CliError::runtime(format!("failed to wait for command: {error}"))
})?,
false,
)
};
let stdout = join_reader(stdout_handle, "stdout")?;
let stderr = join_reader(stderr_handle, "stderr")?;
Ok(CapturedCommand {
exit_code: status.code(),
timed_out,
duration: started.elapsed(),
stdout,
stderr,
})
}
fn prepare_command(command: &[OsString], shell: ShellMode) -> Result<PreparedCommand, CliError> {
let Some(program) = command.first() else {
return Err(CliError::usage("expected a command after --"));
};
match shell {
ShellMode::Raw => Ok(PreparedCommand {
program: program.clone(),
args: command[1..].to_vec(),
cleanup: Vec::new(),
}),
ShellMode::Cmd => {
let wrapper = unique_temp_path("mercury-shell", "cmd");
let body = "@echo off\r\ncall %*\r\nset MERCURY_EXIT=%ERRORLEVEL%\r\nexit /b %MERCURY_EXIT%\r\n";
write_shell_wrapper_file(&wrapper, body)?;
let mut args = vec![
OsString::from("/d"),
OsString::from("/s"),
OsString::from("/c"),
wrapper.as_os_str().to_os_string(),
];
args.extend_from_slice(command);
Ok(PreparedCommand {
program: OsString::from("cmd"),
args,
cleanup: vec![wrapper],
})
}
ShellMode::Pwsh => {
let wrapper = unique_temp_path("mercury-shell", "ps1");
let body = "\
$command = @($args)\r\n\
if ($command.Length -eq 0) { exit 0 }\r\n\
$program = [string]$command[0]\r\n\
$childArgs = @()\r\n\
if ($command.Length -gt 1) {\r\n\
foreach ($item in $command[1..($command.Length - 1)]) {\r\n\
$childArgs += [string]$item\r\n\
}\r\n\
}\r\n\
try {\r\n\
& $program @childArgs\r\n\
$success = $?\r\n\
if ($null -ne $LASTEXITCODE) {\r\n\
exit $LASTEXITCODE\r\n\
}\r\n\
if ($success) {\r\n\
exit 0\r\n\
}\r\n\
exit 1\r\n\
} catch {\r\n\
[Console]::Error.WriteLine($_)\r\n\
if ($null -ne $LASTEXITCODE -and $LASTEXITCODE -ne 0) {\r\n\
exit $LASTEXITCODE\r\n\
}\r\n\
exit 1\r\n\
}\r\n";
write_shell_wrapper_file(&wrapper, body)?;
let mut args = vec![
OsString::from("-NoProfile"),
OsString::from("-File"),
wrapper.as_os_str().to_os_string(),
];
args.extend_from_slice(command);
Ok(PreparedCommand {
program: OsString::from("pwsh"),
args,
cleanup: vec![wrapper],
})
}
}
}
fn cleanup_paths(paths: &[PathBuf]) {
for path in paths {
let _ = fs::remove_file(path);
}
}
fn write_shell_wrapper_file(path: &Path, body: &str) -> Result<(), CliError> {
let mut file = OpenOptions::new()
.create_new(true)
.write(true)
.open(path)
.map_err(|error| {
CliError::runtime(format!(
"refusing to replace existing shell wrapper {}: {error}",
path.display()
))
})?;
file.write_all(body.as_bytes()).map_err(|error| {
CliError::runtime(format!("failed to write {}: {error}", path.display()))
})?;
file.flush().map_err(|error| {
CliError::runtime(format!("failed to flush {}: {error}", path.display()))
})?;
Ok(())
}
fn unique_temp_path(prefix: &str, extension: &str) -> PathBuf {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |value| value.as_nanos());
std::env::temp_dir().join(format!("{prefix}-{unique}.{extension}"))
}
fn join_reader(
handle: thread::JoinHandle<Result<Vec<u8>, io::Error>>,
stream: &str,
) -> Result<Vec<u8>, CliError> {
handle
.join()
.map_err(|_| CliError::runtime(format!("{stream} capture thread panicked")))?
.map_err(|error| CliError::runtime(format!("failed to read child {stream}: {error}")))
}
fn read_stream<R>(mut reader: R, stream: &str) -> Result<Vec<u8>, io::Error>
where
R: Read,
{
read_stream_with_limit(&mut reader, stream, MAX_CAPTURE_STREAM_BYTES)
}
fn read_stream_with_limit<R>(
reader: &mut R,
stream: &str,
max_capture_stream_bytes: usize,
) -> Result<Vec<u8>, io::Error>
where
R: Read,
{
let mut buffer = Vec::new();
let mut chunk = [0_u8; 8192];
let mut truncated = false;
loop {
let read = reader.read(&mut chunk)?;
if read == 0 {
break;
}
let remaining = max_capture_stream_bytes.saturating_sub(buffer.len());
if remaining > 0 {
let keep = remaining.min(read);
buffer.extend_from_slice(&chunk[..keep]);
}
if read > remaining {
truncated = true;
}
}
if truncated {
let marker = format!(
"\n[mercury: child {stream} truncated after {max_capture_stream_bytes} bytes]\n"
);
buffer.extend_from_slice(marker.as_bytes());
}
Ok(buffer)
}
fn quote_for_text(value: &str) -> String {
if value.is_empty() || value.chars().any(|ch| ch.is_whitespace() || ch == '"') {
format!("\"{}\"", value.replace('"', "\\\""))
} else {
value.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn success_command() -> Vec<OsString> {
#[cfg(windows)]
{
vec![
OsString::from("cmd"),
OsString::from("/d"),
OsString::from("/s"),
OsString::from("/c"),
OsString::from("exit 0"),
]
}
#[cfg(not(windows))]
{
vec![
OsString::from("sh"),
OsString::from("-c"),
OsString::from("exit 0"),
]
}
}
fn slow_command() -> Vec<OsString> {
#[cfg(windows)]
{
vec![
OsString::from("pwsh"),
OsString::from("-NoProfile"),
OsString::from("-Command"),
OsString::from("Start-Sleep -Milliseconds 300"),
]
}
#[cfg(not(windows))]
{
vec![
OsString::from("sh"),
OsString::from("-c"),
OsString::from("sleep 1"),
]
}
}
#[test]
fn parse_helpers_report_usage_errors() {
assert!(matches!(
parse_duration_flag("--timeout", "nope"),
Err(CliError::Usage(message)) if message.contains("invalid --timeout value")
));
assert!(matches!(
parse_i32_flag("--expect-exit", "bad"),
Err(CliError::Usage(message)) if message.contains("invalid --expect-exit value")
));
assert!(matches!(
parse_usize_flag("--tail-bytes", "bad"),
Err(CliError::Usage(message)) if message.contains("invalid --tail-bytes value")
));
assert!(matches!(
parse_shell_mode("--shell", "bad"),
Err(CliError::Usage(message)) if message.contains("invalid --shell value")
));
}
#[test]
fn render_command_quotes_whitespace_and_empty_values() {
assert_eq!(
render_command(&[
OsString::from("pwsh"),
OsString::from("-Command"),
OsString::from("Write-Output hi"),
OsString::from(""),
]),
"pwsh -Command \"Write-Output hi\" \"\""
);
}
#[test]
fn tail_bytes_take_only_the_suffix() {
assert_eq!(tail_bytes_to_string(b"abcdef", 3), "def");
assert_eq!(tail_bytes_to_string(b"abc", 99), "abc");
}
#[test]
fn tail_bytes_strip_common_ansi_sequences() {
assert_eq!(tail_bytes_to_string(b"\x1b[31;1mboom\x1b[0m", 32), "boom");
assert_eq!(tail_bytes_to_string(b"\x1b]0;title\x07done", 32), "done");
let tail = tail_bytes_to_string(b"prefix\x1b[31;1mboom\x1b[0m", 6);
assert!(!tail.contains('\u{1b}'));
assert!(tail.ends_with("boom"));
}
#[test]
fn run_command_reports_success_and_timeout() {
let success = run_command(&success_command(), None, Some(Duration::from_secs(2)))
.expect("successful probe");
assert_eq!(success.exit_code, Some(0));
assert!(!success.timed_out);
let timeout = run_command(&slow_command(), None, Some(Duration::from_millis(50)))
.expect("timeout probe");
assert!(timeout.timed_out);
}
#[test]
fn capture_command_collects_output() {
#[cfg(windows)]
let command = vec![
OsString::from("pwsh"),
OsString::from("-NoProfile"),
OsString::from("-Command"),
OsString::from("Write-Output 'out'; Write-Error 'err'"),
];
#[cfg(not(windows))]
let command = vec![
OsString::from("sh"),
OsString::from("-c"),
OsString::from("printf out; printf err >&2"),
];
let captured = run_command_capture_with_shell(
&command,
ShellMode::Raw,
None,
Some(Duration::from_secs(2)),
)
.expect("captured");
assert!(!captured.stdout.is_empty());
assert!(!captured.stderr.is_empty());
}
#[cfg(windows)]
#[test]
fn pwsh_wrapper_runs_script_files_as_argv() {
let script = unique_temp_path("runtimekit-test", "ps1");
fs::write(&script, "Write-Output 'done'\n").expect("script");
let captured = run_command_capture_with_shell(
&[script.as_os_str().to_os_string()],
ShellMode::Pwsh,
None,
Some(Duration::from_secs(2)),
)
.expect("captured pwsh script file");
let _ = fs::remove_file(script);
assert_eq!(captured.exit_code, Some(0));
assert_eq!(String::from_utf8_lossy(&captured.stdout), "done\r\n");
assert!(captured.stderr.is_empty());
}
#[test]
fn run_command_requires_non_empty_command() {
let error = run_command(&[], None, None).expect_err("missing command should fail");
assert!(matches!(
error,
CliError::Usage(message) if message.contains("expected a command")
));
}
#[test]
fn collect_command_values_accepts_positionals_and_rejects_flags() {
let mut parser = lexopt::Parser::from_iter([
OsString::from("runtimekit"),
OsString::from("tool"),
OsString::from("alpha"),
OsString::from("two words"),
]);
let first = parser.next().expect("parser item").expect("first value");
let collected = match first {
ArgValue(value) => collect_command_values(&mut parser, value).expect("values"),
_ => panic!("expected first positional value"),
};
assert_eq!(
collected,
vec![
OsString::from("tool"),
OsString::from("alpha"),
OsString::from("two words")
]
);
let mut parser = lexopt::Parser::from_iter([
OsString::from("runtimekit"),
OsString::from("tool"),
OsString::from("--bad"),
]);
let first = parser.next().expect("parser item").expect("first value");
let error = match first {
ArgValue(value) => collect_command_values(&mut parser, value).expect_err("flag error"),
_ => panic!("expected first positional value"),
};
assert!(matches!(
error,
CliError::Usage(message)
if message.contains("command arguments must appear after --")
));
}
#[test]
fn prepare_command_builds_shell_specific_wrappers() {
let command = vec![OsString::from("echo"), OsString::from("hello world")];
let raw = prepare_command(&command, ShellMode::Raw).expect("raw");
assert_eq!(raw.program, OsString::from("echo"));
assert_eq!(raw.args, vec![OsString::from("hello world")]);
assert!(raw.cleanup.is_empty());
#[cfg(windows)]
{
let cmd = prepare_command(&command, ShellMode::Cmd).expect("cmd");
assert_eq!(cmd.program, OsString::from("cmd"));
assert!(cmd.args.len() >= 5);
assert_eq!(cmd.cleanup.len(), 1);
assert_eq!(
cmd.cleanup[0].extension().and_then(std::ffi::OsStr::to_str),
Some("cmd")
);
assert!(fs::metadata(&cmd.cleanup[0]).is_ok());
cleanup_paths(&cmd.cleanup);
assert!(fs::metadata(&cmd.cleanup[0]).is_err());
let pwsh = prepare_command(&command, ShellMode::Pwsh).expect("pwsh");
assert_eq!(pwsh.program, OsString::from("pwsh"));
assert!(pwsh.args.len() >= 4);
assert_eq!(pwsh.cleanup.len(), 1);
assert_eq!(
pwsh.cleanup[0]
.extension()
.and_then(std::ffi::OsStr::to_str),
Some("ps1")
);
assert!(fs::metadata(&pwsh.cleanup[0]).is_ok());
cleanup_paths(&pwsh.cleanup);
assert!(fs::metadata(&pwsh.cleanup[0]).is_err());
}
}
#[test]
fn shell_wrapper_writer_refuses_preexisting_paths() {
let path = unique_temp_path("runtimekit-existing-wrapper", "cmd");
fs::write(&path, "original").expect("preexisting wrapper");
let error =
write_shell_wrapper_file(&path, "replacement").expect_err("preexisting path refused");
assert!(matches!(
error,
CliError::Runtime(message)
if message.contains("refusing to replace existing shell wrapper")
));
assert_eq!(
fs::read_to_string(&path).expect("preserved content"),
"original"
);
let _ = fs::remove_file(path);
}
#[test]
fn stream_helpers_cover_success_and_failure_paths() {
let stdout = join_reader(
thread::spawn(|| Ok::<Vec<u8>, io::Error>(b"hello".to_vec())),
"stdout",
)
.expect("stdout");
assert_eq!(stdout, b"hello");
let read_error = join_reader(
thread::spawn(|| Err::<Vec<u8>, io::Error>(io::Error::other("boom"))),
"stderr",
)
.expect_err("stderr read error");
assert!(matches!(
read_error,
CliError::Runtime(message)
if message.contains("failed to read child stderr")
));
let panic_error = join_reader(
thread::spawn(|| -> Result<Vec<u8>, io::Error> {
panic!("reader panic");
}),
"stdout",
)
.expect_err("panic error");
assert!(matches!(
panic_error,
CliError::Runtime(message)
if message.contains("stdout capture thread panicked")
));
assert_eq!(
read_stream(io::Cursor::new(b"abc".to_vec()), "stdout").expect("cursor"),
b"abc"
);
assert_eq!(quote_for_text("two words"), "\"two words\"");
assert_eq!(quote_for_text("plain"), "plain");
}
#[test]
fn read_stream_caps_memory_and_drains_remaining_bytes() {
let max_capture_stream_bytes = 64;
let input = vec![b'x'; max_capture_stream_bytes + 128];
let mut reader = io::Cursor::new(input);
let captured = read_stream_with_limit(&mut reader, "stdout", max_capture_stream_bytes)
.expect("capture");
assert!(captured.len() < max_capture_stream_bytes + 128);
assert!(captured.starts_with(&[b'x'; 64]));
assert!(String::from_utf8_lossy(&captured).contains("child stdout truncated"));
}
}
+57
View File
@@ -0,0 +1,57 @@
//! Loom model for the runtime command capture join pattern.
use loom::sync::{Arc, Mutex};
use loom::thread;
fn join_capture(
handle: loom::thread::JoinHandle<Result<Vec<u8>, &'static str>>,
stream: &'static str,
) -> Result<Vec<u8>, String> {
handle
.join()
.map_err(|_| format!("{stream} capture thread panicked"))?
.map_err(|error| format!("failed to read child {stream}: {error}"))
}
#[test]
fn capture_threads_publish_before_join_returns() {
loom::model(|| {
let events = Arc::new(Mutex::new(Vec::new()));
let stdout_events = Arc::clone(&events);
let stderr_events = Arc::clone(&events);
let stdout = thread::spawn(move || {
let mut guard = stdout_events.lock().expect("stdout lock");
guard.push("stdout");
});
let stderr = thread::spawn(move || {
let mut guard = stderr_events.lock().expect("stderr lock");
guard.push("stderr");
});
stdout.join().expect("stdout join");
stderr.join().expect("stderr join");
{
let guard = events.lock().expect("events lock");
assert_eq!(guard.len(), 2);
assert!(guard.contains(&"stdout"));
assert!(guard.contains(&"stderr"));
drop(guard);
}
});
}
#[test]
fn capture_join_models_success_and_read_error_paths() {
loom::model(|| {
let stdout = thread::spawn(|| Ok(vec![b'o', b'k']));
let stderr = thread::spawn(|| Err("broken pipe"));
assert_eq!(join_capture(stdout, "stdout").expect("stdout"), b"ok");
assert_eq!(
join_capture(stderr, "stderr").expect_err("stderr"),
"failed to read child stderr: broken pipe"
);
});
}