feat(exec): mount temporary environments via rattler_vfs - #2618
Draft
Dagmar-Dinjens wants to merge 34 commits into
Draft
feat(exec): mount temporary environments via rattler_vfs#2618Dagmar-Dinjens wants to merge 34 commits into
Dagmar-Dinjens wants to merge 34 commits into
Conversation
improved permission checking, improved error message, improved slice usage in link.rs, Still have to look at the v2 backwards incompatibility comment
Finalise the offset-based prefix replacement: a structured Offsets enum (text vs binary), memchr-based NUL scanning, and the `copy_and_replace_placeholders_with_offsets` family, so install (and other consumers) can replace the install prefix at known byte offsets instead of rescanning each file. The `offsets` field on PathsEntry/PrefixPlaceholder is optional and skipped when absent, so paths.json stays backward-compatible (paths_version unchanged).
Correct the doc comments on `Offsets`, `PrefixPlaceholder::offsets` and `PrefixPlaceholder::shebang_length` to match the draft CEP "Prefix placeholder offsets in paths.json" (conda#2565): - `offsets` exclude occurrences inside the shebang region, and for binary files the group's last value is the NUL terminator offset or the file size when the final C string is unterminated at end-of-file. - `shebang_length` is present if and only if `offsets` is present, `file_mode` is text, and the file starts with `#!` — independent of whether the first line contains the placeholder. - The `Offsets` shape is determined normatively by `file_mode`, not inferred from the JSON structure.
Bring install-time prefix replacement into line with the draft CEP "Prefix placeholder offsets in paths.json" (conda#2565). `offsets` are absolute byte positions that exclude the shebang region and are spliced uniformly on every platform; the shebang region (first `shebang_length` bytes) is transformed separately. - Plumb `shebang_length` from `PrefixPlaceholder` into the offset functions and use it as the region boundary instead of re-deriving it from content, validating it equals first-0x0A-index + 1. - Add the missing non-rewriting-target rule: on Windows (e.g. a noarch package) the shebang region gets plain placeholder replacement rather than being left untouched. On Unix it is rewritten by the shebang rules (region minus its trailing newline, newline copied through verbatim). - Report producer non-conformance (in-region offset, shape/mode mismatch, out-of-range or unordered offsets, missing placeholder bytes, empty binary outer list, `#!` file without `shebang_length`) as a distinguishable `OffsetReplaceError::InconsistentMetadata`. The offset functions validate before writing anything, so `link_file` falls back to the search-based path (with a warning) using the still-empty destination instead of failing the install. - Fix a pre-existing panic in the search-based `copy_and_replace_textual_ placeholder`: a `#!` file with no newline fed an empty line into `replace_shebang`, tripping its `starts_with("#!")` assertion. - Fix the binary test that recorded the NUL terminator one byte past the actual `\x00`, and align the suite with the CEP's informative test-vector list (short/long Unix prefixes, non-rewriting target, no trailing newline, empty offsets, multiple in-shebang occurrences, over-long shebang with no occurrence, unterminated final C string, and the fallback for non-conformant input).
Add read-only `offsets` and `shebang_length` getters to the py-rattler `PrefixPlaceholder` so consumers can inspect the CEP fields (conda#2565). `offsets` returns `None`, a `list[int]` for text-mode files, or a `list[list[int]]` for binary-mode files (grouped by C string).
- "Check intra-doc links" (`-D rustdoc::private-intra-doc-links`): the public `copy_and_replace_textual_placeholder_offsets` doc linked to the private `replace_shebang` fn; demote it to a plain code span. - Windows-x86_64: `test_replace_long_prefix_in_text_file_offsets` hardcoded `shebang_length: Some(44)`, which is wrong when the `shebang_test.txt` fixture is checked out with CRLF (the carriage return shifts the first newline to 45). Derive `shebang_length` from the file contents instead.
The offsets CEP (conda/ceps#179) was restructured on Jul 15: `offsets` is no longer a bare list of positions but a list of offset groups, each recording the occurrences under one encoding: "offsets": [{"encoding": "utf-8", "ranges": [10, 45]}] Installers do not agree on which encodings of the placeholder they replace (conda searches UTF-8 plus UTF-16/UTF-32 variants; rattler and libmamba search UTF-8 only), so each installer applies exactly the groups its own search-based replacement covers and one package serves every installer. - `Offsets` becomes `OffsetRanges` inside a new `OffsetGroup` carrying an `OffsetEncoding`; unknown encoding names and unrecognized group members parse (they must not fail the whole paths.json) but mark the metadata as unusable. - `select_utf8_offset_ranges` validates the CEP's structural rules and picks the UTF-8 group; rattler skips the other defined encodings, whose occurrences its own search would not have replaced either. - The installer treats invalid metadata as inconsistent (falling back to search-based replacement) and copies files with no UTF-8 group through unchanged apart from text shebang handling; pre-CEP flat offsets deserialize as absent rather than failing the parse. - Adds the CEP's new test vector 9 (a binary file with occurrences under more than one encoding) and the Examples-section entries as tests.
…ental_ Per review discussion on conda#2565: until conda/ceps#179 is finalized the schema may still change, so mark the Rust API as unstable by renaming the PrefixPlaceholder fields to `experimental_offsets` and `experimental_shebang_length` (py-rattler properties follow suit). The serialized form is unchanged — serde renames keep `offsets` and `shebang_length` on the wire, as the CEP specifies. One self-contained commit so it can be reverted wholesale once the CEP merges and the fields become stable.
Dagmar's foundational implementation of the on-demand virtual filesystem that serves conda environments from the package cache. Later renamed to rattler_vfs and hardened (mount transports, overlay, codesign, prefix-replacement parity). (Reworded from the original "initial commit with multiple major problems still needing fixing"; authorship preserved.)
Rename Dagmar's rattler_fs foundation to rattler_vfs and harden it into the installable crate: FUSE/NFS/ProjFS transports, overlay, codesign, and the ranged prefix-replacement paths (byte-identical to rattler's install path, consuming its CEP-conformant offset APIs).
Add `rattler mount` to mount a locked environment through rattler_vfs. Gated behind the default-on `mount` feature (opt out with --no-default-features); the NFS transport is enabled for the bundled crate.
Add the rattler-vfs end-to-end harness: a nushell mount/verify script, a GitHub Actions workflow, the pixi `rattler-vfs` environment, and a test-data fixture environment for the mount tests.
Pull the shebang-region transformation out of copy_and_replace_textual_placeholder_offsets into a reusable, public `replace_shebang_region` helper: on Unix targets the region (minus its trailing newline) is rewritten by the shebang rules and may collapse to `#!/usr/bin/env <program>`; on non-rewriting targets it receives plain placeholder replacement. This makes the helper the single source of truth for how the shebang region is patched, shared by install-time replacement here and rattler_vfs mount-time ranged reads so the two stay byte-identical. Behavior is unchanged (verified by the existing link tests).
`text_ranged_read` spliced offsets uniformly, so a mounted shebang script kept its original (unreplaced) first line and reported the wrong size — diverging from an install once a CEP-conformant producer excludes the shebang region from `offsets`. Introduce `plan_text_replacement`, which mirrors the installer: it transforms the shebang region via the shared `replace_shebang_region` helper (collapsing an over-long line to `#!/usr/bin/env <program>` on Unix, or plain-replacing it on non-rewriting targets) and records the remaining occurrences as body offsets. `text_ranged_read` now emits the transformed region followed by the body-spliced tail, and `getattr` sizes are computed the same way. The install-vs-mount parity tests gain shebang coverage: line kept, collapsed for an over-long prefix, no trailing newline, multiple occurrences in the line, only-in-shebang, and a non-rewriting (Windows) target — plus ranged reads that cross the region/body boundary.
Two failures surfaced on the offsets work in CI: - "Check intra-doc links" (`-D rustdoc::private-intra-doc-links`): the public docs for `replace_shebang_region` and `copy_and_replace_textual_placeholder_offsets` linked to the private `replace_shebang` fn. Demote those to plain code spans. - Windows-x86_64: `test_replace_long_prefix_in_text_file_offsets` hardcoded `shebang_length: Some(44)`, which is wrong when the `shebang_test.txt` fixture is checked out with CRLF (the extra carriage return shifts the first newline to 45). Derive `shebang_length` from the file contents instead, so the test is robust to the checkout's line endings.
The Windows-only code paths never compile on the Linux/macOS CI, so three edition-2024 / `-D warnings` errors only surfaced on the Windows job: - projfs_adapter.rs: the `unsafe extern "system"` ProjFS callbacks perform raw pointer / FFI operations directly in the fn body, which Rust 2024's `unsafe_op_in_unsafe_fn` rejects. The whole module is inherently unsafe FFI, so allow the lint module-wide rather than wrapping ~30 sites. - lib.rs `mount_nfs`: on targets without NFS support the platform block `bail!`s, making the success tail (`tracing::info!` + `Ok(..)`) unreachable. Gate that tail behind `cfg(any(macos, linux))` so it is only compiled where it is reachable. - nfs_adapter.rs: `NfsMountHandle::mount_point` is only read by the macOS/Linux `umount` paths, so it is dead code elsewhere — allow it on non-NFS targets.
Add TextPlan::from_recorded, which builds a text replacement plan straight from the offsets recorded in paths.json instead of scanning the file, and use it during VirtualFS construction: one stat for the size arithmetic plus at most shebang_length bytes read for the shebang region. The recorded offsets are the producer's contract per the CEP and are trusted as-is; scanning remains as the fallback for pre-CEP packages and for metadata that fails the cheap shebang sanity check (logged so a non-conformant producer is self-diagnosing). Also log a warning before the macOS codesign materialization path falls back to serving raw bytes, instead of degrading silently.
Follow rattler_conda_types' restructure of `offsets` (conda/ceps#179): plan construction now resolves the recorded metadata once through `select_utf8_offset_ranges` — rattler applies exactly the groups its own search-based replacement covers (UTF-8 only): - usable metadata yields the UTF-8 group's ranges for the text/binary plan; valid metadata with no UTF-8 group plans zero splices (the wide string occurrences would not have been replaced by rattler's search either), serving the bytes verbatim; - structurally invalid or unrecognized metadata falls back to scanning the file, with a warning — the mount-side analogue of the installer's search-based fallback; - the selected ranges themselves remain trusted as-is, keeping the never-panic policy of the ranged reads. The macOS codesign path hands the dispatcher a synthesized UTF-8 group around the plan's c-string groups, and is skipped when there is nothing to replace (the bytes are served verbatim, so the original signature remains valid).
Reinstate `OverlayMismatch` (Error/Adopt), `MountConfig::with_overlay_mismatch`, and `overlay::recorded_env_hash`, which were dropped when the crate was restructured after review. pixi's mount backend depends on them: its `mount-overlay-mismatch` config defaults to adopting a mismatched overlay (keeping e.g. `pip install` results across `pixi add`) with a client-side warning, and uses the side-effect-free probe to detect the mismatch before mounting. Without the policy every mismatch hard-fails the mount. The behavior is unchanged from what was reviewed at 742a2d8, modulo the crate rename; the default remains `OverlayMismatch::Error`.
…as root
MountOption::CUSTOM("noatime") is placed in the kernel mount(2) data
string, which the FUSE driver rejects with EINVAL. This only triggers
when mounting as root: fuser then calls mount(2) directly, while the
unprivileged path delegates to fusermount3, which parses noatime itself
and converts it into MS_NOATIME.
Use the typed MountOption::NoAtime, which fuser translates into the
MS_NOATIME mount flag on both paths.
Repro: run any FUSE mount (e.g. rattler mount / pixi mount) as root on
Linux; it fails with:
failed to mount: Error calling mount() at ... with
"fd=10,rootmode=40755,user_id=0,group_id=0,noatime": EINVAL
…king The rebase onto upstream main picked up fs4 1.x, which renamed try_lock_exclusive to try_lock in line with std's file-locking API (stabilized in Rust 1.89). std's inherent File::try_lock covers the overlay lock's needs directly, so drop the fs4 dependency from rattler_vfs entirely.
Companion to the rattler_conda_types commit marking the paths.json offsets fields experimental until conda/ceps#179 is finalized. One self-contained commit so both can be reverted wholesale once the fields become stable.
Replace the extract-to-disk install in `rattler exec` with a lazy `rattler_vfs` mount served from the shared package cache: solve, build an in-memory lock file from the solved records, and mount it (read-only where supported). The solved lock file is cached at `<prefix>/.exec-lock.yml` so warm runs skip repodata + solve. The mount handle is unmounted explicitly before `process::exit`, and a stale mount from a crashed run is force-cleared on the next invocation. `rattler_vfs`/`rattler_lock` are made non-optional in rattler-bin (exec uses them on every run); the `mount` feature now only gates the `mount` subcommand. Stacked on conda#2566 (rattler_vfs crate). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`rattler exec` clears a possible stale mount (left by a crashed run) before mounting. It did so unconditionally, spawning a `umount` subprocess — and printing a spurious "not currently mounted" error — on every invocation. Guard it behind a mount-point check: compare the st_dev of the prefix against its parent, and treat a stat error as "possibly a dead mount" so stale-mount recovery still works. The common no-mount case now skips the subprocess entirely, removing a fixed per-run cost and tightening warm run-to-run timing (ripgrep warm 62.2±3.5ms -> 56.9±1.7ms over repeated hyperfine runs). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Adds a virtual-filesystem backend to
rattler exec: instead of extracting and linking a temporary environment to disk, the environment is solved once and served lazily through arattler_vfsmount (NFS on macOS/Linux). Files are read on demand from the shared package cache, so creating and running a throwaway env is dramatically cheaper — no extract/link step — while the on-disk footprint stays at a few kilobytes (just the cached lock file + mount point).perf(exec): skip force-unmount when the prefix is not mounted— the stale-mount cleanup before mounting ran an unconditionalumountsubprocess (and printed a spurious "not currently mounted" error) on every invocation. It's now guarded by a mount-point check (compare the prefix'sst_devagainst its parent; a stat error still triggers cleanup so dead-mount recovery is preserved). Removes a fixed per-run cost.Benchmarked with
hyperfineagainstpixi execacross cold / fresh-env / warm cache states (macOS, arm64):The force-unmount guard tightened warm run-to-run timing for lightweight commands (ripgrep warm 62.2 ± 3.5 ms → 56.9 ± 1.7 ms over repeated runs).
How Has This Been Tested?
cargo nextest run -p rattler-bin— all tests pass.cargo fmt --checkandcargo clippyclean on the touched crates.rattler exec -s python=3.12 -s numpy -- python -c 'import numpy'on macOS; verified the mount is created/torn down, no spuriousumountoutput, and (separately) mount options vianfsstat -m.hyperfinebenchmark comparingrattler execvspixi execacross cold/fresh-env/warm as described above.AI Disclosure
Tools: Claude Code (Claude Opus 4.8)
Checklist: