@@ -6,20 +6,19 @@ use pet_core::{arch::Architecture, env::PythonEnv, python_environment::PythonEnv
66use serde:: { Deserialize , Serialize } ;
77use 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
1618const PYTHON_INFO_JSON_SEPARATOR : & str = "093385e9-59f7-4a16-a604-14bf206256fe" ;
1719const 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.
2322const 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) ) ]
215195mod 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\n printf '%s' '{}' >&2\n printf '\\ 377\\ 376%s\\ n' '{}'\n exit {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\n sleep 60\n " ) . unwrap ( ) ;
261+ std:: fs:: write ( & fake_exe, "#!/bin/sh\n exec 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"\xff invalid" . as_slice ( ) ,
332+ PYTHON_INFO_JSON_SEPARATOR . as_bytes ( ) ,
333+ ] {
334+ assert ! ( parse_interpreter_output( "python" , bytes, SystemTime :: now( ) ) . is_none( ) ) ;
335+ }
336+ }
337+ }
0 commit comments