Skip to content

feat(exec): mount temporary environments via rattler_vfs - #2618

Draft
Dagmar-Dinjens wants to merge 34 commits into
conda:mainfrom
Dagmar-Dinjens:vfs-exec
Draft

feat(exec): mount temporary environments via rattler_vfs#2618
Dagmar-Dinjens wants to merge 34 commits into
conda:mainfrom
Dagmar-Dinjens:vfs-exec

Conversation

@Dagmar-Dinjens

@Dagmar-Dinjens Dagmar-Dinjens commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

🚧 **Draft — stacked PR **
This is stacked on #2566 (feat: add rattler_vfs crate),

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 a rattler_vfs mount (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 unconditional umount subprocess (and printed a spurious "not currently mounted" error) on every invocation. It's now guarded by a mount-point check (compare the prefix's st_dev against its parent; a stat error still triggers cleanup so dead-mount recovery is preserved). Removes a fixed per-run cost.

Benchmarked with hyperfine against pixi exec across cold / fresh-env / warm cache states (macOS, arm64):

  • cold and fresh-env (env created each run): VFS wins by large margins (ripgrep ~4–9×, python+numpy ~20–40×) since nothing is extracted or linked.
  • warm (env reused): VFS is slower than a native on-disk env (ripgrep ~57 ms vs ~21 ms; python+numpy ~440 ms vs ~90 ms) — this is userspace-NFS per-RPC round-trip overhead, expected for a lazy mount and the subject of future work (persistent/reused mounts).

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 --check and cargo clippy clean on the touched crates.
  • Manual end-to-end: rattler exec -s python=3.12 -s numpy -- python -c 'import numpy' on macOS; verified the mount is created/torn down, no spurious umount output, and (separately) mount options via nfsstat -m.
  • hyperfine benchmark comparing rattler exec vs pixi exec across cold/fresh-env/warm as described above.

AI Disclosure

  • This PR contains AI-generated content.
    • I have tested any AI-generated content in my PR.
    • I take responsibility for any AI-generated content in my PR.

Tools: Claude Code (Claude Opus 4.8)

Implement performance/consistency improvements to `rattler exec`'s VFS mount
path: guard the pre-mount force-unmount so it only runs when the prefix is
actually mounted (avoiding a per-run umount subprocess), then rebuild,
benchmark (hyperfine) and profile (samply) to verify the impact, and prepare
a PR that stacks cleanly on the rattler_vfs and paths.json PRs.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • I have added sufficient tests to cover my changes.

DagmarFiep and others added 30 commits July 17, 2026 14:48
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
chrisburr and others added 4 commits July 17, 2026 16:51
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants