Skip to content

Commit 5b56447

Browse files
committed
windows: verify raw pipe command parity
1 parent d891bf2 commit 5b56447

2 files changed

Lines changed: 319 additions & 0 deletions

File tree

codex-rs/utils/pty/src/tests.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ mod windows_conpty_job_tests;
3232
#[path = "windows_pipe_job_tests.rs"]
3333
mod windows_pipe_job_tests;
3434

35+
#[cfg(windows)]
36+
#[path = "windows_pipe_parity_tests.rs"]
37+
mod windows_pipe_parity_tests;
38+
3539
fn find_python() -> Option<String> {
3640
for candidate in ["python3", "python"] {
3741
if let Ok(output) = std::process::Command::new(candidate)
Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
use super::collect_split_output;
2+
use super::windows_job_test_support::TestDirectory;
3+
use crate::SpawnedProcess;
4+
use crate::spawn_pipe_process;
5+
use crate::spawn_pipe_process_no_stdin;
6+
use std::collections::HashMap;
7+
use std::fs;
8+
use std::path::Path;
9+
use std::path::PathBuf;
10+
use std::process::Stdio;
11+
use tokio::io::AsyncReadExt;
12+
use tokio::io::AsyncWriteExt;
13+
14+
struct PipeParityCase {
15+
name: &'static str,
16+
program: String,
17+
args: Vec<String>,
18+
cwd: PathBuf,
19+
env: HashMap<String, String>,
20+
stdin: Option<Vec<u8>>,
21+
}
22+
23+
#[derive(Debug, PartialEq, Eq)]
24+
struct PipeResult {
25+
stdout: Vec<u8>,
26+
stderr: Vec<u8>,
27+
exit_code: i32,
28+
}
29+
30+
fn find_powershell() -> Option<String> {
31+
["pwsh.exe", "powershell.exe"]
32+
.into_iter()
33+
.find_map(|candidate| {
34+
std::process::Command::new(candidate)
35+
.args(["-NoLogo", "-NoProfile", "-Command", "exit 0"])
36+
.status()
37+
.ok()
38+
.filter(std::process::ExitStatus::success)
39+
.map(|_| candidate.to_string())
40+
})
41+
}
42+
43+
async fn run_tokio_pipe(case: &PipeParityCase) -> anyhow::Result<PipeResult> {
44+
let mut command = tokio::process::Command::new(&case.program);
45+
command
46+
.args(&case.args)
47+
.current_dir(&case.cwd)
48+
.env_clear()
49+
.envs(&case.env)
50+
.stdin(if case.stdin.is_some() {
51+
Stdio::piped()
52+
} else {
53+
Stdio::null()
54+
})
55+
.stdout(Stdio::piped())
56+
.stderr(Stdio::piped());
57+
let mut child = command.spawn()?;
58+
if let Some(input) = &case.stdin {
59+
let mut stdin = child
60+
.stdin
61+
.take()
62+
.ok_or_else(|| anyhow::anyhow!("reference child has no stdin"))?;
63+
stdin.write_all(input).await?;
64+
stdin.shutdown().await?;
65+
}
66+
let mut stdout = child
67+
.stdout
68+
.take()
69+
.ok_or_else(|| anyhow::anyhow!("reference child has no stdout"))?;
70+
let mut stderr = child
71+
.stderr
72+
.take()
73+
.ok_or_else(|| anyhow::anyhow!("reference child has no stderr"))?;
74+
let stdout_task = tokio::spawn(async move {
75+
let mut output = Vec::new();
76+
stdout.read_to_end(&mut output).await.map(|_| output)
77+
});
78+
let stderr_task = tokio::spawn(async move {
79+
let mut output = Vec::new();
80+
stderr.read_to_end(&mut output).await.map(|_| output)
81+
});
82+
let timeout = tokio::time::Duration::from_secs(15);
83+
let status = tokio::time::timeout(timeout, child.wait())
84+
.await
85+
.map_err(|_| anyhow::anyhow!("reference process timed out"))??;
86+
let stdout = tokio::time::timeout(timeout, stdout_task)
87+
.await
88+
.map_err(|_| anyhow::anyhow!("reference stdout timed out"))???;
89+
let stderr = tokio::time::timeout(timeout, stderr_task)
90+
.await
91+
.map_err(|_| anyhow::anyhow!("reference stderr timed out"))???;
92+
Ok(PipeResult {
93+
stdout,
94+
stderr,
95+
exit_code: status.code().unwrap_or(-1),
96+
})
97+
}
98+
99+
async fn run_raw_pipe(case: &PipeParityCase) -> anyhow::Result<PipeResult> {
100+
let arg0 = Some("this-arg0-must-be-ignored-on-windows".to_string());
101+
let spawned = if case.stdin.is_some() {
102+
spawn_pipe_process(&case.program, &case.args, &case.cwd, &case.env, &arg0).await?
103+
} else {
104+
spawn_pipe_process_no_stdin(&case.program, &case.args, &case.cwd, &case.env, &arg0).await?
105+
};
106+
let SpawnedProcess {
107+
session,
108+
stdout_rx,
109+
stderr_rx,
110+
exit_rx,
111+
} = spawned;
112+
if let Some(input) = &case.stdin {
113+
let writer = session.writer_sender();
114+
writer.send(input.clone()).await?;
115+
drop(writer);
116+
session.close_stdin();
117+
}
118+
let stdout_task = tokio::spawn(collect_split_output(stdout_rx));
119+
let stderr_task = tokio::spawn(collect_split_output(stderr_rx));
120+
let timeout = tokio::time::Duration::from_secs(15);
121+
let exit_code = tokio::time::timeout(timeout, exit_rx)
122+
.await
123+
.map_err(|_| anyhow::anyhow!("raw pipe process timed out"))?
124+
.unwrap_or(-1);
125+
let stdout = tokio::time::timeout(timeout, stdout_task)
126+
.await
127+
.map_err(|_| anyhow::anyhow!("raw pipe stdout timed out"))??;
128+
let stderr = tokio::time::timeout(timeout, stderr_task)
129+
.await
130+
.map_err(|_| anyhow::anyhow!("raw pipe stderr timed out"))??;
131+
Ok(PipeResult {
132+
stdout,
133+
stderr,
134+
exit_code,
135+
})
136+
}
137+
138+
fn set_case_insensitive_env(environment: &mut HashMap<String, String>, key: &str, value: String) {
139+
environment.retain(|candidate, _| !candidate.eq_ignore_ascii_case(key));
140+
environment.insert(key.to_string(), value);
141+
}
142+
143+
fn path_with_prefix(directory: &Path) -> anyhow::Result<String> {
144+
let mut paths = vec![directory.to_owned()];
145+
if let Some(parent_path) = std::env::var_os("PATH") {
146+
paths.extend(std::env::split_paths(&parent_path));
147+
}
148+
Ok(std::env::join_paths(paths)?.to_string_lossy().into_owned())
149+
}
150+
151+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
152+
async fn raw_pipe_matches_tokio_command_for_windows_process_semantics() -> anyhow::Result<()> {
153+
let directory = TestDirectory::new("pipe-parity")?;
154+
let unicode_cwd = directory.join("cwd-漢字-é");
155+
fs::create_dir(&unicode_cwd)?;
156+
let command_interpreter = std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string());
157+
let mut environment: HashMap<String, String> = std::env::vars().collect();
158+
set_case_insensitive_env(
159+
&mut environment,
160+
"CODEX_UNICODE_VALUE",
161+
"café-漢字".to_string(),
162+
);
163+
164+
let batch_script = directory.join("args-probe.cmd");
165+
fs::write(
166+
&batch_script,
167+
"@echo off\r\nsetlocal DisableDelayedExpansion\r\necho arg1=[%~1]\r\necho arg2=[%~2]\r\necho arg3=[%~3]\r\necho arg4=[%~4]\r\necho arg5=[%~5]\r\necho arg6=[%~6]\r\necho env=[%CODEX_UNICODE_VALUE%]\r\necho cwd=[%CD%]\r\nexit /b 0\r\n",
168+
)?;
169+
170+
let path_directory = directory.join("path-bin");
171+
fs::create_dir(&path_directory)?;
172+
let path_executable_name = format!("codex-path-probe-{}", std::process::id());
173+
let copied_executable = path_directory.join(format!("{path_executable_name}.exe"));
174+
fs::copy(&command_interpreter, &copied_executable)?;
175+
let path_batch_name = format!("codex-batch-probe-{}.cmd", std::process::id());
176+
fs::write(
177+
path_directory.join(&path_batch_name),
178+
"@echo off\r\necho child-path-batch\r\nexit /b 0\r\n",
179+
)?;
180+
let path_bat_name = format!("codex-bat-probe-{}.bat", std::process::id());
181+
fs::write(
182+
path_directory.join(&path_bat_name),
183+
"@echo off\r\necho child-path-bat\r\nexit /b 0\r\n",
184+
)?;
185+
let spaced_directory = directory.join("space bin");
186+
fs::create_dir(&spaced_directory)?;
187+
let spaced_executable = spaced_directory.join("probe executable.exe");
188+
fs::copy(&command_interpreter, &spaced_executable)?;
189+
let mut path_environment = environment.clone();
190+
set_case_insensitive_env(
191+
&mut path_environment,
192+
"PATH",
193+
path_with_prefix(&path_directory)?,
194+
);
195+
196+
let mut cases = vec![
197+
PipeParityCase {
198+
name: "split output and exit 37",
199+
program: command_interpreter.clone(),
200+
args: vec![
201+
"/D".to_string(),
202+
"/Q".to_string(),
203+
"/C".to_string(),
204+
"(echo split-out)&(echo split-err 1>&2)&exit /b 37".to_string(),
205+
],
206+
cwd: unicode_cwd.clone(),
207+
env: environment.clone(),
208+
stdin: None,
209+
},
210+
PipeParityCase {
211+
name: "closed stdin at process start",
212+
program: command_interpreter.clone(),
213+
args: vec![
214+
"/D".to_string(),
215+
"/Q".to_string(),
216+
"/C".to_string(),
217+
"(set /p line=)||(echo stdin-closed)".to_string(),
218+
],
219+
cwd: unicode_cwd.clone(),
220+
env: environment.clone(),
221+
stdin: None,
222+
},
223+
PipeParityCase {
224+
name: "batch quoting and Unicode environment/cwd",
225+
program: batch_script.to_string_lossy().into_owned(),
226+
args: vec![
227+
String::new(),
228+
"two words".to_string(),
229+
"quote\"value".to_string(),
230+
"trailing\\".to_string(),
231+
"100%".to_string(),
232+
"漢字-é".to_string(),
233+
],
234+
cwd: unicode_cwd.clone(),
235+
env: environment.clone(),
236+
stdin: None,
237+
},
238+
PipeParityCase {
239+
name: "extensionless exe from child PATH",
240+
program: path_executable_name,
241+
args: vec![
242+
"/D".to_string(),
243+
"/Q".to_string(),
244+
"/C".to_string(),
245+
"echo child-path-exe".to_string(),
246+
],
247+
cwd: unicode_cwd.clone(),
248+
env: path_environment.clone(),
249+
stdin: None,
250+
},
251+
PipeParityCase {
252+
name: "batch file from child PATH",
253+
program: path_batch_name,
254+
args: Vec::new(),
255+
cwd: unicode_cwd.clone(),
256+
env: path_environment.clone(),
257+
stdin: None,
258+
},
259+
PipeParityCase {
260+
name: "bat file from child PATH",
261+
program: path_bat_name,
262+
args: Vec::new(),
263+
cwd: unicode_cwd.clone(),
264+
env: path_environment,
265+
stdin: None,
266+
},
267+
PipeParityCase {
268+
name: "absolute executable path containing spaces",
269+
program: spaced_executable.to_string_lossy().into_owned(),
270+
args: vec![
271+
"/D".to_string(),
272+
"/Q".to_string(),
273+
"/C".to_string(),
274+
"echo absolute-space-exe".to_string(),
275+
],
276+
cwd: unicode_cwd.clone(),
277+
env: environment.clone(),
278+
stdin: None,
279+
},
280+
];
281+
282+
if let Some(powershell) = find_powershell() {
283+
let powershell_script = directory.join("args-probe.ps1");
284+
fs::write(
285+
&powershell_script,
286+
"$OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::new()\nforeach ($arg in $args) { [Console]::Out.WriteLine([Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([string]$arg))) }\n[Console]::Out.WriteLine([Environment]::GetEnvironmentVariable('CODEX_UNICODE_VALUE'))\n[Console]::Out.WriteLine((Get-Location).Path)\nexit 0\n",
287+
)?;
288+
cases.push(PipeParityCase {
289+
name: "regular executable quoting",
290+
program: powershell,
291+
args: vec![
292+
"-NoLogo".to_string(),
293+
"-NoProfile".to_string(),
294+
"-NonInteractive".to_string(),
295+
"-File".to_string(),
296+
powershell_script.to_string_lossy().into_owned(),
297+
String::new(),
298+
"two words".to_string(),
299+
"quote\"value".to_string(),
300+
"trailing\\".to_string(),
301+
"漢字-é".to_string(),
302+
],
303+
cwd: unicode_cwd,
304+
env: environment,
305+
stdin: None,
306+
});
307+
}
308+
309+
for case in cases {
310+
let reference = run_tokio_pipe(&case).await?;
311+
let raw = run_raw_pipe(&case).await?;
312+
pretty_assertions::assert_eq!(raw, reference, "parity case: {}", case.name);
313+
}
314+
Ok(())
315+
}

0 commit comments

Comments
 (0)