Skip to main content

cpex_hosts_python/
venv.rs

1// Location: ./crates/cpex-hosts-python/src/venv.rs
2// Copyright 2026
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Ted Habeck
5//
6// Venv manager — builds, caches, and reuses a per-plugin virtualenv.
7//
8// Ported from `cpex/framework/isolated/client.py` (`create_venv`,
9// `_is_venv_cache_valid`, `_save_cache_metadata`, `_manifest_path`). The
10// layout is deliberately identical to the Python CLI's so a venv built by
11// either side is reusable by the other: `.venv` under the plugin's class-root
12// directory, cache metadata under `.cpex/venv_cache`.
13//
14// # Cache validity
15//
16// Reuse is keyed on a SHA256 of the requirements file plus the persisted
17// manifest's version and content hash. Requirements alone is not enough: a
18// plugin installed by FQN conversion has no requirements file, so its
19// requirements hash is a constant and the manifest signals are the only way
20// to notice it changed.
21//
22// The manifest signals are read as *optional*. Metadata written by an older
23// CLI has no `manifest_version` / `manifest_hash` keys, and reading an absent
24// key as `None` and comparing it against a real value would treat every
25// pre-existing install as a mismatch — wiping and rebuilding every venv on
26// the first run after an upgrade. An absent key therefore means "no signal"
27// and is skipped; only a key that is present *and* differs invalidates.
28//
29// # Blocking work
30//
31// `initialize()` is awaited sequentially by the manager, so a multi-minute
32// pip install on the runtime thread would stall every other plugin's init
33// and the rollback path with it. Subprocesses go through `tokio::process`
34// and synchronous filesystem work through `spawn_blocking`.
35//
36// Moving pip off the runtime thread keeps the *thread* free but not the
37// *sequence*: `ensure()` is still awaited to completion before the next
38// plugin's `initialize()` starts. A pip that never returns (an unreachable
39// index with no client timeout, a stuck TLS handshake, a resolver backtracking
40// forever) therefore stalls startup indefinitely, so the install is bounded by
41// a wall-clock timeout and the child is killed on expiry rather than leaked —
42// see `install_requirements` and `DEFAULT_PIP_TIMEOUT_SECS`.
43//
44// # Shared venvs and ownership
45//
46// A venv directory is keyed on the class *root*, so every plugin in one package
47// deliberately shares it (see `VenvLayout`). Sharing makes the rebuild path
48// destructive in a way per-plugin venvs never were: `prepare_directories`
49// removes the venv before recreating it, and a second plugin rebuilding for its
50// own reasons would delete the interpreter and site-packages the first plugin's
51// already-running worker is executing out of.
52//
53// Ownership is therefore tracked explicitly. `.cpex/venv_cache/owners.json`
54// records which plugins (by full class name) currently consider the shared venv
55// theirs, and a rebuild is only allowed to delete the directory when the set
56// holds nobody but the caller. A plugin that finds co-owners registered
57// registers itself and reuses the venv in place instead of wiping it. That
58// keeps the intended sharing while removing the mutual-destruction window.
59
60use std::collections::BTreeSet;
61use std::fmt::Write as _;
62use std::path::{Path, PathBuf};
63
64use serde::{Deserialize, Serialize};
65use sha2::{Digest, Sha256};
66
67use crate::error::{HostError, ProcessOutput};
68
69/// Default wall-clock bound on one `pip install` invocation, in seconds.
70///
71/// Ten minutes: long enough for a cold install of a heavy dependency tree over
72/// a slow link (torch-sized wheels routinely take minutes), short enough that a
73/// wedged pip does not hold startup open forever. `initialize()` is awaited
74/// sequentially, so this is the per-plugin worst case added to boot.
75pub const DEFAULT_PIP_TIMEOUT_SECS: u64 = 600;
76
77/// Cache metadata persisted next to each venv.
78///
79/// Field names match what `client.py`'s `_save_cache_metadata` writes, so
80/// metadata is interchangeable between the two implementations.
81///
82/// `manifest_version` and `manifest_hash` are `Option` to distinguish
83/// "absent" (older metadata, no signal) from "present and different"
84/// (a real change). That distinction is the whole point — see the module
85/// docs.
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct CacheMetadata {
88    /// Absolute path of the venv this metadata describes.
89    pub venv_path: String,
90
91    /// Absolute path of the requirements file, when there was one.
92    #[serde(default)]
93    pub requirements_file: Option<String>,
94
95    /// SHA256 of the requirements file content (empty-content digest when
96    /// there is no file).
97    pub requirements_hash: String,
98
99    /// Manifest version at install time. Absent in metadata written before
100    /// this signal existed.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub manifest_version: Option<String>,
103
104    /// SHA256 of the persisted manifest content. Absent in older metadata.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub manifest_hash: Option<String>,
107
108    /// Interpreter version the venv was built with, for diagnostics.
109    #[serde(default)]
110    pub python_version: Option<String>,
111}
112
113/// Why a cached venv was rejected. Carried rather than logged-and-dropped so
114/// tests can pin the specific rule that fired and operators get a precise
115/// reason.
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum CacheVerdict {
118    /// The cache is usable as-is.
119    Valid,
120    /// The venv directory does not exist.
121    VenvMissing,
122    /// No metadata file sits alongside the venv.
123    MetadataMissing,
124    /// The metadata file could not be parsed.
125    MetadataUnreadable,
126    /// The requirements file content changed.
127    RequirementsChanged,
128    /// The manifest version changed (signal present in metadata).
129    ManifestVersionChanged,
130    /// The manifest content changed (signal present in metadata).
131    ManifestHashChanged,
132}
133
134impl CacheVerdict {
135    /// Whether the cached venv can be reused without a rebuild.
136    pub fn is_valid(&self) -> bool {
137        matches!(self, Self::Valid)
138    }
139}
140
141/// SHA256 of a file's bytes, hex-encoded.
142///
143/// An absent or unreadable path hashes to the empty-content digest, matching
144/// `client.py`'s `_compute_requirements_hash`: a plugin with no requirements
145/// file gets a stable constant rather than an error, so the manifest signals
146/// carry the change detection instead.
147pub fn hash_file_or_empty(path: Option<&Path>) -> String {
148    let mut hasher = Sha256::new();
149    match path {
150        Some(p) => match std::fs::read(p) {
151            Ok(bytes) => hasher.update(&bytes),
152            Err(_) => hasher.update(b""),
153        },
154        None => hasher.update(b""),
155    }
156    let digest = hasher.finalize();
157    let mut hex = String::with_capacity(digest.len() * 2);
158    for byte in digest {
159        let _ = write!(hex, "{byte:02x}");
160    }
161    hex
162}
163
164/// Filesystem-safe stem for a fully-qualified class name.
165///
166/// Sanitization must match the Python side's `manifest_filename_for_class`
167/// byte for byte: non-alphanumerics (other than `-` and `_`) become `-`,
168/// leading and trailing `-` are stripped, and an empty result degrades to
169/// `plugin` rather than producing a dotfile.
170fn manifest_stem_for_class(class_name: &str) -> String {
171    let safe: String = class_name
172        .trim()
173        .chars()
174        .map(|c| {
175            if c.is_alphanumeric() || c == '-' || c == '_' {
176                c
177            } else {
178                '-'
179            }
180        })
181        .collect();
182    let safe = safe.trim_matches('-');
183    if safe.is_empty() {
184        "plugin".to_string()
185    } else {
186        safe.to_string()
187    }
188}
189
190/// Per-plugin manifest filename for a fully-qualified class name.
191///
192/// Plugins that share a package share one venv directory (it is keyed on the
193/// class root), but each needs its own manifest file: if `pkg.a.PluginA` and
194/// `pkg.b.PluginB` shared one, installing either would change the other's
195/// manifest hash and both would rebuild forever.
196///
197/// Must produce byte-identical output to the Python side's
198/// `manifest_filename_for_class` — both implementations resolve the same path,
199/// and a divergence means each sees the other's install as a change.
200pub fn manifest_filename_for_class(class_name: &str) -> String {
201    format!(
202        "{}.plugin-manifest.yaml",
203        manifest_stem_for_class(class_name)
204    )
205}
206
207/// Decide whether a cached venv can be reused.
208///
209/// `expected_manifest_version` and `expected_manifest_hash` are what the
210/// plugin declares *now*; the metadata carries what was true at install
211/// time. A metadata signal that is absent is skipped rather than compared —
212/// see the module docs for why that asymmetry matters.
213pub fn evaluate_cache(
214    venv_path: &Path,
215    metadata_path: &Path,
216    expected_requirements_hash: &str,
217    expected_manifest_version: Option<&str>,
218    expected_manifest_hash: Option<&str>,
219) -> CacheVerdict {
220    if !venv_path.exists() {
221        return CacheVerdict::VenvMissing;
222    }
223    if !metadata_path.exists() {
224        return CacheVerdict::MetadataMissing;
225    }
226
227    let Ok(raw) = std::fs::read_to_string(metadata_path) else {
228        return CacheVerdict::MetadataUnreadable;
229    };
230    let Ok(metadata) = serde_json::from_str::<CacheMetadata>(&raw) else {
231        return CacheVerdict::MetadataUnreadable;
232    };
233
234    if metadata.requirements_hash != expected_requirements_hash {
235        return CacheVerdict::RequirementsChanged;
236    }
237
238    // Present-and-different invalidates; absent is "no signal" and skipped.
239    if let Some(cached) = metadata.manifest_version.as_deref()
240        && Some(cached) != expected_manifest_version
241    {
242        return CacheVerdict::ManifestVersionChanged;
243    }
244    if let Some(cached) = metadata.manifest_hash.as_deref()
245        && Some(cached) != expected_manifest_hash
246    {
247        return CacheVerdict::ManifestHashChanged;
248    }
249
250    CacheVerdict::Valid
251}
252
253/// Filename, inside `cache_dir`, of the shared venv's owner registry.
254///
255/// One file per venv directory rather than per plugin — it is the *shared*
256/// resource's membership list, so every co-owner must read and write the same
257/// path.
258const OWNERS_FILENAME: &str = "owners.json";
259
260/// The set of plugins that currently claim a shared venv.
261///
262/// Persisted rather than held in memory because co-owners can live in different
263/// processes (a gateway and a `cpex` CLI invocation against the same
264/// `plugins/` tree), and an in-memory set would not see the other side.
265///
266/// A `BTreeSet` keeps the on-disk order stable, so the file does not churn.
267#[derive(Debug, Clone, Default, Serialize, Deserialize)]
268pub struct VenvOwners {
269    /// Full class names of the plugins claiming this venv.
270    #[serde(default)]
271    pub owners: BTreeSet<String>,
272}
273
274impl VenvOwners {
275    /// Read the registry, treating an absent or corrupt file as empty.
276    ///
277    /// Absent is the common case (a venv this host has never built). Corrupt is
278    /// treated the same way deliberately: the registry is an optimization for
279    /// *not* deleting a venv, so failing open to "no recorded owners" preserves
280    /// the pre-existing rebuild behavior rather than wedging startup on an
281    /// unparseable file.
282    pub fn load(path: &Path) -> Self {
283        std::fs::read_to_string(path)
284            .ok()
285            .and_then(|raw| serde_json::from_str(&raw).ok())
286            .unwrap_or_default()
287    }
288
289    /// Whether anyone other than `class_name` claims this venv.
290    pub fn has_other_owner(&self, class_name: &str) -> bool {
291        self.owners.iter().any(|o| o != class_name)
292    }
293}
294
295/// Resolved on-disk layout for one plugin's venv.
296///
297/// The class *root* (the first dotted segment of the class name) names the
298/// directory, which is what makes plugins in one package share a venv.
299#[derive(Debug, Clone)]
300pub struct VenvLayout {
301    /// Directory holding the venv and its cache — `<plugin_dir>/<class_root>`.
302    pub plugin_path: PathBuf,
303    /// The virtualenv itself.
304    pub venv_path: PathBuf,
305    /// Cache-metadata directory.
306    pub cache_dir: PathBuf,
307    /// This plugin's metadata file within `cache_dir`.
308    pub metadata_path: PathBuf,
309    /// This plugin's persisted manifest.
310    pub manifest_path: PathBuf,
311    /// Owner registry for the shared venv directory. Shared by every plugin
312    /// that resolves to this `venv_path`, unlike `metadata_path`.
313    pub owners_path: PathBuf,
314}
315
316impl VenvLayout {
317    /// Resolve the layout for a class name under the first configured plugin
318    /// dir, mirroring `client.py`'s `__init__`.
319    pub fn resolve(plugin_dirs: &[String], class_name: &str) -> Result<Self, HostError> {
320        // The host supplies `<project root>/plugins`, so an empty list here is
321        // an internal error rather than a misconfiguration — the message avoids
322        // naming a config key an operator could set, because there isn't one.
323        let first = plugin_dirs.first().ok_or_else(|| HostError::Config {
324            message: "isolated_venv requires at least one plugin directory — \
325                      the venv is built under the first one"
326                .into(),
327        })?;
328
329        let class_root = class_name.split('.').next().unwrap_or(class_name);
330        if class_root.is_empty() {
331            return Err(HostError::Config {
332                message: format!("`class_name` '{class_name}' has no leading package segment"),
333            });
334        }
335
336        let plugin_path = Path::new(first).join(class_root);
337        let venv_path = plugin_path.join(".venv");
338        let cache_dir = plugin_path.join(".cpex").join("venv_cache");
339
340        // Both filenames are keyed on the full class name.
341        //
342        // This diverges from `client.py`, which keys the *metadata* filename on
343        // the venv directory name (`_get_cache_metadata_path` reads
344        // `Path(venv_path).name`, always `.venv`) while keying the *manifest*
345        // per class. Since plugins in one package deliberately share a venv
346        // directory, that gives them one shared `.venv_metadata.json`: each
347        // install overwrites the other's `requirements_hash` and
348        // `manifest_hash`, so each plugin's build invalidates its neighbour's
349        // cache and the two rebuild in a loop — the exact thrash the per-class
350        // manifest naming exists to prevent, left half-solved.
351        //
352        // Keying both on the class name closes it. The cost is that a venv
353        // built by the Python CLI is not recognized as cached by this host on
354        // first run (its metadata sits under the other filename), so the host
355        // rebuilds once and is a cache hit forever after. That one-time
356        // rebuild is strictly better than a permanent rebuild loop.
357        let metadata_path = cache_dir.join(format!(
358            "{}_metadata.json",
359            manifest_stem_for_class(class_name)
360        ));
361        let manifest_path = plugin_path.join(manifest_filename_for_class(class_name));
362        // Deliberately *not* keyed on the class name: this is the shared venv's
363        // membership list, so co-owners must all read the same file.
364        let owners_path = cache_dir.join(OWNERS_FILENAME);
365
366        Ok(Self {
367            plugin_path,
368            venv_path,
369            cache_dir,
370            metadata_path,
371            manifest_path,
372            owners_path,
373        })
374    }
375
376    /// The venv's own Python interpreter.
377    pub fn python_executable(&self) -> PathBuf {
378        if cfg!(windows) {
379            self.venv_path.join("Scripts").join("python.exe")
380        } else {
381            self.venv_path.join("bin").join("python")
382        }
383    }
384}
385
386/// Outcome of a `VenvManager::ensure` call.
387#[derive(Debug, Clone, PartialEq, Eq)]
388pub enum EnsureOutcome {
389    /// The cached venv was reused — no rebuild, no reinstall.
390    Reused,
391    /// The venv was created (or recreated) and requirements installed.
392    Built {
393        /// Whether a requirements install actually ran. False when the plugin
394        /// has no requirements file — the venv is still fresh.
395        installed_requirements: bool,
396    },
397    /// This plugin's own cache signals were stale, but another plugin already
398    /// owns the shared venv directory, so it was updated in place rather than
399    /// deleted and recreated.
400    ///
401    /// Distinct from `Built` because the venv was *not* recreated: the
402    /// interpreter another plugin's worker is running out of is the same one as
403    /// before. Distinct from `Reused` because an install may have run.
404    SharedUpdate {
405        /// Whether a requirements install ran into the existing venv.
406        installed_requirements: bool,
407    },
408}
409
410/// Builds and caches one plugin's virtualenv.
411///
412/// Construction is cheap and does no I/O; `ensure()` does the work and is
413/// called from the plugin's `initialize()`.
414#[derive(Debug, Clone)]
415pub struct VenvManager {
416    layout: VenvLayout,
417    /// Absolute path of the requirements file, when the plugin has one and it
418    /// exists on disk.
419    requirements_file: Option<PathBuf>,
420    /// Manifest version the plugin declares now, if any.
421    manifest_version: Option<String>,
422    /// Full class name — this plugin's identity in the shared venv's owner
423    /// registry. The venv *directory* is keyed on the class root, so the full
424    /// name is what distinguishes co-owners of it.
425    class_name: String,
426    /// Wall-clock bound on one pip install.
427    pip_timeout_secs: u64,
428}
429
430impl VenvManager {
431    /// Resolve the layout for a plugin and record its cache inputs.
432    ///
433    /// `requirements_file` is interpreted relative to the resolved plugin
434    /// path when it is not already absolute, mirroring `client.py`'s
435    /// `package_path / requirements_file_input`.
436    pub fn new(
437        plugin_dirs: &[String],
438        class_name: &str,
439        requirements_file: Option<&str>,
440        manifest_version: Option<&str>,
441    ) -> Result<Self, HostError> {
442        let layout = VenvLayout::resolve(plugin_dirs, class_name)?;
443
444        let requirements_file = requirements_file.map(|rel| {
445            let path = Path::new(rel);
446            if path.is_absolute() {
447                path.to_path_buf()
448            } else {
449                layout.plugin_path.join(path)
450            }
451        });
452
453        Ok(Self {
454            layout,
455            requirements_file,
456            manifest_version: manifest_version.map(str::to_string),
457            class_name: class_name.to_string(),
458            pip_timeout_secs: DEFAULT_PIP_TIMEOUT_SECS,
459        })
460    }
461
462    /// Override the pip install timeout.
463    ///
464    /// Configurable because the right bound is deployment-specific: an air-gapped
465    /// mirror resolves in seconds, a cold cross-region install of a large wheel
466    /// set can legitimately take minutes. Tests use a very small value to reach
467    /// the timeout path without waiting out the default.
468    pub fn with_pip_timeout_secs(mut self, secs: u64) -> Self {
469        self.pip_timeout_secs = secs;
470        self
471    }
472
473    /// The wall-clock bound applied to one pip install.
474    pub fn pip_timeout_secs(&self) -> u64 {
475        self.pip_timeout_secs
476    }
477
478    /// The resolved on-disk layout.
479    pub fn layout(&self) -> &VenvLayout {
480        &self.layout
481    }
482
483    /// The plugins currently claiming the shared venv directory.
484    pub fn owners(&self) -> VenvOwners {
485        VenvOwners::load(&self.layout.owners_path)
486    }
487
488    /// The venv's interpreter, for launching the worker.
489    pub fn python_executable(&self) -> PathBuf {
490        self.layout.python_executable()
491    }
492
493    /// Whether the cached venv is currently reusable, and why not if it isn't.
494    pub fn cache_verdict(&self) -> CacheVerdict {
495        evaluate_cache(
496            &self.layout.venv_path,
497            &self.layout.metadata_path,
498            &hash_file_or_empty(self.requirements_file.as_deref()),
499            self.manifest_version.as_deref(),
500            Some(&hash_file_or_empty(Some(&self.layout.manifest_path))),
501        )
502    }
503
504    /// Ensure a usable venv exists, building and installing only when the
505    /// cache says it must.
506    ///
507    /// All blocking work is moved off the runtime thread: the manager awaits
508    /// each plugin's `initialize()` sequentially, so a cold pip install run
509    /// inline would stall every other plugin's init and the rollback path.
510    pub async fn ensure(&self) -> Result<EnsureOutcome, HostError> {
511        let verdict = self.cache_verdict();
512        if verdict.is_valid() {
513            tracing::info!(venv = %self.layout.venv_path.display(), "reusing cached venv");
514            // Still claim ownership. A venv this plugin is about to run out of
515            // must be registered even on the pure cache-hit path, or a
516            // co-owner initializing later would see an empty registry and feel
517            // free to delete the interpreter this plugin is using.
518            self.register_owner().await?;
519            return Ok(EnsureOutcome::Reused);
520        }
521
522        // Only the plugin that is the venv's sole owner may destroy it. With a
523        // co-owner present the directory is updated in place: this plugin's own
524        // cache signals are stale, but wiping the shared interpreter would
525        // break a worker that is already executing out of it.
526        let shared = self.venv_has_other_owner();
527        let venv_exists = self.layout.venv_path.exists();
528        let update_in_place = shared && venv_exists;
529
530        tracing::info!(
531            venv = %self.layout.venv_path.display(),
532            reason = ?verdict,
533            class_name = %self.class_name,
534            update_in_place,
535            "{}",
536            if update_in_place {
537                "updating shared venv in place — another plugin owns it"
538            } else {
539                "building venv"
540            }
541        );
542
543        self.prepare_directories(update_in_place).await?;
544        if !update_in_place {
545            self.create_venv().await?;
546        }
547
548        let installed_requirements = self.install_requirements().await?;
549        // Ownership is recorded only once the venv is usable — a failed build
550        // must not leave a claim that stops the next attempt from rebuilding.
551        self.register_owner().await?;
552        self.save_metadata().await?;
553
554        if update_in_place {
555            return Ok(EnsureOutcome::SharedUpdate {
556                installed_requirements,
557            });
558        }
559        Ok(EnsureOutcome::Built {
560            installed_requirements,
561        })
562    }
563
564    /// Whether a plugin other than this one has claimed the shared venv.
565    fn venv_has_other_owner(&self) -> bool {
566        VenvOwners::load(&self.layout.owners_path).has_other_owner(&self.class_name)
567    }
568
569    /// Add this plugin to the shared venv's owner registry.
570    ///
571    /// Read-modify-write rather than a blind overwrite: the whole point is to
572    /// preserve *other* plugins' claims. Two hosts racing here can still lose
573    /// one claim to a last-writer-wins interleaving; that degrades to the old
574    /// behavior (a rebuild that a co-owner did not expect) rather than to
575    /// silent corruption, and the sequential-init path this runs on makes the
576    /// race rare enough not to justify a lock file.
577    async fn register_owner(&self) -> Result<(), HostError> {
578        let path = self.layout.owners_path.clone();
579        let cache_dir = self.layout.cache_dir.clone();
580        let class_name = self.class_name.clone();
581
582        tokio::task::spawn_blocking(move || {
583            let mut owners = VenvOwners::load(&path);
584            if !owners.owners.insert(class_name) {
585                // Already registered — nothing to write.
586                return Ok(());
587            }
588
589            std::fs::create_dir_all(&cache_dir).map_err(|e| HostError::VenvBuild {
590                message: format!("could not create cache dir {}: {e}", cache_dir.display()),
591            })?;
592            let json = serde_json::to_string_pretty(&owners).map_err(|e| HostError::VenvBuild {
593                message: format!("could not serialize the venv owner registry: {e}"),
594            })?;
595            std::fs::write(&path, json).map_err(|e| HostError::VenvBuild {
596                message: format!("could not write venv owners {}: {e}", path.display()),
597            })
598        })
599        .await
600        .map_err(|e| HostError::VenvBuild {
601            message: format!("venv owner registration panicked: {e}"),
602        })?
603    }
604
605    /// Remove this plugin from the shared venv's owner registry.
606    ///
607    /// Called from the plugin's shutdown path so a venv stops being treated as
608    /// shared once its co-owners are gone — otherwise a stale claim would
609    /// permanently downgrade every later rebuild to an in-place update, and a
610    /// removed dependency would never actually disappear.
611    pub async fn release_owner(&self) -> Result<(), HostError> {
612        let path = self.layout.owners_path.clone();
613        let class_name = self.class_name.clone();
614
615        tokio::task::spawn_blocking(move || {
616            let mut owners = VenvOwners::load(&path);
617            if !owners.owners.remove(&class_name) {
618                return Ok(());
619            }
620
621            // The last owner leaving takes the file with it, so a fresh run
622            // starts from a clean "sole owner" state rather than an empty
623            // object it has to parse.
624            if owners.owners.is_empty() {
625                if path.exists() {
626                    std::fs::remove_file(&path).map_err(|e| HostError::VenvBuild {
627                        message: format!("could not remove venv owners {}: {e}", path.display()),
628                    })?;
629                }
630                return Ok(());
631            }
632
633            let json = serde_json::to_string_pretty(&owners).map_err(|e| HostError::VenvBuild {
634                message: format!("could not serialize the venv owner registry: {e}"),
635            })?;
636            std::fs::write(&path, json).map_err(|e| HostError::VenvBuild {
637                message: format!("could not write venv owners {}: {e}", path.display()),
638            })
639        })
640        .await
641        .map_err(|e| HostError::VenvBuild {
642            message: format!("venv owner release panicked: {e}"),
643        })?
644    }
645
646    /// Create the cache directory and, unless the venv is shared, clear any
647    /// stale venv.
648    ///
649    /// `update_in_place` suppresses the removal: a co-owned venv is upgraded by
650    /// pip rather than recreated, because deleting it would pull the interpreter
651    /// out from under another plugin's running worker.
652    async fn prepare_directories(&self, update_in_place: bool) -> Result<(), HostError> {
653        let venv_path = self.layout.venv_path.clone();
654        let cache_dir = self.layout.cache_dir.clone();
655        let plugin_path = self.layout.plugin_path.clone();
656
657        tokio::task::spawn_blocking(move || {
658            std::fs::create_dir_all(&plugin_path).map_err(|e| HostError::VenvBuild {
659                message: format!("could not create plugin dir {}: {e}", plugin_path.display()),
660            })?;
661            std::fs::create_dir_all(&cache_dir).map_err(|e| HostError::VenvBuild {
662                message: format!("could not create cache dir {}: {e}", cache_dir.display()),
663            })?;
664
665            // An invalid cache means the venv contents cannot be trusted;
666            // client.py rmtree's before rebuilding rather than upgrading in
667            // place, so a removed dependency actually disappears. That reasoning
668            // holds only for a venv this plugin owns alone — see
669            // `update_in_place`.
670            if !update_in_place && venv_path.exists() {
671                std::fs::remove_dir_all(&venv_path).map_err(|e| HostError::VenvBuild {
672                    message: format!("could not remove stale venv {}: {e}", venv_path.display()),
673                })?;
674            }
675            Ok(())
676        })
677        .await
678        .map_err(|e| HostError::VenvBuild {
679            message: format!("venv directory preparation panicked: {e}"),
680        })?
681    }
682
683    /// Run `python3 -m venv --system-site-packages=false` on the venv path.
684    ///
685    /// `client.py` uses `venv.EnvBuilder(with_pip=True, symlinks=True)`; the
686    /// CLI equivalent is `python3 -m venv` with pip left enabled.
687    async fn create_venv(&self) -> Result<(), HostError> {
688        let output = tokio::process::Command::new("python3")
689            .arg("-m")
690            .arg("venv")
691            .arg(&self.layout.venv_path)
692            .output()
693            .await
694            .map_err(|e| HostError::VenvBuild {
695                message: format!("could not run `python3 -m venv`: {e} (is python3 on PATH?)"),
696            })?;
697
698        if !output.status.success() {
699            return Err(HostError::VenvCommand {
700                step: "python3 -m venv",
701                context: self.layout.venv_path.display().to_string(),
702                output: ProcessOutput::from_output(&output),
703            });
704        }
705        Ok(())
706    }
707
708    /// Install the plugin's requirements into the venv.
709    ///
710    /// Returns whether an install ran. A plugin with no requirements file is
711    /// not an error: an FQN-converted plugin gets its package from the install
712    /// channel instead, and installing requirements transitively brings in the
713    /// `cpex` framework (and with it `worker.py`).
714    /// Install the plugin's requirements into the venv, bounded by
715    /// `pip_timeout_secs`.
716    ///
717    /// # Why the timeout is here rather than left to pip
718    ///
719    /// pip has no total-runtime bound of its own — `--timeout` covers a single
720    /// socket read, so a resolver that backtracks forever or an index that
721    /// dribbles bytes indefinitely never returns. Since the manager awaits each
722    /// plugin's `initialize()` in sequence, one such install stalls the whole
723    /// startup with no upper bound and no diagnostic.
724    ///
725    /// On expiry the child is killed rather than dropped-and-forgotten:
726    /// `kill_on_drop` would leave the reap to the runtime, and a pip mid-write
727    /// into `site-packages` needs to be stopped before this returns, or the next
728    /// plugin's install races a process still mutating the same directory.
729    async fn install_requirements(&self) -> Result<bool, HostError> {
730        let Some(requirements) = self.requirements_file.as_ref().filter(|p| p.exists()) else {
731            tracing::info!("no requirements file; skipping install");
732            return Ok(false);
733        };
734
735        let python = self.python_executable();
736        let mut child = tokio::process::Command::new(&python)
737            .args(["-m", "pip", "install", "-r"])
738            .arg(requirements)
739            .stdin(std::process::Stdio::null())
740            // stdout is discarded rather than piped: nothing reads it, and an
741            // unread pipe fills and blocks pip on a verbose install.
742            .stdout(std::process::Stdio::null())
743            .stderr(std::process::Stdio::piped())
744            // Belt and braces: if this future is cancelled rather than timing
745            // out, the child still does not outlive the host.
746            .kill_on_drop(true)
747            .spawn()
748            .map_err(|e| HostError::VenvBuild {
749                message: format!("could not run pip in {}: {e}", python.display()),
750            })?;
751
752        // stderr is drained concurrently with the wait rather than after it. pip
753        // is chatty on a failed resolve, and a full 64KiB pipe buffer would
754        // otherwise block the child forever — which the timeout would then
755        // report as a hang, hiding the real error message.
756        let stderr = child.stderr.take();
757        let stderr_task = tokio::spawn(async move {
758            let mut buf = Vec::new();
759            if let Some(mut stderr) = stderr {
760                use tokio::io::AsyncReadExt;
761                let _ = stderr.read_to_end(&mut buf).await;
762            }
763            buf
764        });
765
766        let timeout = std::time::Duration::from_secs(self.pip_timeout_secs);
767        // `wait` rather than `wait_with_output`: it borrows the child, so the
768        // handle survives the timeout branch and can actually kill it.
769        let status = match tokio::time::timeout(timeout, child.wait()).await {
770            Ok(result) => result.map_err(|e| HostError::VenvBuild {
771                message: format!("could not wait on pip in {}: {e}", python.display()),
772            })?,
773
774            Err(_) => {
775                tracing::warn!(
776                    timeout_secs = self.pip_timeout_secs,
777                    requirements = %requirements.display(),
778                    "pip install exceeded its timeout; killing it"
779                );
780
781                // Kill and reap. `kill()` here sends SIGKILL and awaits the
782                // child, so this returns only once the process is gone — a pip
783                // mid-write into site-packages must not outlive this call and
784                // race the next plugin's install into the same directory.
785                if let Err(e) = child.kill().await {
786                    tracing::warn!("could not kill the timed-out pip process: {e}");
787                }
788                stderr_task.abort();
789
790                return Err(HostError::VenvBuild {
791                    message: format!(
792                        "pip install -r {} exceeded the {}s timeout and was killed — check \
793                         network reachability to the package index, or raise the pip timeout",
794                        requirements.display(),
795                        self.pip_timeout_secs,
796                    ),
797                });
798            },
799        };
800
801        if !status.success() {
802            let stderr = stderr_task.await.unwrap_or_default();
803            // Built by hand rather than via `ProcessOutput::from_output`: the
804            // wait above borrows the child so stderr could be drained
805            // concurrently, which means there is no `std::process::Output` to
806            // convert. `status.code()` is `None` when pip was killed by a
807            // signal, which `ProcessOutput` renders distinctly from an exit.
808            return Err(HostError::VenvCommand {
809                step: "pip install",
810                context: requirements.display().to_string(),
811                output: ProcessOutput {
812                    exit_code: status.code(),
813                    stderr: String::from_utf8_lossy(&stderr).trim().to_string(),
814                },
815            });
816        }
817        Ok(true)
818    }
819
820    /// Persist the cache metadata that makes the next run a cache hit.
821    async fn save_metadata(&self) -> Result<(), HostError> {
822        let metadata = CacheMetadata {
823            venv_path: self.layout.venv_path.display().to_string(),
824            requirements_file: self
825                .requirements_file
826                .as_ref()
827                .filter(|p| p.exists())
828                .map(|p| p.display().to_string()),
829            requirements_hash: hash_file_or_empty(self.requirements_file.as_deref()),
830            manifest_version: self.manifest_version.clone(),
831            manifest_hash: Some(hash_file_or_empty(Some(&self.layout.manifest_path))),
832            python_version: None,
833        };
834
835        let path = self.layout.metadata_path.clone();
836        let json = serde_json::to_string_pretty(&metadata).map_err(|e| HostError::VenvBuild {
837            message: format!("could not serialize cache metadata: {e}"),
838        })?;
839
840        tokio::task::spawn_blocking(move || {
841            std::fs::write(&path, json).map_err(|e| HostError::VenvBuild {
842                message: format!("could not write cache metadata {}: {e}", path.display()),
843            })
844        })
845        .await
846        .map_err(|e| HostError::VenvBuild {
847            message: format!("cache metadata write panicked: {e}"),
848        })?
849    }
850}
851
852#[cfg(test)]
853mod tests {
854    use super::*;
855    use crate::testing::TempDir;
856
857    /// Empty-content SHA256 — what a plugin with no requirements file hashes
858    /// to, so it appears in most of these fixtures.
859    const EMPTY_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
860
861    /// Lay down a venv dir plus a metadata file, and return both paths.
862    fn scaffold(dir: &TempDir, metadata: &str) -> (PathBuf, PathBuf) {
863        let venv = dir.path().join(".venv");
864        std::fs::create_dir_all(&venv).unwrap();
865        let metadata_path = dir.path().join("metadata.json");
866        std::fs::write(&metadata_path, metadata).unwrap();
867        (venv, metadata_path)
868    }
869
870    fn metadata_json(
871        requirements_hash: &str,
872        version: Option<&str>,
873        manifest_hash: Option<&str>,
874    ) -> String {
875        let mut obj = serde_json::json!({
876            "venv_path": "/tmp/.venv",
877            "requirements_file": null,
878            "requirements_hash": requirements_hash,
879            "python_version": "3.12.1",
880        });
881        if let Some(v) = version {
882            obj["manifest_version"] = serde_json::json!(v);
883        }
884        if let Some(h) = manifest_hash {
885            obj["manifest_hash"] = serde_json::json!(h);
886        }
887        serde_json::to_string(&obj).unwrap()
888    }
889
890    // --- hashing ------------------------------------------------------------
891
892    #[test]
893    fn absent_requirements_file_hashes_to_the_empty_digest() {
894        // client.py hashes b"" for both "no file configured" and "configured
895        // but not on disk", so both reuse a venv rather than erroring.
896        assert_eq!(hash_file_or_empty(None), EMPTY_SHA256);
897        assert_eq!(
898            hash_file_or_empty(Some(Path::new("/nonexistent/requirements.txt"))),
899            EMPTY_SHA256
900        );
901    }
902
903    #[test]
904    fn requirements_hash_tracks_content_not_path() {
905        let dir = TempDir::new();
906        let a = dir.path().join("a.txt");
907        let b = dir.path().join("b.txt");
908        std::fs::write(&a, "requests==2.31.0\n").unwrap();
909        std::fs::write(&b, "requests==2.31.0\n").unwrap();
910        assert_eq!(hash_file_or_empty(Some(&a)), hash_file_or_empty(Some(&b)));
911
912        std::fs::write(&b, "requests==2.32.0\n").unwrap();
913        assert_ne!(hash_file_or_empty(Some(&a)), hash_file_or_empty(Some(&b)));
914    }
915
916    // --- manifest filename (contract with the Python side) ------------------
917
918    #[test]
919    fn manifest_filename_matches_the_python_sanitization_rule() {
920        // Byte-for-byte agreement with utils.manifest_filename_for_class:
921        // dots become dashes, alphanumerics and -_ survive.
922        assert_eq!(
923            manifest_filename_for_class("pkg.module.ClassName"),
924            "pkg-module-ClassName.plugin-manifest.yaml"
925        );
926        assert_eq!(
927            manifest_filename_for_class("my_pkg.filters.PiiFilter"),
928            "my_pkg-filters-PiiFilter.plugin-manifest.yaml"
929        );
930        // Leading/trailing separators are stripped, and an all-separator name
931        // degrades to "plugin" rather than producing a dotfile.
932        assert_eq!(
933            manifest_filename_for_class("  .A.  "),
934            "A.plugin-manifest.yaml"
935        );
936        assert_eq!(
937            manifest_filename_for_class("..."),
938            "plugin.plugin-manifest.yaml"
939        );
940    }
941
942    #[test]
943    fn plugins_sharing_a_package_get_distinct_manifest_filenames() {
944        // The anti-thrash guarantee: one shared venv dir, one manifest each.
945        assert_ne!(
946            manifest_filename_for_class("pkg.a.PluginA"),
947            manifest_filename_for_class("pkg.b.PluginB")
948        );
949    }
950
951    // --- cache validity -----------------------------------------------------
952
953    #[test]
954    fn valid_when_every_present_signal_agrees() {
955        let dir = TempDir::new();
956        let (venv, meta) = scaffold(
957            &dir,
958            &metadata_json(EMPTY_SHA256, Some("1.0.0"), Some("abc123")),
959        );
960
961        assert_eq!(
962            evaluate_cache(&venv, &meta, EMPTY_SHA256, Some("1.0.0"), Some("abc123")),
963            CacheVerdict::Valid
964        );
965    }
966
967    #[test]
968    fn invalid_when_the_venv_directory_is_gone() {
969        let dir = TempDir::new();
970        let meta = dir.path().join("metadata.json");
971        std::fs::write(&meta, metadata_json(EMPTY_SHA256, None, None)).unwrap();
972
973        assert_eq!(
974            evaluate_cache(&dir.path().join(".venv"), &meta, EMPTY_SHA256, None, None),
975            CacheVerdict::VenvMissing
976        );
977    }
978
979    #[test]
980    fn invalid_when_metadata_is_absent() {
981        let dir = TempDir::new();
982        let venv = dir.path().join(".venv");
983        std::fs::create_dir_all(&venv).unwrap();
984
985        assert_eq!(
986            evaluate_cache(
987                &venv,
988                &dir.path().join("missing.json"),
989                EMPTY_SHA256,
990                None,
991                None
992            ),
993            CacheVerdict::MetadataMissing
994        );
995    }
996
997    #[test]
998    fn invalid_when_metadata_will_not_parse() {
999        // client.py catches JSONDecodeError and returns False — a corrupt
1000        // metadata file rebuilds rather than propagating.
1001        let dir = TempDir::new();
1002        let (venv, meta) = scaffold(&dir, "{ this is not json");
1003
1004        assert_eq!(
1005            evaluate_cache(&venv, &meta, EMPTY_SHA256, None, None),
1006            CacheVerdict::MetadataUnreadable
1007        );
1008    }
1009
1010    #[test]
1011    fn changed_requirements_hash_invalidates() {
1012        let dir = TempDir::new();
1013        let (venv, meta) = scaffold(
1014            &dir,
1015            &metadata_json("old-hash", Some("1.0.0"), Some("abc123")),
1016        );
1017
1018        assert_eq!(
1019            evaluate_cache(&venv, &meta, "new-hash", Some("1.0.0"), Some("abc123")),
1020            CacheVerdict::RequirementsChanged
1021        );
1022    }
1023
1024    #[test]
1025    fn changed_manifest_version_invalidates_when_the_signal_is_present() {
1026        let dir = TempDir::new();
1027        let (venv, meta) = scaffold(
1028            &dir,
1029            &metadata_json(EMPTY_SHA256, Some("1.0.0"), Some("abc123")),
1030        );
1031
1032        assert_eq!(
1033            evaluate_cache(&venv, &meta, EMPTY_SHA256, Some("2.0.0"), Some("abc123")),
1034            CacheVerdict::ManifestVersionChanged
1035        );
1036    }
1037
1038    #[test]
1039    fn changed_manifest_hash_invalidates_when_the_signal_is_present() {
1040        let dir = TempDir::new();
1041        let (venv, meta) = scaffold(
1042            &dir,
1043            &metadata_json(EMPTY_SHA256, Some("1.0.0"), Some("abc123")),
1044        );
1045
1046        assert_eq!(
1047            evaluate_cache(&venv, &meta, EMPTY_SHA256, Some("1.0.0"), Some("def456")),
1048            CacheVerdict::ManifestHashChanged
1049        );
1050    }
1051
1052    #[test]
1053    fn absent_manifest_signals_do_not_invalidate() {
1054        // THE upgrade-safety rule. Metadata written by an older CLI carries
1055        // neither manifest key. Reading absent-as-None and comparing against a
1056        // live value would report a mismatch and wipe every existing venv on
1057        // the first run after upgrading the host.
1058        let dir = TempDir::new();
1059        let (venv, meta) = scaffold(&dir, &metadata_json(EMPTY_SHA256, None, None));
1060
1061        assert_eq!(
1062            evaluate_cache(&venv, &meta, EMPTY_SHA256, Some("1.0.0"), Some("abc123")),
1063            CacheVerdict::Valid,
1064            "absent manifest signals mean 'no signal', not 'mismatch'"
1065        );
1066    }
1067
1068    #[test]
1069    fn each_manifest_signal_is_evaluated_independently() {
1070        // Half-populated metadata is real: only the key that is present gets
1071        // compared, so a version-only record still catches a version bump and
1072        // still ignores the hash.
1073        let dir = TempDir::new();
1074        let (venv, meta) = scaffold(&dir, &metadata_json(EMPTY_SHA256, Some("1.0.0"), None));
1075
1076        assert_eq!(
1077            evaluate_cache(&venv, &meta, EMPTY_SHA256, Some("1.0.0"), Some("any-hash")),
1078            CacheVerdict::Valid,
1079            "an absent manifest_hash is skipped even when manifest_version is present"
1080        );
1081        assert_eq!(
1082            evaluate_cache(&venv, &meta, EMPTY_SHA256, Some("9.9.9"), Some("any-hash")),
1083            CacheVerdict::ManifestVersionChanged,
1084            "the present signal is still enforced"
1085        );
1086    }
1087
1088    #[test]
1089    fn a_present_signal_against_an_unknown_expected_value_invalidates() {
1090        // The converse of the upgrade rule: metadata that recorded a version
1091        // while the plugin now declares none is a real change, not "no signal".
1092        let dir = TempDir::new();
1093        let (venv, meta) = scaffold(&dir, &metadata_json(EMPTY_SHA256, Some("1.0.0"), None));
1094
1095        assert_eq!(
1096            evaluate_cache(&venv, &meta, EMPTY_SHA256, None, None),
1097            CacheVerdict::ManifestVersionChanged
1098        );
1099    }
1100
1101    #[test]
1102    fn requirements_are_checked_before_manifest_signals() {
1103        // Ordering matters for the reported reason: client.py compares the
1104        // requirements hash first, so a config that changed both should
1105        // attribute the rebuild to requirements.
1106        let dir = TempDir::new();
1107        let (venv, meta) = scaffold(&dir, &metadata_json("old", Some("1.0.0"), Some("abc")));
1108
1109        assert_eq!(
1110            evaluate_cache(&venv, &meta, "new", Some("2.0.0"), Some("def")),
1111            CacheVerdict::RequirementsChanged
1112        );
1113    }
1114
1115    #[test]
1116    fn metadata_round_trips_without_inventing_manifest_keys() {
1117        // Serializing must not write `manifest_version: null` — the Python
1118        // side reads key *presence* as the signal, so a null key would turn
1119        // "no signal" into a permanent mismatch.
1120        let metadata = CacheMetadata {
1121            venv_path: "/tmp/x/.venv".into(),
1122            requirements_file: None,
1123            requirements_hash: EMPTY_SHA256.into(),
1124            manifest_version: None,
1125            manifest_hash: None,
1126            python_version: Some("3.12.1".into()),
1127        };
1128        let json = serde_json::to_string(&metadata).unwrap();
1129        assert!(
1130            !json.contains("manifest_version"),
1131            "absent signals must stay absent: {json}"
1132        );
1133        assert!(!json.contains("manifest_hash"));
1134
1135        let parsed: CacheMetadata = serde_json::from_str(&json).unwrap();
1136        assert!(parsed.manifest_version.is_none());
1137    }
1138
1139    // --- layout -------------------------------------------------------------
1140
1141    #[test]
1142    fn layout_is_keyed_on_the_class_root_so_a_package_shares_one_venv() {
1143        let dirs = vec!["/plugins".to_string()];
1144        let a = VenvLayout::resolve(&dirs, "pkg.a.PluginA").unwrap();
1145        let b = VenvLayout::resolve(&dirs, "pkg.b.PluginB").unwrap();
1146
1147        assert_eq!(a.venv_path, PathBuf::from("/plugins/pkg/.venv"));
1148        assert_eq!(a.venv_path, b.venv_path, "one venv per package root");
1149        assert_eq!(a.cache_dir, PathBuf::from("/plugins/pkg/.cpex/venv_cache"));
1150
1151        // ...but distinct manifests, so neither install invalidates the other.
1152        assert_ne!(a.manifest_path, b.manifest_path);
1153    }
1154
1155    #[test]
1156    fn layout_uses_the_first_plugin_dir() {
1157        let dirs = vec!["/first".to_string(), "/second".to_string()];
1158        let layout = VenvLayout::resolve(&dirs, "pkg.Plugin").unwrap();
1159        assert_eq!(layout.plugin_path, PathBuf::from("/first/pkg"));
1160    }
1161
1162    #[test]
1163    fn layout_requires_at_least_one_plugin_dir() {
1164        let err = VenvLayout::resolve(&[], "pkg.Plugin").unwrap_err();
1165        assert!(matches!(err, HostError::Config { .. }));
1166        assert!(
1167            err.to_string().contains("plugin directory"),
1168            "the message should explain what is missing: {err}"
1169        );
1170    }
1171
1172    #[test]
1173    fn metadata_filename_is_keyed_on_the_full_class_name() {
1174        // Deliberately unlike client.py, which keys this on the venv directory
1175        // name and so hands every plugin in a shared package the same file.
1176        // See the comment in `VenvLayout::resolve`.
1177        let layout = VenvLayout::resolve(&["/plugins".to_string()], "pkg.Plugin").unwrap();
1178        assert_eq!(
1179            layout.metadata_path,
1180            PathBuf::from("/plugins/pkg/.cpex/venv_cache/pkg-Plugin_metadata.json")
1181        );
1182    }
1183
1184    #[test]
1185    fn plugins_sharing_a_package_get_distinct_metadata_files() {
1186        // The other half of the anti-thrash guarantee. A shared metadata file
1187        // would let each plugin's build overwrite the other's requirements and
1188        // manifest hashes, invalidating it on the next run — forever.
1189        let dirs = vec!["/plugins".to_string()];
1190        let a = VenvLayout::resolve(&dirs, "pkg.a.PluginA").unwrap();
1191        let b = VenvLayout::resolve(&dirs, "pkg.b.PluginB").unwrap();
1192        assert_eq!(a.venv_path, b.venv_path, "still one shared venv");
1193        assert_ne!(a.metadata_path, b.metadata_path);
1194    }
1195
1196    #[test]
1197    fn python_executable_sits_inside_the_venv() {
1198        let layout = VenvLayout::resolve(&["/plugins".to_string()], "pkg.Plugin").unwrap();
1199        let exe = layout.python_executable();
1200        assert!(exe.starts_with(&layout.venv_path));
1201        assert!(exe.ends_with(if cfg!(windows) {
1202            "python.exe"
1203        } else {
1204            "python"
1205        }));
1206    }
1207
1208    // --- manager ------------------------------------------------------------
1209
1210    fn manager_in(dir: &TempDir, class_name: &str, requirements: Option<&str>) -> VenvManager {
1211        VenvManager::new(
1212            &[dir.path().display().to_string()],
1213            class_name,
1214            requirements,
1215            Some("1.0.0"),
1216        )
1217        .expect("layout resolves")
1218    }
1219
1220    #[test]
1221    fn relative_requirements_paths_resolve_under_the_plugin_path() {
1222        let dir = TempDir::new();
1223        let mgr = manager_in(&dir, "pkg.Plugin", Some("requirements.txt"));
1224        assert_eq!(
1225            mgr.requirements_file.as_deref(),
1226            Some(dir.path().join("pkg").join("requirements.txt").as_path())
1227        );
1228    }
1229
1230    #[test]
1231    fn absolute_requirements_paths_are_left_alone() {
1232        let dir = TempDir::new();
1233        let mgr = manager_in(&dir, "pkg.Plugin", Some("/etc/shared/requirements.txt"));
1234        assert_eq!(
1235            mgr.requirements_file.as_deref(),
1236            Some(Path::new("/etc/shared/requirements.txt"))
1237        );
1238    }
1239
1240    #[test]
1241    fn a_missing_venv_reports_an_invalid_cache() {
1242        let dir = TempDir::new();
1243        let mgr = manager_in(&dir, "pkg.Plugin", None);
1244        assert_eq!(mgr.cache_verdict(), CacheVerdict::VenvMissing);
1245    }
1246
1247    #[tokio::test]
1248    async fn ensure_builds_then_reuses_the_venv() {
1249        // The cached-venv-reuse acceptance example: a second initialize with
1250        // unchanged inputs must not rebuild or reinstall.
1251        if crate::testing::skip_without_python3("ensure_builds_then_reuses_the_venv") {
1252            return;
1253        }
1254        let dir = TempDir::new();
1255        let mgr = manager_in(&dir, "pkg.Plugin", None);
1256
1257        let first = mgr.ensure().await.expect("venv builds");
1258        assert_eq!(
1259            first,
1260            EnsureOutcome::Built {
1261                installed_requirements: false
1262            },
1263            "no requirements file means a fresh venv with no install"
1264        );
1265        assert!(
1266            mgr.python_executable().exists(),
1267            "the interpreter must exist after a build"
1268        );
1269
1270        let second = mgr.ensure().await.expect("cached venv is reusable");
1271        assert_eq!(
1272            second,
1273            EnsureOutcome::Reused,
1274            "unchanged inputs must not rebuild"
1275        );
1276    }
1277
1278    #[tokio::test]
1279    async fn a_changed_requirements_hash_forces_a_rebuild() {
1280        if crate::testing::skip_without_python3("a_changed_requirements_hash_forces_a_rebuild") {
1281            return;
1282        }
1283        let dir = TempDir::new();
1284        std::fs::create_dir_all(dir.path().join("pkg")).unwrap();
1285        let requirements = dir.path().join("pkg").join("requirements.txt");
1286        // Empty requirements: pip succeeds without network access, which keeps
1287        // this test hermetic while still exercising the install branch.
1288        std::fs::write(&requirements, "").unwrap();
1289
1290        let mgr = manager_in(&dir, "pkg.Plugin", Some("requirements.txt"));
1291        assert!(matches!(
1292            mgr.ensure().await.unwrap(),
1293            EnsureOutcome::Built { .. }
1294        ));
1295        assert_eq!(mgr.ensure().await.unwrap(), EnsureOutcome::Reused);
1296
1297        // Editing the file changes its hash, which must invalidate.
1298        std::fs::write(&requirements, "# a comment changes the hash\n").unwrap();
1299        assert_eq!(mgr.cache_verdict(), CacheVerdict::RequirementsChanged);
1300        assert!(matches!(
1301            mgr.ensure().await.unwrap(),
1302            EnsureOutcome::Built { .. }
1303        ));
1304    }
1305
1306    #[tokio::test]
1307    async fn an_absent_requirements_file_builds_and_skips_install() {
1308        if crate::testing::skip_without_python3(
1309            "an_absent_requirements_file_builds_and_skips_install",
1310        ) {
1311            return;
1312        }
1313        let dir = TempDir::new();
1314        // Configured but not present on disk — must not error.
1315        let mgr = manager_in(&dir, "pkg.Plugin", Some("does-not-exist.txt"));
1316
1317        assert_eq!(
1318            mgr.ensure()
1319                .await
1320                .expect("a missing requirements file is not fatal"),
1321            EnsureOutcome::Built {
1322                installed_requirements: false
1323            }
1324        );
1325    }
1326
1327    #[tokio::test]
1328    async fn two_plugins_sharing_a_package_do_not_thrash_each_others_cache() {
1329        // Both resolve to one venv dir but distinct manifest + metadata files,
1330        // so building either leaves the other's cache valid.
1331        if crate::testing::skip_without_python3(
1332            "two_plugins_sharing_a_package_do_not_thrash_each_others_cache",
1333        ) {
1334            return;
1335        }
1336        let dir = TempDir::new();
1337        let a = manager_in(&dir, "pkg.a.PluginA", None);
1338        let b = manager_in(&dir, "pkg.b.PluginB", None);
1339
1340        assert_eq!(
1341            a.layout().venv_path,
1342            b.layout().venv_path,
1343            "one shared venv"
1344        );
1345        assert_ne!(a.layout().metadata_path, b.layout().metadata_path);
1346
1347        assert!(matches!(
1348            a.ensure().await.unwrap(),
1349            EnsureOutcome::Built { .. }
1350        ));
1351        assert_eq!(a.ensure().await.unwrap(), EnsureOutcome::Reused);
1352
1353        // B has no metadata yet, so it does its own first-time setup. A already
1354        // owns the shared venv, so that setup updates it in place rather than
1355        // recreating it — see `a_second_plugin_rebuilding_does_not_delete_the_shared_venv`.
1356        assert!(matches!(
1357            b.ensure().await.unwrap(),
1358            EnsureOutcome::SharedUpdate { .. }
1359        ));
1360        // ...and crucially A is still a cache hit afterwards. A shared manifest
1361        // filename here would have invalidated A and started a rebuild loop.
1362        assert_eq!(
1363            a.ensure().await.unwrap(),
1364            EnsureOutcome::Reused,
1365            "B's build must not invalidate A"
1366        );
1367        assert_eq!(b.ensure().await.unwrap(), EnsureOutcome::Reused);
1368    }
1369
1370    // --- shared-venv ownership ----------------------------------------------
1371
1372    #[test]
1373    fn the_owners_file_is_shared_by_every_plugin_in_the_package() {
1374        // The registry is the shared venv's membership list, so unlike
1375        // `metadata_path` it must resolve to one path for all co-owners.
1376        // Keying it per class would give each plugin its own empty registry,
1377        // and every one of them would believe it was the sole owner.
1378        let dirs = vec!["/plugins".to_string()];
1379        let a = VenvLayout::resolve(&dirs, "pkg.a.PluginA").unwrap();
1380        let b = VenvLayout::resolve(&dirs, "pkg.b.PluginB").unwrap();
1381
1382        assert_eq!(a.venv_path, b.venv_path, "one shared venv");
1383        assert_eq!(
1384            a.owners_path, b.owners_path,
1385            "co-owners of one venv must read the same registry"
1386        );
1387        assert_eq!(
1388            a.owners_path,
1389            PathBuf::from("/plugins/pkg/.cpex/venv_cache/owners.json")
1390        );
1391    }
1392
1393    #[test]
1394    fn a_missing_or_corrupt_owner_registry_reads_as_empty() {
1395        // Failing open matters: the registry only ever *prevents* a delete, so
1396        // an unreadable file must degrade to the previous rebuild behavior
1397        // rather than wedge startup.
1398        let dir = TempDir::new();
1399        assert!(
1400            VenvOwners::load(&dir.path().join("nope.json"))
1401                .owners
1402                .is_empty()
1403        );
1404
1405        let corrupt = dir.path().join("owners.json");
1406        std::fs::write(&corrupt, "{ not json").unwrap();
1407        assert!(VenvOwners::load(&corrupt).owners.is_empty());
1408    }
1409
1410    #[test]
1411    fn has_other_owner_ignores_the_caller_itself() {
1412        let mut owners = VenvOwners::default();
1413        owners.owners.insert("pkg.a.PluginA".to_string());
1414
1415        assert!(
1416            !owners.has_other_owner("pkg.a.PluginA"),
1417            "a plugin re-initializing is still the sole owner and may rebuild"
1418        );
1419        assert!(
1420            owners.has_other_owner("pkg.b.PluginB"),
1421            "a different plugin in the same package is a co-owner"
1422        );
1423    }
1424
1425    #[tokio::test]
1426    async fn a_cache_hit_still_claims_ownership_of_the_venv() {
1427        // Otherwise the reuse path would run a worker out of a venv it never
1428        // registered for, and a co-owner initializing later would see an empty
1429        // registry and delete it.
1430        if crate::testing::skip_without_python3("a_cache_hit_still_claims_ownership_of_the_venv") {
1431            return;
1432        }
1433        let dir = TempDir::new();
1434        let mgr = manager_in(&dir, "pkg.Plugin", None);
1435
1436        mgr.ensure().await.unwrap();
1437        assert!(mgr.owners().owners.contains("pkg.Plugin"));
1438
1439        // Drop the claim, then take the pure cache-hit path.
1440        mgr.release_owner().await.unwrap();
1441        assert!(mgr.owners().owners.is_empty());
1442
1443        assert_eq!(mgr.ensure().await.unwrap(), EnsureOutcome::Reused);
1444        assert!(
1445            mgr.owners().owners.contains("pkg.Plugin"),
1446            "the reuse path must register ownership too"
1447        );
1448    }
1449
1450    #[tokio::test]
1451    async fn a_second_plugin_rebuilding_does_not_delete_the_shared_venv() {
1452        // The reviewer's bug, directly. A and B share `pkg/.venv` by design.
1453        // Before the owner registry, B's rebuild called `remove_dir_all` on the
1454        // shared venv — deleting the interpreter and site-packages that A's
1455        // already-running worker was executing out of.
1456        if crate::testing::skip_without_python3(
1457            "a_second_plugin_rebuilding_does_not_delete_the_shared_venv",
1458        ) {
1459            return;
1460        }
1461        let dir = TempDir::new();
1462        let a = manager_in(&dir, "pkg.a.PluginA", None);
1463        let b = manager_in(&dir, "pkg.b.PluginB", None);
1464        assert_eq!(a.layout().venv_path, b.layout().venv_path, "one venv");
1465
1466        // A builds and claims the venv.
1467        assert!(matches!(
1468            a.ensure().await.unwrap(),
1469            EnsureOutcome::Built { .. }
1470        ));
1471        assert!(a.python_executable().exists());
1472
1473        // A marker inside the venv stands in for "the files A's worker is
1474        // running out of". Its survival is the actual assertion: a path check
1475        // alone would pass even against a freshly recreated venv.
1476        let marker = a.layout().venv_path.join("a-was-here.marker");
1477        std::fs::write(&marker, "A's interpreter tree").unwrap();
1478
1479        // B's cache signals are stale (it has no metadata), so pre-fix it took
1480        // the destructive rebuild path.
1481        assert!(!b.cache_verdict().is_valid());
1482        assert_eq!(
1483            b.ensure().await.unwrap(),
1484            EnsureOutcome::SharedUpdate {
1485                installed_requirements: false
1486            },
1487            "a co-owned venv must be updated in place, not recreated"
1488        );
1489
1490        assert!(
1491            marker.exists(),
1492            "B's build deleted the shared venv out from under A"
1493        );
1494        assert!(
1495            a.python_executable().exists(),
1496            "A's interpreter must survive B's build"
1497        );
1498
1499        // Both plugins end up with a working venv and a recorded claim.
1500        let owners = a.owners().owners;
1501        assert!(owners.contains("pkg.a.PluginA"), "owners: {owners:?}");
1502        assert!(owners.contains("pkg.b.PluginB"), "owners: {owners:?}");
1503
1504        // ...and A is still a cache hit, so this did not reintroduce thrash.
1505        assert_eq!(a.ensure().await.unwrap(), EnsureOutcome::Reused);
1506        assert_eq!(b.ensure().await.unwrap(), EnsureOutcome::Reused);
1507    }
1508
1509    #[tokio::test]
1510    async fn the_sole_owner_still_gets_a_destructive_rebuild() {
1511        // The counterpart guarantee. Sharing must not cost correctness for the
1512        // ordinary single-plugin case: an invalid cache there still wipes the
1513        // venv, so a removed dependency actually disappears.
1514        if crate::testing::skip_without_python3("the_sole_owner_still_gets_a_destructive_rebuild") {
1515            return;
1516        }
1517        let dir = TempDir::new();
1518        let mgr = manager_in(&dir, "pkg.Plugin", None);
1519        mgr.ensure().await.unwrap();
1520
1521        let stale = mgr.layout().venv_path.join("stale-dependency.marker");
1522        std::fs::write(&stale, "removed from requirements").unwrap();
1523
1524        // Invalidate: a manifest appears where there was none.
1525        std::fs::write(&mgr.layout().manifest_path, "version: 1.0.1\n").unwrap();
1526        assert_eq!(mgr.cache_verdict(), CacheVerdict::ManifestHashChanged);
1527
1528        assert!(
1529            matches!(mgr.ensure().await.unwrap(), EnsureOutcome::Built { .. }),
1530            "the sole owner rebuilds rather than updating in place"
1531        );
1532        assert!(
1533            !stale.exists(),
1534            "a sole-owner rebuild must clear the old venv contents"
1535        );
1536    }
1537
1538    #[tokio::test]
1539    async fn releasing_the_last_owner_restores_destructive_rebuilds() {
1540        // A stale claim left behind by a departed plugin would downgrade every
1541        // later rebuild to an in-place update forever.
1542        if crate::testing::skip_without_python3(
1543            "releasing_the_last_owner_restores_destructive_rebuilds",
1544        ) {
1545            return;
1546        }
1547        let dir = TempDir::new();
1548        let a = manager_in(&dir, "pkg.a.PluginA", None);
1549        let b = manager_in(&dir, "pkg.b.PluginB", None);
1550
1551        a.ensure().await.unwrap();
1552        b.ensure().await.unwrap();
1553        assert_eq!(a.owners().owners.len(), 2);
1554
1555        // A shuts down and hands the venv over.
1556        a.release_owner().await.unwrap();
1557        assert!(!a.owners().has_other_owner("pkg.b.PluginB"));
1558
1559        // B is now sole owner, so its next invalid-cache build is destructive.
1560        let stale = b.layout().venv_path.join("stale.marker");
1561        std::fs::write(&stale, "x").unwrap();
1562        std::fs::write(&b.layout().manifest_path, "version: 2.0.0\n").unwrap();
1563
1564        assert!(matches!(
1565            b.ensure().await.unwrap(),
1566            EnsureOutcome::Built { .. }
1567        ));
1568        assert!(!stale.exists(), "the sole remaining owner rebuilds fully");
1569
1570        // The last owner leaving removes the file entirely.
1571        b.release_owner().await.unwrap();
1572        assert!(!b.layout().owners_path.exists());
1573    }
1574
1575    // --- pip timeout --------------------------------------------------------
1576
1577    #[test]
1578    fn the_default_pip_timeout_is_applied_and_overridable() {
1579        let dir = TempDir::new();
1580        let mgr = manager_in(&dir, "pkg.Plugin", None);
1581        assert_eq!(mgr.pip_timeout_secs(), DEFAULT_PIP_TIMEOUT_SECS);
1582        assert_eq!(mgr.with_pip_timeout_secs(3).pip_timeout_secs(), 3);
1583    }
1584
1585    #[tokio::test]
1586    async fn a_hanging_pip_install_times_out_and_kills_the_child() {
1587        // Init is sequential, so an unbounded pip stalls every other plugin's
1588        // startup. Pre-fix this test hangs for the length of the sleep (and in
1589        // production, forever) instead of returning.
1590        //
1591        // The hang is produced by putting a fake `pip` on the venv's path that
1592        // sleeps: the manager runs `<venv>/bin/python -m pip install -r ...`, so
1593        // shadowing the module inside the venv is enough — no network needed.
1594        if crate::testing::skip_without_python3(
1595            "a_hanging_pip_install_times_out_and_kills_the_child",
1596        ) {
1597            return;
1598        }
1599        let dir = TempDir::new();
1600        std::fs::create_dir_all(dir.path().join("pkg")).unwrap();
1601        std::fs::write(dir.path().join("pkg").join("requirements.txt"), "").unwrap();
1602
1603        let mgr = manager_in(&dir, "pkg.Plugin", Some("requirements.txt")).with_pip_timeout_secs(1);
1604
1605        // A real venv, so the install runs against the venv's own interpreter
1606        // exactly as production does.
1607        mgr.prepare_directories(false).await.expect("dirs prepared");
1608        mgr.create_venv().await.expect("the venv builds");
1609
1610        // `python -m pip` resolves `pip/__main__.py` from sys.path, and the
1611        // venv's own site-packages precedes the stdlib for it — so shadowing the
1612        // module inside the venv produces the hang with no network involved.
1613        let site = {
1614            let lib = mgr.layout().venv_path.join("lib");
1615            std::fs::read_dir(&lib)
1616                .expect("venv lib dir")
1617                .flatten()
1618                .map(|e| e.path().join("site-packages"))
1619                .find(|p| p.exists())
1620                .expect("site-packages exists in a built venv")
1621        };
1622        let pip_pkg = site.join("pip");
1623        std::fs::create_dir_all(&pip_pkg).unwrap();
1624        std::fs::write(pip_pkg.join("__init__.py"), "").unwrap();
1625        // Records its pid, then sleeps far past the 1s timeout. Recording the pid
1626        // is what lets the test prove the child was actually killed rather than
1627        // left running; without the fix this call blocks for the full 300s.
1628        let pidfile = dir.path().join("pip.pid");
1629        std::fs::write(
1630            pip_pkg.join("__main__.py"),
1631            format!(
1632                "import os, time\nopen({:?}, 'w').write(str(os.getpid()))\ntime.sleep(300)\n",
1633                pidfile.display().to_string()
1634            ),
1635        )
1636        .unwrap();
1637
1638        let started = std::time::Instant::now();
1639        let err = mgr
1640            .install_requirements()
1641            .await
1642            .expect_err("a pip that never returns must not stall startup forever");
1643        let elapsed = started.elapsed();
1644
1645        assert!(
1646            matches!(err, HostError::VenvBuild { .. }),
1647            "a pip timeout is a venv build failure: {err:?}"
1648        );
1649        let message = err.to_string();
1650        assert!(
1651            message.contains("timeout"),
1652            "the error must name the timeout so an operator can raise it: {message}"
1653        );
1654
1655        // The bound is the point: a 1s timeout against a 300s sleep.
1656        assert!(
1657            elapsed < std::time::Duration::from_secs(30),
1658            "the install must return on the timeout, not on pip's own completion: {elapsed:?}"
1659        );
1660
1661        // ...and the child must actually be gone. A pip still mutating
1662        // site-packages would race the next plugin's install into the same
1663        // shared directory.
1664        let pid: i32 = std::fs::read_to_string(&pidfile)
1665            .expect("the fake pip recorded its pid")
1666            .trim()
1667            .parse()
1668            .expect("pid is numeric");
1669        // `kill -0` succeeds only while the process exists. Poll briefly: the
1670        // reap is synchronous in `install_requirements`, but the OS may take a
1671        // moment to clear the entry.
1672        let mut alive = true;
1673        for _ in 0..40 {
1674            let status = std::process::Command::new("kill")
1675                .args(["-0", &pid.to_string()])
1676                .stdout(std::process::Stdio::null())
1677                .stderr(std::process::Stdio::null())
1678                .status()
1679                .expect("kill -0 runs");
1680            if !status.success() {
1681                alive = false;
1682                break;
1683            }
1684            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1685        }
1686        assert!(
1687            !alive,
1688            "the timed-out pip process (pid {pid}) was leaked rather than killed"
1689        );
1690    }
1691
1692    #[tokio::test]
1693    async fn a_failed_pip_install_carries_its_exit_code_and_stderr_as_details() {
1694        // The exit code and stderr tail must survive as structured `details` an
1695        // operator's log pipeline can match on. Pre-fix this path returned
1696        // `VenvBuild { message }`, whose `details()` is empty by definition, so
1697        // the only way to recover the exit code was to parse English.
1698        if crate::testing::skip_without_python3(
1699            "a_failed_pip_install_carries_its_exit_code_and_stderr_as_details",
1700        ) {
1701            return;
1702        }
1703        let dir = TempDir::new();
1704        std::fs::create_dir_all(dir.path().join("pkg")).unwrap();
1705        std::fs::write(dir.path().join("pkg").join("requirements.txt"), "").unwrap();
1706
1707        let mgr = manager_in(&dir, "pkg.Plugin", Some("requirements.txt"));
1708        mgr.prepare_directories(false).await.expect("dirs prepared");
1709        mgr.create_venv().await.expect("the venv builds");
1710
1711        // Same pip-shadowing trick as the timeout test, but exiting non-zero
1712        // with a recognizable message on stderr instead of sleeping.
1713        let site = {
1714            let lib = mgr.layout().venv_path.join("lib");
1715            std::fs::read_dir(&lib)
1716                .expect("venv lib dir")
1717                .flatten()
1718                .map(|e| e.path().join("site-packages"))
1719                .find(|p| p.exists())
1720                .expect("site-packages exists in a built venv")
1721        };
1722        let pip_pkg = site.join("pip");
1723        std::fs::create_dir_all(&pip_pkg).unwrap();
1724        std::fs::write(pip_pkg.join("__init__.py"), "").unwrap();
1725        std::fs::write(
1726            pip_pkg.join("__main__.py"),
1727            "import sys\nsys.stderr.write('ERROR: No matching distribution found for nope\\n')\n\
1728             sys.exit(7)\n",
1729        )
1730        .unwrap();
1731
1732        let err = mgr
1733            .install_requirements()
1734            .await
1735            .expect_err("a non-zero pip exit is a build failure");
1736
1737        let details = err.details();
1738        assert_eq!(
1739            details.get("exit_code").and_then(|v| v.as_i64()),
1740            Some(7),
1741            "pip's exit code must reach details, not just the prose: {details:?}"
1742        );
1743        assert_eq!(
1744            details.get("step").and_then(|v| v.as_str()),
1745            Some("pip install"),
1746            "the failing step must be named so a caller can branch on it: {details:?}"
1747        );
1748        let stderr = details
1749            .get("stderr_excerpt")
1750            .and_then(|v| v.as_str())
1751            .unwrap_or_default();
1752        assert!(
1753            stderr.contains("No matching distribution"),
1754            "pip's own diagnosis must survive into details: {stderr:?}"
1755        );
1756        assert_eq!(
1757            err.code(),
1758            "venv_build_failed",
1759            "the stable code stays the same as the message-only variant's"
1760        );
1761    }
1762
1763    #[tokio::test]
1764    async fn a_manifest_edit_invalidates_the_cache() {
1765        if crate::testing::skip_without_python3("a_manifest_edit_invalidates_the_cache") {
1766            return;
1767        }
1768        let dir = TempDir::new();
1769        let mgr = manager_in(&dir, "pkg.Plugin", None);
1770        assert!(matches!(
1771            mgr.ensure().await.unwrap(),
1772            EnsureOutcome::Built { .. }
1773        ));
1774        assert_eq!(mgr.ensure().await.unwrap(), EnsureOutcome::Reused);
1775
1776        // Writing a manifest where there was none changes the manifest hash —
1777        // the sole change signal for a plugin with no requirements file.
1778        std::fs::write(&mgr.layout().manifest_path, "version: 1.0.1\n").unwrap();
1779        assert_eq!(mgr.cache_verdict(), CacheVerdict::ManifestHashChanged);
1780    }
1781
1782    #[tokio::test]
1783    async fn a_declared_version_bump_invalidates_the_cache() {
1784        if crate::testing::skip_without_python3("a_declared_version_bump_invalidates_the_cache") {
1785            return;
1786        }
1787        let dir = TempDir::new();
1788        let v1 = manager_in(&dir, "pkg.Plugin", None);
1789        assert!(matches!(
1790            v1.ensure().await.unwrap(),
1791            EnsureOutcome::Built { .. }
1792        ));
1793
1794        // Same plugin, newer declared version: the metadata recorded 1.0.0.
1795        let v2 = VenvManager::new(
1796            &[dir.path().display().to_string()],
1797            "pkg.Plugin",
1798            None,
1799            Some("2.0.0"),
1800        )
1801        .unwrap();
1802        assert_eq!(v2.cache_verdict(), CacheVerdict::ManifestVersionChanged);
1803    }
1804
1805    #[tokio::test]
1806    async fn metadata_written_by_this_host_is_a_cache_hit_for_it() {
1807        // Round-trip guard: whatever `save_metadata` writes must satisfy
1808        // `evaluate_cache` on the next run. A field-name drift between the two
1809        // would otherwise rebuild every venv on every single run.
1810        if crate::testing::skip_without_python3(
1811            "metadata_written_by_this_host_is_a_cache_hit_for_it",
1812        ) {
1813            return;
1814        }
1815        let dir = TempDir::new();
1816        let mgr = manager_in(&dir, "pkg.Plugin", None);
1817        mgr.ensure().await.unwrap();
1818
1819        let raw = std::fs::read_to_string(&mgr.layout().metadata_path).unwrap();
1820        let parsed: CacheMetadata =
1821            serde_json::from_str(&raw).expect("our own metadata must parse");
1822        assert_eq!(parsed.manifest_version.as_deref(), Some("1.0.0"));
1823        assert!(parsed.manifest_hash.is_some());
1824        assert_eq!(mgr.cache_verdict(), CacheVerdict::Valid);
1825    }
1826}