1use std::panic::AssertUnwindSafe;
2use std::path::{Path, PathBuf};
3use std::process::Stdio;
4use std::time::Duration;
5use std::{env, io, panic};
6
7use async_channel::{Receiver, SendError};
8use tempfile::tempdir_in;
9use thiserror::Error;
10use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
11use tokio::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command};
12use tokio::sync::oneshot;
13use tracing::{debug, instrument};
14use walkdir::WalkDir;
15
16use uv_configuration::Concurrency;
17use uv_fs::Simplified;
18use uv_static::EnvVars;
19use uv_warnings::warn_user;
20
21const COMPILEALL_SCRIPT: &str = include_str!("pip_compileall.py");
22const DEFAULT_COMPILE_TIMEOUT: Duration = Duration::from_mins(1);
24
25type WorkerOutcome = std::thread::Result<Result<(), CompileError>>;
26type WorkerHandle = oneshot::Receiver<WorkerOutcome>;
27
28#[derive(Debug, Error)]
29pub enum CompileError {
30 #[error("Failed to list files in `site-packages`")]
31 Walkdir(#[from] walkdir::Error),
32 #[error("Failed to send task to worker")]
33 WorkerDisappeared(SendError<PathBuf>),
34 #[error("Failed to identify Python source files")]
35 SourceFiles(#[source] anyhow::Error),
36 #[error("The task executor is broken, did some other task panic?")]
37 Join,
38 #[error("Failed to start Python interpreter to run compile script")]
39 PythonSubcommand(#[source] io::Error),
40 #[error("Failed to create temporary script file")]
41 TempFile(#[source] io::Error),
42 #[error(r#"Bytecode compilation failed, expected "{0}", received: "{1}""#)]
43 WrongPath(String, String),
44 #[error("Failed to write to Python {device}")]
45 ChildStdio {
46 device: &'static str,
47 #[source]
48 err: io::Error,
49 },
50 #[error("Python process stderr:\n{stderr}")]
51 ErrorWithStderr {
52 stderr: String,
53 #[source]
54 err: Box<Self>,
55 },
56 #[error("Bytecode timed out ({}s) compiling file: `{}`", elapsed.as_secs_f32(), source_file)]
57 CompileTimeout {
58 elapsed: Duration,
59 source_file: String,
60 },
61 #[error("Python startup timed out ({}s)", _0.as_secs_f32())]
62 StartupTimeout(Duration),
63 #[error("Got invalid value from environment for {var}: {message}.")]
64 EnvironmentError { var: &'static str, message: String },
65}
66
67fn compile_timeout() -> Result<Option<Duration>, CompileError> {
68 let timeout = match env::var(EnvVars::UV_COMPILE_BYTECODE_TIMEOUT) {
69 Ok(value) => match value.as_str() {
70 "0" => None,
71 _ => match value.parse::<u64>().map(Duration::from_secs) {
72 Ok(duration) => Some(duration),
73 Err(_) => {
74 return Err(CompileError::EnvironmentError {
75 var: EnvVars::UV_COMPILE_BYTECODE_TIMEOUT,
76 message: format!("Expected an integer number of seconds, got \"{value}\""),
77 });
78 }
79 },
80 },
81 Err(_) => Some(DEFAULT_COMPILE_TIMEOUT),
82 };
83 if let Some(duration) = timeout {
84 debug!(
85 "Using bytecode compilation timeout of {}s",
86 duration.as_secs()
87 );
88 } else {
89 debug!("Disabling bytecode compilation timeout");
90 }
91 Ok(timeout)
92}
93
94fn spawn_workers(
95 dir: &Path,
96 python_executable: &Path,
97 pip_compileall_py: &Path,
98 receiver: &Receiver<PathBuf>,
99 worker_count: usize,
100 timeout: Option<Duration>,
101) -> Vec<WorkerHandle> {
102 debug!("Starting {} bytecode compilation workers", worker_count);
103 let mut worker_handles = Vec::with_capacity(worker_count);
104 for _ in 0..worker_count {
105 let (tx, rx) = oneshot::channel();
106
107 let worker = worker(
108 dir.to_path_buf(),
109 python_executable.to_path_buf(),
110 pip_compileall_py.to_path_buf(),
111 receiver.clone(),
112 timeout,
113 );
114
115 std::thread::Builder::new()
117 .name("uv-compile".to_owned())
118 .spawn(move || {
119 let result = panic::catch_unwind(AssertUnwindSafe(|| {
121 tokio::runtime::Builder::new_current_thread()
122 .enable_all()
123 .build()
124 .expect("Failed to build runtime")
125 .block_on(worker)
126 }));
127
128 let _ = tx.send(result);
130 })
131 .expect("Failed to start compilation worker");
132
133 worker_handles.push(rx);
134 }
135 worker_handles
136}
137
138async fn wait_for_workers(
140 worker_handles: Vec<WorkerHandle>,
141 send_error: Option<SendError<PathBuf>>,
142) -> Result<(), CompileError> {
143 for result in futures::future::join_all(worker_handles).await {
144 match result {
145 Err(_) | Ok(Err(_)) => return Err(CompileError::Join),
147 Ok(Ok(Err(compile_error))) => return Err(compile_error),
148 Ok(Ok(Ok(()))) => {}
149 }
150 }
151
152 if let Some(send_error) = send_error {
153 return Err(CompileError::WorkerDisappeared(send_error));
156 }
157
158 Ok(())
159}
160
161#[instrument(skip(python_executable))]
173pub async fn compile_tree(
174 dir: &Path,
175 python_executable: &Path,
176 concurrency: &Concurrency,
177 cache: &Path,
178) -> Result<usize, CompileError> {
179 debug_assert!(
180 dir.is_absolute(),
181 "compileall doesn't work with relative paths: `{}`",
182 dir.display()
183 );
184 let worker_count = concurrency.installs;
185
186 let (sender, receiver) = async_channel::bounded::<PathBuf>(worker_count * 10);
188
189 let tempdir = tempdir_in(cache).map_err(CompileError::TempFile)?;
191 let pip_compileall_py = tempdir.path().join("pip_compileall.py");
192 let timeout = compile_timeout()?;
193 let worker_handles = spawn_workers(
194 dir,
195 python_executable,
196 &pip_compileall_py,
197 &receiver,
198 worker_count,
199 timeout,
200 );
201 drop(receiver);
203
204 let mut source_files = 0;
206 let mut send_error = None;
207 let walker = WalkDir::new(dir)
208 .into_iter()
209 .filter_entry(|dir| dir.file_name() != "__pycache__");
211 for entry in walker {
212 let entry = match entry {
213 Ok(entry) => entry,
214 Err(err) => {
215 if err
216 .io_error()
217 .is_some_and(|err| err.kind() == io::ErrorKind::NotFound)
218 {
219 continue;
221 }
222 return Err(err.into());
223 }
224 };
225 if entry.file_type().is_file() && entry.path().extension().is_some_and(|ext| ext == "py") {
227 source_files += 1;
228 if let Err(err) = sender.send(entry.path().to_owned()).await {
229 send_error = Some(err);
234 break;
235 }
236 }
237 }
238
239 drop(sender);
242
243 wait_for_workers(worker_handles, send_error).await?;
244
245 Ok(source_files)
246}
247
248#[instrument(skip(files, python_executable))]
253pub async fn compile_files(
254 files: impl IntoIterator<Item = anyhow::Result<PathBuf>>,
255 python_executable: &Path,
256 concurrency: &Concurrency,
257 cache: &Path,
258) -> Result<usize, CompileError> {
259 let mut files = files.into_iter();
260 let mut initial_files = Vec::with_capacity(concurrency.installs);
261 for file in files.by_ref().take(concurrency.installs) {
262 initial_files.push(file.map_err(CompileError::SourceFiles)?);
263 }
264 if initial_files.is_empty() {
265 return Ok(0);
266 }
267
268 let worker_count = initial_files.len();
269 let (sender, receiver) = async_channel::bounded::<PathBuf>(worker_count * 10);
270
271 let tempdir = tempdir_in(cache).map_err(CompileError::TempFile)?;
273 let pip_compileall_py = tempdir.path().join("pip_compileall.py");
274 let timeout = compile_timeout()?;
275 let worker_handles = spawn_workers(
276 cache,
277 python_executable,
278 &pip_compileall_py,
279 &receiver,
280 worker_count,
281 timeout,
282 );
283 drop(receiver);
284
285 let mut send_error = None;
286 let mut source_error = None;
287 let mut source_files = 0;
288 for file in initial_files.into_iter().map(Ok).chain(files) {
289 let file = match file {
290 Ok(file) => file,
291 Err(err) => {
292 source_error = Some(err);
293 break;
294 }
295 };
296 debug_assert!(
297 file.is_absolute(),
298 "compileall doesn't work with relative paths: `{}`",
299 file.display()
300 );
301 source_files += 1;
302 if let Err(err) = sender.send(file).await {
303 send_error = Some(err);
304 break;
305 }
306 }
307 drop(sender);
308
309 wait_for_workers(worker_handles, send_error).await?;
310 if let Some(source_error) = source_error {
311 return Err(CompileError::SourceFiles(source_error));
312 }
313
314 Ok(source_files)
315}
316
317async fn worker(
318 dir: PathBuf,
319 interpreter: PathBuf,
320 pip_compileall_py: PathBuf,
321 receiver: Receiver<PathBuf>,
322 timeout: Option<Duration>,
323) -> Result<(), CompileError> {
324 fs_err::tokio::write(&pip_compileall_py, COMPILEALL_SCRIPT)
325 .await
326 .map_err(CompileError::TempFile)?;
327
328 let wait_until_ready = async {
333 loop {
334 if let Some(child) =
336 launch_bytecode_compiler(&dir, &interpreter, &pip_compileall_py).await?
337 {
338 break Ok::<_, CompileError>(child);
339 }
340 }
341 };
342
343 let (mut bytecode_compiler, child_stdin, mut child_stdout, mut child_stderr) =
346 if let Some(duration) = timeout {
347 tokio::time::timeout(duration, wait_until_ready)
348 .await
349 .map_err(|_| CompileError::StartupTimeout(timeout.unwrap()))??
350 } else {
351 wait_until_ready.await?
352 };
353
354 let stderr_reader = tokio::task::spawn(async move {
355 let mut child_stderr_collected: Vec<u8> = Vec::new();
356 child_stderr
357 .read_to_end(&mut child_stderr_collected)
358 .await?;
359 Ok(child_stderr_collected)
360 });
361
362 let result = worker_main_loop(receiver, child_stdin, &mut child_stdout, timeout).await;
363 let _ = bytecode_compiler.kill().await;
365
366 let child_stderr_collected = stderr_reader
369 .await
370 .map_err(|_| CompileError::Join)?
371 .map_err(|err| CompileError::ChildStdio {
372 device: "stderr",
373 err,
374 })?;
375 let result = if child_stderr_collected.is_empty() {
376 result
377 } else {
378 let stderr = String::from_utf8_lossy(&child_stderr_collected);
379 match result {
380 Ok(()) => {
381 debug!(
382 "Bytecode compilation `python` at {} stderr:\n{}\n---",
383 interpreter.user_display(),
384 stderr
385 );
386 Ok(())
387 }
388 Err(err) => Err(CompileError::ErrorWithStderr {
389 stderr: stderr.trim().to_string(),
390 err: Box::new(err),
391 }),
392 }
393 };
394
395 debug!("Bytecode compilation worker exiting: {:?}", result);
396
397 result
398}
399
400async fn launch_bytecode_compiler(
402 dir: &Path,
403 interpreter: &Path,
404 pip_compileall_py: &Path,
405) -> Result<
406 Option<(
407 Child,
408 ChildStdin,
409 BufReader<ChildStdout>,
410 BufReader<ChildStderr>,
411 )>,
412 CompileError,
413> {
414 let mut bytecode_compiler = Command::new(interpreter)
416 .arg(pip_compileall_py)
417 .stdin(Stdio::piped())
418 .stdout(Stdio::piped())
419 .stderr(Stdio::piped())
420 .current_dir(dir)
421 .env(EnvVars::PYTHONUNBUFFERED, "1")
423 .spawn()
424 .map_err(CompileError::PythonSubcommand)?;
425
426 let child_stdin = bytecode_compiler
429 .stdin
430 .take()
431 .expect("Child must have stdin");
432 let mut child_stdout = BufReader::new(
433 bytecode_compiler
434 .stdout
435 .take()
436 .expect("Child must have stdout"),
437 );
438 let child_stderr = BufReader::new(
439 bytecode_compiler
440 .stderr
441 .take()
442 .expect("Child must have stderr"),
443 );
444
445 let mut out_line = String::new();
447 child_stdout
448 .read_line(&mut out_line)
449 .await
450 .map_err(|err| CompileError::ChildStdio {
451 device: "stdout",
452 err,
453 })?;
454
455 if out_line.trim_end() == "Ready" {
456 Ok(Some((
458 bytecode_compiler,
459 child_stdin,
460 child_stdout,
461 child_stderr,
462 )))
463 } else if out_line.is_empty() {
464 Ok(None)
466 } else {
467 Err(CompileError::WrongPath("Ready".to_string(), out_line))
469 }
470}
471
472async fn worker_main_loop(
476 receiver: Receiver<PathBuf>,
477 mut child_stdin: ChildStdin,
478 child_stdout: &mut BufReader<ChildStdout>,
479 timeout: Option<Duration>,
480) -> Result<(), CompileError> {
481 let mut out_line = String::new();
482 while let Ok(source_file) = receiver.recv().await {
483 let source_file = source_file.display().to_string();
484 if source_file.contains(['\r', '\n']) {
485 warn_user!("Path contains newline, skipping: {source_file:?}");
486 continue;
487 }
488 let bytes = format!("{source_file}\n").into_bytes();
490
491 let python_handle = async {
492 child_stdin
493 .write_all(&bytes)
494 .await
495 .map_err(|err| CompileError::ChildStdio {
496 device: "stdin",
497 err,
498 })?;
499
500 out_line.clear();
501 child_stdout.read_line(&mut out_line).await.map_err(|err| {
502 CompileError::ChildStdio {
503 device: "stdout",
504 err,
505 }
506 })?;
507 Ok::<(), CompileError>(())
508 };
509
510 if let Some(duration) = timeout {
513 tokio::time::timeout(duration, python_handle)
514 .await
515 .map_err(|_| CompileError::CompileTimeout {
516 elapsed: duration,
517 source_file: source_file.clone(),
518 })??;
519 } else {
520 python_handle.await?;
521 }
522
523 let actual = out_line.trim_end_matches(['\n', '\r']);
526 if actual != source_file {
527 return Err(CompileError::WrongPath(source_file, actual.to_string()));
528 }
529 }
530 Ok(())
531}