Skip to main content

uv_installer/
compile.rs

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");
22/// This is longer than any compilation should ever take.
23const 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        // Spawn each worker on a dedicated thread.
116        std::thread::Builder::new()
117            .name("uv-compile".to_owned())
118            .spawn(move || {
119                // Report panics back to the main thread.
120                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                // This may fail if the main thread returned early due to an error.
129                let _ = tx.send(result);
130            })
131            .expect("Failed to start compilation worker");
132
133        worker_handles.push(rx);
134    }
135    worker_handles
136}
137
138/// Wait for all workers to exit so worker failures are not hidden by channel send errors.
139async 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            // A worker thread panicked or exited without reporting its result.
146            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        // This is suspicious: Why did the channel stop working, but all workers exited
154        // successfully?
155        return Err(CompileError::WorkerDisappeared(send_error));
156    }
157
158    Ok(())
159}
160
161/// Bytecode compile all file in `dir` using a pool of Python interpreters running a Python script
162/// that calls `compileall.compile_file`.
163///
164/// All compilation errors are muted (like pip). There is a 60s timeout for each file to handle
165/// a broken `python`. The timeout can be configured with `UV_COMPILE_BYTECODE_TIMEOUT`; a value of
166/// `0` disables the timeout.
167///
168/// We only compile all files, but we don't update the RECORD, relying on PEP 491:
169/// > Uninstallers should be smart enough to remove .pyc even if it is not mentioned in RECORD.
170///
171/// We've confirmed that both uv and pip (as of 24.0.0) remove the `__pycache__` directory.
172#[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    // A larger buffer is significantly faster than just 1 or the worker count.
187    let (sender, receiver) = async_channel::bounded::<PathBuf>(worker_count * 10);
188
189    // Running Python with an actual file will produce better error messages.
190    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    // Make sure the channel gets closed when all workers exit.
202    drop(receiver);
203
204    // Start the producer, sending all `.py` files to workers.
205    let mut source_files = 0;
206    let mut send_error = None;
207    let walker = WalkDir::new(dir)
208        .into_iter()
209        // Otherwise we stumble over temporary files from `compileall`.
210        .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                    // The directory was removed, just ignore it
220                    continue;
221                }
222                return Err(err.into());
223            }
224        };
225        // https://github.com/pypa/pip/blob/3820b0e52c7fed2b2c43ba731b718f316e6816d1/src/pip/_internal/operations/install/wheel.py#L593-L604
226        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                // The workers exited.
230                // If e.g. something with the Python interpreter is wrong, the workers have exited
231                // with an error. We try to report this informative error and only if that fails,
232                // report the send error.
233                send_error = Some(err);
234                break;
235            }
236        }
237    }
238
239    // All workers will receive an error after the last item. Note that there are still
240    // up to worker_count * 10 items in the queue.
241    drop(sender);
242
243    wait_for_workers(worker_handles, send_error).await?;
244
245    Ok(source_files)
246}
247
248/// Bytecode compile the given Python source files using a pool of Python interpreters.
249///
250/// All paths must be absolute. Compilation errors are muted (like pip), while failures to launch
251/// or communicate with the Python workers are returned.
252#[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    // Running Python with an actual file will produce better error messages.
272    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    // Sometimes, the first time we read from stdout, we get an empty string back (no newline). If
329    // we try to write to stdin, it will often be a broken pipe. In this case, we have to restart
330    // the child process
331    // https://github.com/astral-sh/uv/issues/2245
332    let wait_until_ready = async {
333        loop {
334            // If the interpreter started successful, return it, else retry.
335            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    // Handle a broken `python` by using a timeout, one that's higher than any compilation
344    // should ever take.
345    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    // Reap the process to avoid zombies.
364    let _ = bytecode_compiler.kill().await;
365
366    // If there was something printed to stderr (which shouldn't happen, we muted all errors), tell
367    // the user, otherwise only forward the result.
368    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
400/// Returns the child and stdin/stdout/stderr on a successful launch or `None` for a broken interpreter state.
401async 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    // We input the paths through stdin and get the successful paths returned through stdout.
415    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        // Otherwise stdout is buffered and we'll wait forever for a response
422        .env(EnvVars::PYTHONUNBUFFERED, "1")
423        .spawn()
424        .map_err(CompileError::PythonSubcommand)?;
425
426    // https://stackoverflow.com/questions/49218599/write-to-child-process-stdin-in-rust/49597789#comment120223107_49597789
427    // Unbuffered, we need to write immediately or the python process will get stuck waiting
428    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    // Check if the launch was successful.
446    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        // Success
457        Ok(Some((
458            bytecode_compiler,
459            child_stdin,
460            child_stdout,
461            child_stderr,
462        )))
463    } else if out_line.is_empty() {
464        // Failed to launch, try again
465        Ok(None)
466    } else {
467        // Not observed yet
468        Err(CompileError::WrongPath("Ready".to_string(), out_line))
469    }
470}
471
472/// We use stdin/stdout as a sort of bounded channel. We write one path to stdin, then wait until
473/// we get the same path back from stdout. This way we ensure one worker is only working on one
474/// piece of work at the same time.
475async 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        // Luckily, LF alone works on windows too
489        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        // Handle a broken `python` by using a timeout, one that's higher than any compilation
511        // should ever take.
512        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        // This is a sanity check, if we don't get the path back something has gone wrong, e.g.
524        // we're not actually running a python interpreter.
525        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}