Skip to content

Commit 39c6710

Browse files
fix: drain interpreter probe pipes while running (#554)
Drain interpreter stdout/stderr while the child runs so healthy noisy probes do not stall on full pipes. Preserve #526 byte-safe parsing and bound captured output and execution time. - Add nonblocking platform pipe reads, a combined 4 MiB capture limit, and explicit nonzero/incomplete-output failures. - Terminate/reap the direct child on errors while retaining the original failure; document exceptional OS cleanup limitations. - Cover large stdout/stderr/both, timeout and output-limit reaping, inherited/transient pipe handles, exact capture boundaries and non-UTF8 JSON preambles. - Keep process-tree supervision, manager deadlines and server shutdown in #530/#529; this is a focused prerequisite, not a complete subprocess supervisor. Validation: 48 Windows and 55 Linux utility tests passed; mandatory formatting/Clippy, workspace all-target/all-feature Clippy, and independent Reviewer clean. A wait-before-drain mutation makes the new regression fail with the original timeout. Fixes #553 Related to #530 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent f72ddd1 commit 39c6710

6 files changed

Lines changed: 890 additions & 103 deletions

File tree

‎Cargo.lock‎

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎crates/pet-python-utils/Cargo.toml‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ license.workspace = true
66

77
[target.'cfg(target_os = "windows")'.dependencies]
88
msvc_spectre_libs = { version = "0.1.1", features = ["error"] }
9+
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_System_Pipes"] }
10+
11+
[target.'cfg(unix)'.dependencies]
12+
libc = "0.2"
913

1014
[dependencies]
1115
lazy_static = "1.4.0"
@@ -18,6 +22,9 @@ serde_json = "1.0.93"
1822
sha2 = "0.10.6"
1923
env_logger = "0.10.2"
2024

25+
[target.'cfg(windows)'.dev-dependencies]
26+
windows-sys = { version = "0.59", features = ["Win32_System_Threading"] }
27+
2128
[dev-dependencies]
2229
tempfile = "3.10"
2330

‎crates/pet-python-utils/src/env.rs‎

Lines changed: 170 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,19 @@ use pet_core::{arch::Architecture, env::PythonEnv, python_environment::PythonEnv
66
use serde::{Deserialize, Serialize};
77
use std::{
88
path::{Path, PathBuf},
9-
process::Stdio,
10-
thread,
11-
time::{Duration, Instant, SystemTime},
9+
time::{Duration, SystemTime},
1210
};
1311

14-
use crate::{cache::create_cache, executable::new_silent_command};
12+
use crate::{
13+
cache::create_cache,
14+
executable::new_silent_command,
15+
process::{output, ProcessError},
16+
};
1517

1618
const PYTHON_INFO_JSON_SEPARATOR: &str = "093385e9-59f7-4a16-a604-14bf206256fe";
1719
const PYTHON_INFO_CMD:&str = "import json, sys; print('093385e9-59f7-4a16-a604-14bf206256fe');print(json.dumps({'version': '.'.join(str(n) for n in sys.version_info), 'sys_prefix': sys.prefix, 'executable': sys.executable, 'is64_bit': sys.maxsize > 2**32}))";
1820

19-
/// Maximum wall-clock time to wait for a spawned Python interpreter to print
20-
/// its info JSON before we give up and kill it. Stale cached paths on Windows
21-
/// (Store stubs, vanished network shares, EDR-stalled `CreateProcess`) can
22-
/// otherwise block `resolve` for tens to hundreds of seconds (Fixes #463).
21+
/// Maximum execution time after synchronous interpreter spawn returns.
2322
const RESOLVE_SPAWN_TIMEOUT: Duration = Duration::from_secs(15);
2423

2524
#[derive(Debug, Deserialize, Clone)]
@@ -107,114 +106,95 @@ fn get_interpreter_details_with_timeout(
107106
let executable = executable.to_str()?;
108107
let start = SystemTime::now();
109108
trace!("Executing Python: {} -c {}", executable, PYTHON_INFO_CMD);
110-
let mut child = match new_silent_command(executable)
111-
.args(["-c", PYTHON_INFO_CMD])
112-
.stdin(Stdio::null())
113-
.stdout(Stdio::piped())
114-
.stderr(Stdio::piped())
115-
.spawn()
116-
{
117-
Ok(child) => child,
118-
Err(err) => {
109+
let result = output(
110+
new_silent_command(executable).args(["-c", PYTHON_INFO_CMD]),
111+
timeout,
112+
);
113+
match result {
114+
Ok(output) => parse_interpreter_result(executable, &output, start),
115+
Err(ProcessError::Timeout(timeout)) => {
116+
warn!("Timed out after {:?} resolving Python via spawn for {:?}; terminated direct child.", timeout, executable);
117+
None
118+
}
119+
Err(error) => {
119120
error!(
120-
"Failed to spawn Python to resolve info {:?}: {}",
121-
executable, err
121+
"Failed to execute Python to resolve info {:?}: {}",
122+
executable, error
122123
);
123-
return None;
124+
None
124125
}
125-
};
126+
}
127+
}
126128

127-
// Poll for completion up to the timeout. A stale cached path on Windows
128-
// (Store stub, vanished network share, EDR-stalled `CreateProcess`) can
129-
// otherwise block `wait_with_output()` for tens to hundreds of seconds.
130-
let deadline = Instant::now() + timeout;
131-
loop {
132-
match child.try_wait() {
133-
Ok(Some(_status)) => break,
134-
Ok(None) => {
135-
if Instant::now() >= deadline {
136-
warn!(
137-
"Timed out after {:?} resolving Python via spawn for {:?}; killing child.",
138-
timeout, executable
139-
);
140-
let _ = child.kill();
141-
let _ = child.wait();
142-
return None;
143-
}
144-
thread::sleep(Duration::from_millis(25));
145-
}
146-
Err(err) => {
147-
error!(
148-
"Failed to wait on Python interpreter spawn for {:?}: {}",
149-
executable, err
150-
);
151-
let _ = child.kill();
152-
let _ = child.wait();
153-
return None;
154-
}
155-
}
129+
fn parse_interpreter_result(
130+
executable: &str,
131+
output: &std::process::Output,
132+
start: SystemTime,
133+
) -> Option<ResolvedPythonEnv> {
134+
if !output.status.success() {
135+
error!(
136+
"Python interpreter {:?} exited with {}: {}",
137+
executable,
138+
output.status,
139+
String::from_utf8_lossy(&output.stderr)
140+
);
141+
return None;
156142
}
143+
parse_interpreter_output(executable, &output.stdout, start)
144+
}
157145

158-
let result = child.wait_with_output();
159-
match result {
160-
Ok(output) => {
161-
let output = output.stdout;
162-
trace!(
163-
"Executed Python {:?} in {:?} & produced an output {:?}",
164-
executable,
165-
start.elapsed(),
166-
String::from_utf8_lossy(&output)
167-
);
168-
let separator = PYTHON_INFO_JSON_SEPARATOR.as_bytes();
169-
if let Some(position) = output
170-
.windows(separator.len())
171-
.position(|bytes| bytes == separator)
172-
{
173-
let output = &output[position + separator.len()..];
174-
if let Ok(info) = serde_json::from_slice::<InterpreterInfo>(output) {
175-
let mut symlinks = vec![
176-
PathBuf::from(executable),
177-
PathBuf::from(info.executable.clone()),
178-
];
179-
symlinks.sort();
180-
symlinks.dedup();
181-
Some(ResolvedPythonEnv {
182-
executable: PathBuf::from(info.executable.clone()),
183-
prefix: PathBuf::from(info.sys_prefix),
184-
version: info.version.trim().to_string(),
185-
is64_bit: info.is64_bit,
186-
symlinks: Some(symlinks),
187-
})
188-
} else {
189-
error!(
190-
"Python Execution for {:?} produced an output {:?} that could not be parsed as JSON",
191-
executable, String::from_utf8_lossy(output),
192-
);
193-
None
194-
}
195-
} else {
196-
error!(
197-
"Python Execution for {:?} produced an output {:?} without a separator",
198-
executable,
199-
String::from_utf8_lossy(&output),
200-
);
201-
None
202-
}
203-
}
204-
Err(err) => {
146+
fn parse_interpreter_output(
147+
executable: &str,
148+
output: &[u8],
149+
start: SystemTime,
150+
) -> Option<ResolvedPythonEnv> {
151+
trace!(
152+
"Executed Python {:?} in {:?} & produced an output {:?}",
153+
executable,
154+
start.elapsed(),
155+
String::from_utf8_lossy(output)
156+
);
157+
let separator = PYTHON_INFO_JSON_SEPARATOR.as_bytes();
158+
if let Some(position) = output
159+
.windows(separator.len())
160+
.position(|bytes| bytes == separator)
161+
{
162+
let output = &output[position + separator.len()..];
163+
if let Ok(info) = serde_json::from_slice::<InterpreterInfo>(output) {
164+
let mut symlinks = vec![
165+
PathBuf::from(executable),
166+
PathBuf::from(info.executable.clone()),
167+
];
168+
symlinks.sort();
169+
symlinks.dedup();
170+
Some(ResolvedPythonEnv {
171+
executable: PathBuf::from(info.executable.clone()),
172+
prefix: PathBuf::from(info.sys_prefix),
173+
version: info.version.trim().to_string(),
174+
is64_bit: info.is64_bit,
175+
symlinks: Some(symlinks),
176+
})
177+
} else {
205178
error!(
206-
"Failed to execute Python to resolve info {:?}: {}",
207-
executable, err
179+
"Python Execution for {:?} produced an output {:?} that could not be parsed as JSON",
180+
executable, String::from_utf8_lossy(output),
208181
);
209182
None
210183
}
184+
} else {
185+
error!(
186+
"Python Execution for {:?} produced an output {:?} without a separator",
187+
executable,
188+
String::from_utf8_lossy(output),
189+
);
190+
None
211191
}
212192
}
213193

214194
#[cfg(all(test, unix))]
215195
mod tests {
216196
use super::*;
217-
use std::os::unix::fs::PermissionsExt;
197+
use std::{os::unix::fs::PermissionsExt, time::Instant};
218198

219199
// https://github.com/microsoft/python-environment-tools/issues/525:
220200
// A launcher printing GBK-encoded "文件不存在" must not panic discovery.
@@ -232,6 +212,36 @@ mod tests {
232212
directory.close()
233213
}
234214

215+
#[test]
216+
fn noisy_interpreter_output_resolves_only_on_success() {
217+
let directory = tempfile::tempdir().unwrap();
218+
let executable = directory.path().join("python");
219+
let payload = format!(
220+
"{}\n{}",
221+
PYTHON_INFO_JSON_SEPARATOR,
222+
r#"{"version":"3.13.1","sys_prefix":"prefix","executable":"python","is64_bit":true}"#
223+
);
224+
for exit_code in [0, 23] {
225+
let script = format!(
226+
"#!/bin/sh\nprintf '%s' '{}' >&2\nprintf '\\377\\376%s\\n' '{}'\nexit {exit_code}\n",
227+
"x".repeat(128 * 1024), payload
228+
);
229+
std::fs::write(&executable, script).unwrap();
230+
std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap();
231+
let started = Instant::now();
232+
let result = get_interpreter_details_with_timeout(&executable, Duration::from_secs(5));
233+
assert!(started.elapsed() < Duration::from_secs(5));
234+
if exit_code == 0 {
235+
assert_eq!(result.unwrap().version, "3.13.1");
236+
} else {
237+
assert!(
238+
result.is_none(),
239+
"valid JSON from a failed interpreter must not be cached"
240+
);
241+
}
242+
}
243+
}
244+
235245
/// Regression test for #463: a spawn that never exits must not block the
236246
/// resolve path indefinitely. We use a shell script that sleeps far longer
237247
/// than the test timeout and assert that the call returns None promptly
@@ -248,7 +258,7 @@ mod tests {
248258
));
249259
std::fs::create_dir_all(&tmp_dir).unwrap();
250260
let fake_exe = tmp_dir.join("hangs");
251-
std::fs::write(&fake_exe, "#!/bin/sh\nsleep 60\n").unwrap();
261+
std::fs::write(&fake_exe, "#!/bin/sh\nexec sleep 60\n").unwrap();
252262
let mut perms = std::fs::metadata(&fake_exe).unwrap().permissions();
253263
perms.set_mode(0o755);
254264
std::fs::set_permissions(&fake_exe, perms).unwrap();
@@ -262,9 +272,66 @@ mod tests {
262272

263273
assert!(result.is_none(), "hanging spawn must return None");
264274
assert!(
265-
elapsed < Duration::from_secs(5),
266-
"spawn must be killed near the timeout (took {:?})",
275+
elapsed < Duration::from_secs(3),
276+
"spawn must return within the execution and cleanup budgets (took {:?})",
267277
elapsed
268278
);
269279
}
270280
}
281+
282+
#[cfg(test)]
283+
mod parser_tests {
284+
use super::*;
285+
286+
#[test]
287+
fn preserves_non_utf8_preamble_and_unicode_json() {
288+
let mut bytes = vec![0xff, 0xfe, b'\n'];
289+
bytes.extend_from_slice(PYTHON_INFO_JSON_SEPARATOR.as_bytes());
290+
bytes.extend_from_slice(br#"{"version":" 3.13.1 ","sys_prefix":"C:\\env\\\u65e5","executable":"python","is64_bit":true}"#);
291+
let info = parse_interpreter_output("python", &bytes, SystemTime::now()).unwrap();
292+
assert_eq!(info.version, "3.13.1");
293+
assert_eq!(info.executable, PathBuf::from("python"));
294+
assert_eq!(info.prefix, PathBuf::from("C:\\env\\\u{65e5}"));
295+
assert_eq!(info.symlinks, Some(vec![PathBuf::from("python")]));
296+
assert!(info.is64_bit);
297+
}
298+
299+
#[test]
300+
fn rejects_valid_interpreter_json_from_failed_process() {
301+
#[cfg(unix)]
302+
use std::os::unix::process::ExitStatusExt;
303+
#[cfg(windows)]
304+
use std::os::windows::process::ExitStatusExt;
305+
let stdout = format!(
306+
"{}{}",
307+
PYTHON_INFO_JSON_SEPARATOR,
308+
r#"{"version":"3.13.1","sys_prefix":"prefix","executable":"python","is64_bit":true}"#
309+
)
310+
.into_bytes();
311+
for code in [0, 23] {
312+
#[cfg(unix)]
313+
let status = std::process::ExitStatus::from_raw(code << 8);
314+
#[cfg(windows)]
315+
let status = std::process::ExitStatus::from_raw(code);
316+
let output = std::process::Output {
317+
status,
318+
stdout: stdout.clone(),
319+
stderr: b"fixture stderr".to_vec(),
320+
};
321+
assert_eq!(
322+
parse_interpreter_result("python", &output, SystemTime::now()).is_some(),
323+
code == 0
324+
);
325+
}
326+
}
327+
328+
#[test]
329+
fn rejects_missing_separator_and_malformed_json() {
330+
for bytes in [
331+
b"\xffinvalid".as_slice(),
332+
PYTHON_INFO_JSON_SEPARATOR.as_bytes(),
333+
] {
334+
assert!(parse_interpreter_output("python", bytes, SystemTime::now()).is_none());
335+
}
336+
}
337+
}

‎crates/pet-python-utils/src/lib.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ pub mod fs_cache;
99
mod headers;
1010
pub mod macos;
1111
pub mod platform_dirs;
12+
mod process;
1213
pub mod version;

0 commit comments

Comments
 (0)