1use std::collections::BTreeMap;
18use std::ffi::OsString;
19use std::fs;
20use std::hash::Hasher;
21use std::path::{Path, PathBuf};
22use std::process::Command;
23use std::sync::{Arc, Mutex, OnceLock, PoisonError, TryLockError};
24use std::time::{Duration, Instant, SystemTime};
25
26use byob_sandbox::RunOptions;
27
28use crate::authoring::Fnv;
29
30pub const HELPER: &str = include_str!("byob_plugin.py");
32pub const HELPER_FILE: &str = "byob_plugin.py";
34const BUILD_TIMEOUT: Duration = Duration::from_secs(15 * 60);
36const MAX_BUILD_OUTPUT: usize = 64 * 1024;
38const PROBLEM_OUTPUT: usize = 3000;
40const READY: &str = ".byob-ready";
42pub const BUILD_CALLED_OFF: &str = "the Python environment wasn't built: stopped";
44const LOCK_BEAT: Duration = Duration::from_secs(20);
46const LOCK_STALE: Duration = Duration::from_secs(120);
48const LOCK_POLL: Duration = Duration::from_millis(100);
50
51#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct Python {
54 pub program: Vec<OsString>,
56 pub read: Vec<PathBuf>,
58 pub env: BTreeMap<String, String>,
60}
61
62pub struct PyEnvs {
64 root: PathBuf,
65 base: Vec<OsString>,
66 uv: Option<Option<PathBuf>>,
68 offline: bool,
69 helper: OnceLock<Result<PathBuf, String>>,
70 envs: Mutex<BTreeMap<String, Arc<PyEnv>>>,
71}
72
73impl PyEnvs {
74 pub fn new(root: impl Into<PathBuf>, base: Vec<OsString>) -> Self {
77 Self { root: root.into(), base, uv: None, offline: false, helper: OnceLock::new(), envs: Mutex::new(BTreeMap::new()) }
78 }
79
80 pub fn with_uv(mut self, uv: Option<PathBuf>) -> Self {
82 self.uv = Some(uv);
83 self
84 }
85
86 pub fn offline(mut self) -> Self {
88 self.offline = true;
89 self
90 }
91
92 pub fn base(&self) -> &[OsString] {
93 &self.base
94 }
95
96 pub fn helper_dir(&self) -> Result<PathBuf, String> {
98 self.helper
99 .get_or_init(|| {
100 let dir = self.root.join("lib");
101 let file = dir.join(HELPER_FILE);
102 if fs::read_to_string(&file).ok().as_deref() != Some(HELPER) {
103 fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
104 let tmp = dir.join(format!(".{HELPER_FILE}.{}", std::process::id()));
105 fs::write(&tmp, HELPER).and_then(|()| fs::rename(&tmp, &file)).map_err(|e| format!("{}: {e}", file.display()))?;
106 }
107 Ok(dir)
108 })
109 .clone()
110 }
111
112 pub fn for_plugin(&self, requirements: &[String], dir: &Path) -> Arc<PyEnv> {
115 let reqs: Vec<String> =
116 requirements.iter().map(|r| if r.starts_with("./") || r.starts_with("../") { dir.join(r).display().to_string() } else { r.clone() }).collect();
117 let mut h = Fnv::default();
118 for p in &self.base {
119 h.write(p.as_encoded_bytes());
120 h.write_u8(0);
121 }
122 let mut sorted = reqs.clone();
123 sorted.sort();
124 for r in &sorted {
125 h.write(r.as_bytes());
126 h.write_u8(1);
127 }
128 let key = format!("{:016x}", h.finish());
129 let mut envs = self.envs.lock().unwrap_or_else(PoisonError::into_inner);
130 let env = envs.entry(key.clone()).or_insert_with(|| {
131 let uv = match &self.uv {
132 Some(u) => u.clone(),
133 None => byob_sandbox::which(std::ffi::OsStr::new("uv")),
134 };
135 Arc::new(PyEnv {
136 dir: self.root.join("venvs").join(&key),
137 reqs,
138 base: self.base.clone(),
139 uv,
140 offline: self.offline,
141 state: Mutex::new(State::Unknown),
142 build: Mutex::new(()),
143 })
144 });
145 Arc::clone(env)
146 }
147
148 pub fn forget_failed(&self) {
150 self.envs.lock().unwrap_or_else(PoisonError::into_inner).retain(|_, e| e.failure().is_none());
151 }
152
153 pub fn python(&self, env: Option<&PyEnv>, cancelled: &dyn Fn() -> bool) -> Result<Python, String> {
156 let helper = self.helper_dir();
157 let mut read = Vec::new();
158 let mut vars = BTreeMap::new();
159 match &helper {
160 Ok(dir) => {
161 let mut path = dir.display().to_string();
162 if let Some(old) = std::env::var_os("PYTHONPATH").filter(|p| !p.is_empty()) {
163 path.push(if cfg!(windows) { ';' } else { ':' });
164 path.push_str(&old.to_string_lossy());
165 }
166 vars.insert("PYTHONPATH".to_owned(), path);
167 read.push(dir.clone());
168 }
169 Err(e) => log::warn!("the byob_plugin helper isn't available: {e}"),
170 }
171 vars.insert("PYTHONUNBUFFERED".to_owned(), "1".to_owned());
172 vars.insert("PYTHONUTF8".to_owned(), "1".to_owned());
174 vars.insert("PYTHONIOENCODING".to_owned(), "utf-8".to_owned());
175 let program = match env {
176 None => self.base.clone(),
177 Some(env) => {
178 let python = env.ensure(cancelled)?;
179 read.push(env.dir.clone());
180 vec![python.into_os_string()]
181 }
182 };
183 Ok(Python { program, read, env: vars })
184 }
185}
186
187#[derive(Clone)]
189pub struct PluginPython {
190 pub envs: Arc<PyEnvs>,
191 pub env: Option<Arc<PyEnv>>,
192}
193
194impl PluginPython {
195 pub fn new(envs: &Arc<PyEnvs>, requirements: &[String], dir: &Path) -> Self {
197 let env = (!requirements.is_empty()).then(|| envs.for_plugin(requirements, dir));
198 Self { envs: Arc::clone(envs), env }
199 }
200
201 pub fn python(&self) -> Result<Python, String> {
203 self.python_until(&|| false)
204 }
205
206 pub fn python_until(&self, cancelled: &dyn Fn() -> bool) -> Result<Python, String> {
208 self.envs.python(self.env.as_deref(), cancelled)
209 }
210
211 pub fn failure(&self) -> Option<String> {
213 self.env.as_ref().and_then(|e| e.failure())
214 }
215}
216
217#[derive(Debug, Clone)]
218enum State {
219 Unknown,
220 Ready(PathBuf),
221 Failed(String),
222}
223
224pub struct PyEnv {
226 dir: PathBuf,
227 reqs: Vec<String>,
228 base: Vec<OsString>,
229 uv: Option<PathBuf>,
230 offline: bool,
231 state: Mutex<State>,
232 build: Mutex<()>,
235}
236
237impl PyEnv {
238 pub fn dir(&self) -> &Path {
239 &self.dir
240 }
241
242 pub fn requirements(&self) -> &[String] {
243 &self.reqs
244 }
245
246 pub fn failure(&self) -> Option<String> {
248 match &*self.state.lock().unwrap_or_else(PoisonError::into_inner) {
249 State::Failed(e) => Some(e.clone()),
250 _ => None,
251 }
252 }
253
254 pub fn ready(&self) -> bool {
256 matches!(&*self.state.lock().unwrap_or_else(PoisonError::into_inner), State::Ready(_)) || self.dir.join(READY).is_file()
257 }
258
259 fn set(&self, s: State) {
260 *self.state.lock().unwrap_or_else(PoisonError::into_inner) = s;
261 }
262
263 fn get(&self) -> State {
264 self.state.lock().unwrap_or_else(PoisonError::into_inner).clone()
265 }
266
267 pub fn ensure(&self, cancelled: &dyn Fn() -> bool) -> Result<PathBuf, String> {
270 match self.get() {
271 State::Ready(p) => return Ok(p),
272 State::Failed(e) => return Err(e),
273 State::Unknown => {}
274 }
275 let _building = loop {
276 match self.build.try_lock() {
277 Ok(g) => break g,
278 Err(TryLockError::Poisoned(p)) => break p.into_inner(),
279 Err(TryLockError::WouldBlock) if cancelled() => return Err(BUILD_CALLED_OFF.into()),
280 Err(TryLockError::WouldBlock) => std::thread::sleep(LOCK_POLL),
281 }
282 };
283 match self.get() {
285 State::Ready(p) => return Ok(p),
286 State::Failed(e) => return Err(e),
287 State::Unknown => {}
288 }
289 let python = venv_python(&self.dir);
290 if self.built(&python) {
291 self.set(State::Ready(python.clone()));
292 return Ok(python);
293 }
294 if let Some(parent) = self.dir.parent() {
295 fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
296 }
297 let lock = BuildLock::take(&self.dir.with_extension("lock"), cancelled)?;
298 if self.built(&python) {
300 self.set(State::Ready(python.clone()));
301 return Ok(python);
302 }
303 log::info!("building a Python environment for {} in {}", self.reqs.join(" "), self.dir.display());
304 let called_off = || {
305 lock.beat();
306 cancelled()
307 };
308 match self.build_now(&python, &called_off) {
309 Ok(()) => {
310 self.set(State::Ready(python.clone()));
311 Ok(python)
312 }
313 Err(e) => {
314 let _ = fs::remove_dir_all(&self.dir);
315 if cancelled() {
316 return Err(BUILD_CALLED_OFF.into());
318 }
319 let e = format!("python.requirements couldn't be installed: {e}");
320 self.set(State::Failed(e.clone()));
321 Err(e)
322 }
323 }
324 }
325
326 fn built(&self, python: &Path) -> bool {
327 self.dir.join(READY).is_file() && python.exists()
328 }
329
330 fn build_now(&self, python: &Path, cancelled: &dyn Fn() -> bool) -> Result<(), String> {
331 let _ = fs::remove_dir_all(&self.dir);
332 let Some((base, lead)) = self.base.split_first() else { return Err("no Python interpreter configured".into()) };
333 match &self.uv {
334 Some(uv) => {
335 let mut c = Command::new(uv);
336 c.arg("venv").arg("--quiet").arg("--python").arg(base).arg(&self.dir);
337 run(c, "uv venv", cancelled)?;
338 let mut c = Command::new(uv);
339 c.args(["pip", "install", "--python"]).arg(python).args(self.offline.then_some("--offline")).args(&self.reqs);
340 run(c, "uv pip install", cancelled)?;
341 }
342 None => {
343 let mut c = Command::new(base);
344 c.args(lead).args(["-m", "venv"]).arg(&self.dir);
345 run(c, "python -m venv", cancelled)?;
346 let mut c = Command::new(python);
347 c.args(["-m", "pip", "install", "--disable-pip-version-check", "--no-input"]).args(self.offline.then_some("--no-index")).args(&self.reqs);
348 run(c, "pip install", cancelled)?;
349 }
350 }
351 fs::write(self.dir.join(READY), self.reqs.join("\n")).map_err(|e| format!("{}: {e}", self.dir.display()))
352 }
353}
354
355struct BuildLock {
358 path: PathBuf,
359 file: Option<fs::File>,
360 beat: std::cell::Cell<Instant>,
361}
362
363impl BuildLock {
364 fn take(path: &Path, cancelled: &dyn Fn() -> bool) -> Result<Self, String> {
366 let mut waited = false;
367 loop {
368 match fs::OpenOptions::new().write(true).create_new(true).open(path) {
369 Ok(mut file) => {
370 let _ = std::io::Write::write_all(&mut file, format!("{}\n", std::process::id()).as_bytes());
371 return Ok(Self { path: path.to_path_buf(), file: Some(file), beat: std::cell::Cell::new(Instant::now()) });
372 }
373 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
374 let age = fs::metadata(path).and_then(|m| m.modified()).ok().and_then(|t| SystemTime::now().duration_since(t).ok());
375 if age.is_some_and(|a| a > LOCK_STALE) {
376 log::warn!("{} is left over from a build that stopped; taking it over", path.display());
377 let _ = fs::remove_file(path);
378 continue;
379 }
380 if !waited {
381 log::info!("waiting for another byob to build the Python environment ({})", path.display());
382 waited = true;
383 }
384 }
385 Err(e) => return Err(format!("{}: {e}", path.display())),
386 }
387 if cancelled() {
388 return Err(BUILD_CALLED_OFF.into());
389 }
390 std::thread::sleep(LOCK_POLL);
391 }
392 }
393
394 fn beat(&self) {
396 if self.beat.get().elapsed() >= LOCK_BEAT {
397 if let Some(f) = &self.file {
398 let _ = f.set_modified(SystemTime::now());
399 }
400 self.beat.set(Instant::now());
401 }
402 }
403}
404
405impl Drop for BuildLock {
406 fn drop(&mut self) {
407 drop(self.file.take());
408 let _ = fs::remove_file(&self.path);
409 }
410}
411
412fn venv_python(dir: &Path) -> PathBuf {
413 if cfg!(windows) { dir.join("Scripts").join("python.exe") } else { dir.join("bin").join("python") }
414}
415
416fn run(cmd: Command, what: &str, cancelled: &dyn Fn() -> bool) -> Result<(), String> {
417 let opts = RunOptions { stdin: Vec::new(), timeout: BUILD_TIMEOUT, max_output: MAX_BUILD_OUTPUT };
418 let o = byob_sandbox::run(cmd, &opts, cancelled).map_err(|e| format!("{what}: {e}"))?;
419 if o.success() {
420 return Ok(());
421 }
422 if o.cancelled {
423 return Err(BUILD_CALLED_OFF.into());
424 }
425 let out = format!("{}\n{}", o.stdout.text().trim(), o.stderr.text().trim());
426 let out = out.trim();
427 let start = out.char_indices().rev().nth(PROBLEM_OUTPUT).map_or(0, |(i, _)| i);
428 let why = if o.timed_out { "took too long".to_owned() } else { format!("exit code {}", o.exit_code.map_or_else(|| "none".into(), |c| c.to_string())) };
429 Err(format!("{what} failed ({why}):\n{}", out.get(start..).unwrap_or(out)))
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435
436 #[test]
437 fn same_requirements_share_an_environment() {
438 let root = tempfile::tempdir().unwrap();
439 let envs = PyEnvs::new(root.path(), crate::default_python()).with_uv(None);
440 let a = envs.for_plugin(&["x==1".into(), "y".into()], Path::new("/p/a"));
441 let b = envs.for_plugin(&["y".into(), "x==1".into()], Path::new("/p/b"));
442 let c = envs.for_plugin(&["x==2".into()], Path::new("/p/a"));
443 assert_eq!(a.dir(), b.dir());
444 assert_ne!(a.dir(), c.dir());
445 let w = envs.for_plugin(&["./wheels/w.whl".into()], Path::new("/p/a"));
446 assert_eq!(w.requirements(), [Path::new("/p/a").join("./wheels/w.whl").display().to_string()]);
447 }
448
449 #[test]
450 fn helper_is_written_once_and_on_the_path() {
451 let root = tempfile::tempdir().unwrap();
452 let envs = PyEnvs::new(root.path(), crate::default_python());
453 let py = envs.python(None, &|| false).unwrap();
454 let dir = envs.helper_dir().unwrap();
455 assert_eq!(fs::read_to_string(dir.join(HELPER_FILE)).unwrap(), HELPER);
456 assert!(py.env["PYTHONPATH"].starts_with(&dir.display().to_string()));
457 assert_eq!(py.program, crate::default_python());
458 }
459
460 #[test]
461 fn python_speaks_utf8_whatever_the_locale() {
462 let root = tempfile::tempdir().unwrap();
463 let py = PyEnvs::new(root.path(), crate::default_python()).python(None, &|| false).unwrap();
464 assert_eq!(py.env.get("PYTHONUTF8").map(String::as_str), Some("1"));
465 assert_eq!(py.env.get("PYTHONIOENCODING").map(String::as_str), Some("utf-8"));
466 }
467
468 fn broken(root: &Path) -> Arc<PyEnv> {
470 PyEnvs::new(root, vec!["/definitely/not/python".into()]).with_uv(None).for_plugin(&["x".into()], Path::new("/p"))
471 }
472
473 #[test]
474 fn another_byobs_build_is_waited_for_and_used_not_clobbered() {
475 let root = tempfile::tempdir().unwrap();
476 let env = broken(root.path());
477 let lock = env.dir().with_extension("lock");
478 fs::create_dir_all(env.dir()).unwrap();
479 fs::write(&lock, "4242\n").unwrap();
480 let dir = env.dir().to_path_buf();
482 let other = std::thread::spawn(move || {
483 std::thread::sleep(Duration::from_millis(300));
484 let python = venv_python(&dir);
485 fs::create_dir_all(python.parent().unwrap()).unwrap();
486 fs::write(&python, "").unwrap();
487 fs::write(dir.join(READY), "x").unwrap();
488 fs::remove_file(dir.with_extension("lock")).unwrap();
489 });
490 assert_eq!(env.ensure(&|| false), Ok(venv_python(env.dir())));
491 other.join().unwrap();
492 assert!(env.dir().join(READY).is_file(), "its build was left alone");
493 }
494
495 #[test]
496 fn waiting_for_another_build_can_be_called_off() {
497 let root = tempfile::tempdir().unwrap();
498 let env = broken(root.path());
499 fs::create_dir_all(env.dir()).unwrap();
500 fs::write(env.dir().join("theirs"), "").unwrap();
501 fs::write(env.dir().with_extension("lock"), "4242\n").unwrap();
502 let t = Instant::now();
503 assert_eq!(env.ensure(&|| t.elapsed() > Duration::from_millis(200)), Err(BUILD_CALLED_OFF.to_owned()));
504 assert!(env.dir().join("theirs").exists(), "their half-built environment was left alone");
505 assert!(env.failure().is_none(), "not a failure: tried again next time");
506 }
507
508 #[test]
509 fn a_lock_left_by_a_byob_that_died_is_taken_over() {
510 let root = tempfile::tempdir().unwrap();
511 let env = broken(root.path());
512 let lock = env.dir().with_extension("lock");
513 fs::create_dir_all(env.dir()).unwrap();
514 let f = fs::File::create(&lock).unwrap();
515 f.set_modified(SystemTime::now() - Duration::from_secs(3600)).unwrap();
516 drop(f);
517 let e = env.ensure(&|| false).unwrap_err();
518 assert!(e.starts_with("python.requirements couldn't be installed"), "{e}");
519 assert!(!lock.exists(), "the lock goes with the build");
520 }
521
522 #[cfg(unix)]
523 #[test]
524 fn a_build_can_be_called_off() {
525 let root = tempfile::tempdir().unwrap();
526 let base: Vec<OsString> = vec!["sh".into(), "-c".into(), "sleep 30".into(), "sh".into()];
528 let env = PyEnvs::new(root.path(), base).with_uv(None).for_plugin(&["x".into()], Path::new("/p"));
529 let t = Instant::now();
530 assert_eq!(env.ensure(&|| t.elapsed() > Duration::from_millis(300)), Err(BUILD_CALLED_OFF.to_owned()));
531 assert!(t.elapsed() < Duration::from_secs(10), "{:?}", t.elapsed());
532 assert!(env.failure().is_none());
533 assert!(!env.dir().exists());
534 assert!(!env.dir().with_extension("lock").exists());
535 }
536}