diff --git a/Cargo.lock b/Cargo.lock index 229927c575..6776fbb155 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1680,8 +1680,7 @@ dependencies = [ [[package]] name = "coalesced_map" version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fe09392885c9af28aed39c6ddd06e41b43dbe98a850ccf0abe3f88d7cc75de" +source = "git+https://github.com/chrisburr/rattler?branch=feat%2Frattler-fs#20df736e29fcdc62ab2daddb992e9defcdd918e1" dependencies = [ "dashmap", "tokio", @@ -2861,8 +2860,7 @@ checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] name = "file_url" version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5d8c8b9119e366c1b4103d3ca9b5e09cc5684ae14f1265d1472d0a7fbc61747" +source = "git+https://github.com/chrisburr/rattler?branch=feat%2Frattler-fs#20df736e29fcdc62ab2daddb992e9defcdd918e1" dependencies = [ "itertools 0.14.0", "percent-encoding", @@ -3027,6 +3025,26 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" +[[package]] +name = "fuser" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80a5eca878900c2e39e9e52fd797954b7fc39eeefc8558257114bfea6a698fcf" +dependencies = [ + "bitflags 2.11.1", + "libc", + "log", + "memchr", + "nix 0.30.1", + "num_enum", + "page_size", + "parking_lot 0.12.5", + "pkg-config", + "ref-cast", + "smallvec", + "zerocopy", +] + [[package]] name = "futures" version = "0.3.32" @@ -5076,6 +5094,38 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2970fbbc8c785e8246234a7bd004ed66cd1ed1a35ec73669a92545e419b836" +[[package]] +name = "nfs3_macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06d8fb377e6efeb91911a8ed962a69615535e167e675025a2be8fa109751426a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "nfs3_server" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fa1515f86cd0b985e3ca7b8004d39a07e70298911cf39f09e403684c32bac8a" +dependencies = [ + "anyhow", + "nfs3_types", + "tokio", + "tracing", +] + +[[package]] +name = "nfs3_types" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5d5ab1a9fdfeab2f03b36b6300dea4ea3806e6a057cb479628b41d65e356b94" +dependencies = [ + "nfs3_macros", +] + [[package]] name = "nix" version = "0.29.0" @@ -5098,6 +5148,7 @@ dependencies = [ "cfg-if 1.0.4", "cfg_aliases", "libc", + "memoffset", ] [[package]] @@ -5274,6 +5325,28 @@ dependencies = [ "libc", ] +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "oauth2" version = "5.0.0" @@ -5688,6 +5761,16 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "parking" version = "2.2.1" @@ -5760,8 +5843,7 @@ checksum = "1e91099d4268b0e11973f036e885d652fb0b21fedcf69738c627f94db6a44f42" [[package]] name = "path_resolver" version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a6d8be98ef9723e752c20eef948d3195d4e1314bc3cc033a8ff26b4e31d660" +source = "git+https://github.com/chrisburr/rattler?branch=feat%2Frattler-fs#20df736e29fcdc62ab2daddb992e9defcdd918e1" dependencies = [ "ahash", "fs-err", @@ -6435,6 +6517,7 @@ dependencies = [ "pypi_modifiers", "rattler", "rattler_conda_types", + "rattler_fs", "rattler_index", "rattler_lock", "rattler_networking", @@ -6468,6 +6551,7 @@ dependencies = [ "uv-pypi-types", "uv-requirements-txt", "which", + "windows-sys 0.61.2", "zip", ] @@ -6643,7 +6727,7 @@ dependencies = [ "pixi_consts", "rattler", "rattler_conda_types", - "rattler_config", + "rattler_config 0.5.2 (registry+https://github.com/rust-lang/crates.io-index)", "rattler_networking", "rattler_repodata_gateway", "reqwest", @@ -6724,6 +6808,7 @@ dependencies = [ "rattler", "rattler_conda_types", "rattler_digest", + "rattler_fs", "rattler_lock", "rattler_networking", "rattler_repodata_gateway", @@ -6735,6 +6820,7 @@ dependencies = [ "serde_ignored", "serde_json", "strsim", + "sysinfo", "tabwriter", "temp-env", "tempfile", @@ -6769,6 +6855,7 @@ dependencies = [ "uv-resolver", "uv-types", "uv-workspace", + "windows-sys 0.61.2", "xxhash-rust", ] @@ -7984,8 +8071,7 @@ dependencies = [ [[package]] name = "rattler" version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67961cc1ccb170208f10169542752dbe730353a874a13cb0749c13b960d72d5b" +source = "git+https://github.com/chrisburr/rattler?branch=feat%2Frattler-fs#20df736e29fcdc62ab2daddb992e9defcdd918e1" dependencies = [ "anyhow", "astral-reqwest-middleware", @@ -8078,14 +8164,14 @@ dependencies = [ "rattler_build_types", "rattler_cache", "rattler_conda_types", - "rattler_config", + "rattler_config 0.5.2 (registry+https://github.com/rust-lang/crates.io-index)", "rattler_digest", "rattler_git", "rattler_index", "rattler_menuinst", "rattler_networking", "rattler_package_streaming", - "rattler_redaction", + "rattler_redaction 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", "rattler_repodata_gateway", "rattler_s3", "rattler_shell", @@ -8305,8 +8391,7 @@ dependencies = [ [[package]] name = "rattler_cache" version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24205a6af611e71f346e5e170c32f00ce1a9b49077f699f18d8404e711dc2209" +source = "git+https://github.com/chrisburr/rattler?branch=feat%2Frattler-fs#20df736e29fcdc62ab2daddb992e9defcdd918e1" dependencies = [ "ahash", "anyhow", @@ -8324,7 +8409,7 @@ dependencies = [ "rattler_digest", "rattler_networking", "rattler_package_streaming", - "rattler_redaction", + "rattler_redaction 0.2.1 (git+https://github.com/chrisburr/rattler?branch=feat%2Frattler-fs)", "rayon", "reqwest", "serde_json", @@ -8339,8 +8424,7 @@ dependencies = [ [[package]] name = "rattler_conda_types" version = "0.47.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385575f3cfb4b9ce40c13d90b37964c80d8b527b62514118e4783ae0717b3ebd" +source = "git+https://github.com/chrisburr/rattler?branch=feat%2Frattler-fs#20df736e29fcdc62ab2daddb992e9defcdd918e1" dependencies = [ "ahash", "core-foundation 0.10.1", @@ -8354,13 +8438,14 @@ dependencies = [ "itertools 0.14.0", "jiff", "lazy-regex", + "memchr", "memmap2 0.9.10", "nom 8.0.0", "nom-language", "purl", "rattler_digest", "rattler_macros", - "rattler_redaction", + "rattler_redaction 0.2.1 (git+https://github.com/chrisburr/rattler?branch=feat%2Frattler-fs)", "rayon", "regex", "serde", @@ -8397,11 +8482,27 @@ dependencies = [ "url", ] +[[package]] +name = "rattler_config" +version = "0.5.2" +source = "git+https://github.com/chrisburr/rattler?branch=feat%2Frattler-fs#20df736e29fcdc62ab2daddb992e9defcdd918e1" +dependencies = [ + "console", + "fs-err", + "indexmap 2.14.0", + "rattler_conda_types", + "serde", + "serde_json", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "tracing", + "url", +] + [[package]] name = "rattler_digest" version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e4af3c4e8b0cf496e66a5f2e7244364409a20cd4338ad9b331bd7247de32b69" +source = "git+https://github.com/chrisburr/rattler?branch=feat%2Frattler-fs#20df736e29fcdc62ab2daddb992e9defcdd918e1" dependencies = [ "blake2 0.11.0-rc.6", "digest 0.11.3", @@ -8417,6 +8518,36 @@ dependencies = [ "url", ] +[[package]] +name = "rattler_fs" +version = "0.1.0" +source = "git+https://github.com/chrisburr/rattler?branch=feat%2Frattler-fs#20df736e29fcdc62ab2daddb992e9defcdd918e1" +dependencies = [ + "anyhow", + "astral-reqwest-middleware", + "fs4", + "fuser", + "hex", + "libc", + "memchr", + "memmap2 0.9.10", + "nfs3_server", + "nfs3_types", + "rattler", + "rattler_cache", + "rattler_conda_types", + "rattler_lock", + "rattler_networking", + "reflink-copy", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror 2.0.18", + "tokio", + "tracing", + "windows 0.62.2", +] + [[package]] name = "rattler_git" version = "0.1.3" @@ -8441,8 +8572,7 @@ dependencies = [ [[package]] name = "rattler_index" version = "0.30.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d078770093f35bfdd275aa0c34689ef989b76dcd37d4e3b31a668e98852e61" +source = "git+https://github.com/chrisburr/rattler?branch=feat%2Frattler-fs#20df736e29fcdc62ab2daddb992e9defcdd918e1" dependencies = [ "ahash", "anyhow", @@ -8458,7 +8588,7 @@ dependencies = [ "jiff", "opendal", "rattler_conda_types", - "rattler_config", + "rattler_config 0.5.2 (git+https://github.com/chrisburr/rattler?branch=feat%2Frattler-fs)", "rattler_digest", "rattler_networking", "rattler_package_streaming", @@ -8481,8 +8611,7 @@ dependencies = [ [[package]] name = "rattler_lock" version = "0.31.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ec8b22e87f14a9ca2e55061f71b1d51d2dfb4aa1766babb95f06f6218d6733a" +source = "git+https://github.com/chrisburr/rattler?branch=feat%2Frattler-fs#20df736e29fcdc62ab2daddb992e9defcdd918e1" dependencies = [ "ahash", "file_url", @@ -8510,8 +8639,7 @@ dependencies = [ [[package]] name = "rattler_macros" version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3d93067b745b200dd741e872f4160de24d1b972ee63db65e8a2cf6a447f1ff" +source = "git+https://github.com/chrisburr/rattler?branch=feat%2Frattler-fs#20df736e29fcdc62ab2daddb992e9defcdd918e1" dependencies = [ "quote", "syn", @@ -8520,8 +8648,7 @@ dependencies = [ [[package]] name = "rattler_menuinst" version = "0.2.68" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "957dbd0e8fb9ed66dce0bd6922914fb8bf795a4334d92e2190f34314a0b1f170" +source = "git+https://github.com/chrisburr/rattler?branch=feat%2Frattler-fs#20df736e29fcdc62ab2daddb992e9defcdd918e1" dependencies = [ "configparser", "dirs", @@ -8551,8 +8678,7 @@ dependencies = [ [[package]] name = "rattler_networking" version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5014e4f5aedcbe01a83a5be0e8669b305adc82cc10985fc83f51c5fe07e0d198" +source = "git+https://github.com/chrisburr/rattler?branch=feat%2Frattler-fs#20df736e29fcdc62ab2daddb992e9defcdd918e1" dependencies = [ "ambient-id", "anyhow", @@ -8575,7 +8701,7 @@ dependencies = [ "itertools 0.14.0", "keyring-core", "netrc-rs", - "rattler_config", + "rattler_config 0.5.2 (git+https://github.com/chrisburr/rattler?branch=feat%2Frattler-fs)", "reqwest", "retry-policies", "serde", @@ -8591,8 +8717,7 @@ dependencies = [ [[package]] name = "rattler_package_streaming" version = "0.26.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b4440ad7368a66ddd370a8160d8fb2a4d0a825b7a050f9c421dac350b37bed2" +source = "git+https://github.com/chrisburr/rattler?branch=feat%2Frattler-fs#20df736e29fcdc62ab2daddb992e9defcdd918e1" dependencies = [ "astral-reqwest-middleware", "astral-tokio-tar", @@ -8614,7 +8739,7 @@ dependencies = [ "rattler_conda_types", "rattler_digest", "rattler_networking", - "rattler_redaction", + "rattler_redaction 0.2.1 (git+https://github.com/chrisburr/rattler?branch=feat%2Frattler-fs)", "reqwest", "serde_json", "simple_spawn_blocking", @@ -8646,8 +8771,7 @@ dependencies = [ [[package]] name = "rattler_pty" version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d19145200e8c578be6dca325fbacf375a57f86d4a7ccf85d2d8ee193ddfb10dd" +source = "git+https://github.com/chrisburr/rattler?branch=feat%2Frattler-fs#20df736e29fcdc62ab2daddb992e9defcdd918e1" dependencies = [ "libc", "nix 0.30.1", @@ -8660,6 +8784,15 @@ name = "rattler_redaction" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d1893dff3da09d778304797291869b17287e35a785cd8bc58fb9cd4760ef51b" +dependencies = [ + "reqwest", + "url", +] + +[[package]] +name = "rattler_redaction" +version = "0.2.1" +source = "git+https://github.com/chrisburr/rattler?branch=feat%2Frattler-fs#20df736e29fcdc62ab2daddb992e9defcdd918e1" dependencies = [ "astral-reqwest-middleware", "reqwest", @@ -8669,8 +8802,7 @@ dependencies = [ [[package]] name = "rattler_repodata_gateway" version = "0.29.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbd05b4c6fbad5fae62d1d67c22c2bb411a7d21a026aeb079893df1d6d157a61" +source = "git+https://github.com/chrisburr/rattler?branch=feat%2Frattler-fs#20df736e29fcdc62ab2daddb992e9defcdd918e1" dependencies = [ "ahash", "anyhow", @@ -8707,7 +8839,7 @@ dependencies = [ "rattler_digest", "rattler_networking", "rattler_package_streaming", - "rattler_redaction", + "rattler_redaction 0.2.1 (git+https://github.com/chrisburr/rattler?branch=feat%2Frattler-fs)", "reqwest", "retry-policies", "rmp-serde", @@ -8732,8 +8864,7 @@ dependencies = [ [[package]] name = "rattler_s3" version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e2a55757ecde8f53a17bc176d13061dda08485e3d1197dd7d9338e04ec7a3ca" +source = "git+https://github.com/chrisburr/rattler?branch=feat%2Frattler-fs#20df736e29fcdc62ab2daddb992e9defcdd918e1" dependencies = [ "aws-config", "aws-credential-types", @@ -8749,8 +8880,7 @@ dependencies = [ [[package]] name = "rattler_shell" version = "0.27.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9794276d154c19579fe7982ab2c1e9a960f0d51e5e56cd8d9fafe36373a71dd3" +source = "git+https://github.com/chrisburr/rattler?branch=feat%2Frattler-fs#20df736e29fcdc62ab2daddb992e9defcdd918e1" dependencies = [ "anyhow", "enum_dispatch", @@ -8771,8 +8901,7 @@ dependencies = [ [[package]] name = "rattler_solve" version = "7.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a9bbdbf299cc4f9b363bd54a4e315be7e96c7645f0a75df5853405d0189ead6" +source = "git+https://github.com/chrisburr/rattler?branch=feat%2Frattler-fs#20df736e29fcdc62ab2daddb992e9defcdd918e1" dependencies = [ "futures", "hex", @@ -8790,8 +8919,7 @@ dependencies = [ [[package]] name = "rattler_upload" version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af1e75ffc1d23661ac5ad6b25836f6918fe46dfe065c013123d08e97f63e6f57" +source = "git+https://github.com/chrisburr/rattler?branch=feat%2Frattler-fs#20df736e29fcdc62ab2daddb992e9defcdd918e1" dependencies = [ "astral-reqwest-middleware", "astral-reqwest-retry", @@ -8804,11 +8932,11 @@ dependencies = [ "miette 7.6.0", "opendal", "rattler_conda_types", - "rattler_config", + "rattler_config 0.5.2 (git+https://github.com/chrisburr/rattler?branch=feat%2Frattler-fs)", "rattler_digest", "rattler_networking", "rattler_package_streaming", - "rattler_redaction", + "rattler_redaction 0.2.1 (git+https://github.com/chrisburr/rattler?branch=feat%2Frattler-fs)", "rattler_s3", "rattler_solve", "reqwest", @@ -8829,8 +8957,7 @@ dependencies = [ [[package]] name = "rattler_virtual_packages" version = "3.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "312c7e886ca90e83d562cadfb3d88af9dcef42f7c2489dae569df84c4bd07890" +source = "git+https://github.com/chrisburr/rattler?branch=feat%2Frattler-fs#20df736e29fcdc62ab2daddb992e9defcdd918e1" dependencies = [ "archspec", "libloading", @@ -10470,8 +10597,7 @@ dependencies = [ [[package]] name = "simple_spawn_blocking" version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55c0b0b683828aa9d4f5c0e59b0c856a12c30a65b5f1ca4292664734d76fa9c2" +source = "git+https://github.com/chrisburr/rattler?branch=feat%2Frattler-fs#20df736e29fcdc62ab2daddb992e9defcdd918e1" dependencies = [ "tokio", ] @@ -10842,7 +10968,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", diff --git a/Cargo.toml b/Cargo.toml index bc4129d526..07b88383c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -175,6 +175,7 @@ slotmap = "1.0.7" spdx = "0.10.8" strsim = "0.11.1" strum = "0.28.0" +sysinfo = "0.39" tabwriter = "1.4.1" tar = "0.4.44" temp-env = { version = "0.3.6", features = ["async_closure"] } @@ -223,6 +224,7 @@ uv-types = { git = "https://github.com/astral-sh/uv", tag = "0.11.15" } uv-workspace = { git = "https://github.com/astral-sh/uv", tag = "0.11.15" } webpki-root-certs = "1" which = "8.0.0" +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_Threading", "Win32_System_IO", "Win32_Storage_FileSystem"] } xxhash-rust = "0.8.15" zip = { version = "8.0.0", default-features = false } zstd = { version = "0.13.3", default-features = false } @@ -231,6 +233,7 @@ zstd = { version = "0.13.3", default-features = false } file_url = "0.3.0" rattler = { version = "0.46", default-features = false } rattler_cache = { version = "0.10", default-features = false } +rattler_fs = { version = "0.1.0" } rattler_conda_types = { version = "0.47", default-features = false, features = [ "rayon", ] } @@ -267,6 +270,26 @@ rattler_build_types = { version = "0.1.8" } rattler_build_variant_config = { version = "0.1.9" } [patch.crates-io] +coalesced_map = { git = "https://github.com/chrisburr/rattler", branch = "feat/rattler-fs" } +file_url = { git = "https://github.com/chrisburr/rattler", branch = "feat/rattler-fs" } +path_resolver = { git = "https://github.com/chrisburr/rattler", branch = "feat/rattler-fs" } +rattler = { git = "https://github.com/chrisburr/rattler", branch = "feat/rattler-fs" } +rattler_cache = { git = "https://github.com/chrisburr/rattler", branch = "feat/rattler-fs" } +rattler_conda_types = { git = "https://github.com/chrisburr/rattler", branch = "feat/rattler-fs" } +rattler_digest = { git = "https://github.com/chrisburr/rattler", branch = "feat/rattler-fs" } +rattler_fs = { git = "https://github.com/chrisburr/rattler", branch = "feat/rattler-fs" } +rattler_index = { git = "https://github.com/chrisburr/rattler", branch = "feat/rattler-fs" } +rattler_lock = { git = "https://github.com/chrisburr/rattler", branch = "feat/rattler-fs" } +rattler_menuinst = { git = "https://github.com/chrisburr/rattler", branch = "feat/rattler-fs" } +rattler_networking = { git = "https://github.com/chrisburr/rattler", branch = "feat/rattler-fs" } +rattler_package_streaming = { git = "https://github.com/chrisburr/rattler", branch = "feat/rattler-fs" } +rattler_repodata_gateway = { git = "https://github.com/chrisburr/rattler", branch = "feat/rattler-fs" } +rattler_s3 = { git = "https://github.com/chrisburr/rattler", branch = "feat/rattler-fs" } +rattler_shell = { git = "https://github.com/chrisburr/rattler", branch = "feat/rattler-fs" } +rattler_solve = { git = "https://github.com/chrisburr/rattler", branch = "feat/rattler-fs" } +rattler_upload = { git = "https://github.com/chrisburr/rattler", branch = "feat/rattler-fs" } +rattler_virtual_packages = { git = "https://github.com/chrisburr/rattler", branch = "feat/rattler-fs" } +simple_spawn_blocking = { git = "https://github.com/chrisburr/rattler", branch = "feat/rattler-fs" } version-ranges = { git = "https://github.com/astral-sh/pubgrub", rev = "d8efd77673c9a90792da9da31b6c0da7ea8a324b" } # Redirect crates.io's `reqwest-middleware` to a local shim that re-exports diff --git a/crates/pixi_cli/Cargo.toml b/crates/pixi_cli/Cargo.toml index e4b25e6092..d27bd69cff 100644 --- a/crates/pixi_cli/Cargo.toml +++ b/crates/pixi_cli/Cargo.toml @@ -68,6 +68,7 @@ pypi_modifiers = { workspace = true } rattler = { workspace = true, features = ["cli-tools", "indicatif"] } rattler_conda_types = { workspace = true } rattler_index = { workspace = true } +rattler_fs = { workspace = true } rattler_lock = { workspace = true } rattler_networking = { workspace = true, default-features = false } rattler_package_streaming = { workspace = true } @@ -111,6 +112,9 @@ pixi_test_utils = { workspace = true } libc = { workspace = true, default-features = false } pixi_pty = { path = "../pixi_pty" } +[target.'cfg(windows)'.dependencies] +windows-sys = { workspace = true } + [features] default = [] native-tls = [ diff --git a/crates/pixi_cli/src/lib.rs b/crates/pixi_cli/src/lib.rs index ce716f5e3d..2594b2da9c 100644 --- a/crates/pixi_cli/src/lib.rs +++ b/crates/pixi_cli/src/lib.rs @@ -37,6 +37,7 @@ pub mod install; pub mod list; pub mod lock; pub(crate) mod match_spec_or_path; +pub mod mount; mod process_exit; pub mod publish; pub mod reinstall; @@ -49,6 +50,7 @@ pub mod shell; pub mod shell_hook; pub mod task; pub mod tree; +pub mod umount; pub mod update; pub mod upgrade; pub mod upload; @@ -174,6 +176,8 @@ pub enum Command { #[clap(visible_alias = "ls")] List(list::Args), Lock(lock::Args), + Mount(mount::Args), + Umount(umount::Args), Reinstall(reinstall::Args), Publish(publish::Args), #[clap(visible_alias = "rm")] @@ -385,6 +389,8 @@ pub async fn execute_command( Command::Update(cmd) => update::execute(cmd).await, Command::Upgrade(cmd) => upgrade::execute(cmd).await, Command::Lock(cmd) => lock::execute(cmd).await, + Command::Mount(cmd) => mount::execute(cmd).await, + Command::Umount(cmd) => umount::execute(cmd).await, Command::Exec(args) => exec::execute(args).await, Command::Build(args) => build::execute(args).await, Command::External(args) => command_info::execute_external_command(args), diff --git a/crates/pixi_cli/src/mount.rs b/crates/pixi_cli/src/mount.rs new file mode 100644 index 0000000000..b35b32af1e --- /dev/null +++ b/crates/pixi_cli/src/mount.rs @@ -0,0 +1,529 @@ +use std::path::PathBuf; + +use clap::Parser; +use miette::IntoDiagnostic; +use pixi_config::{Config, ConfigCli}; +use pixi_core::{ + UpdateLockFileOptions, WorkspaceLocator, + environment::get_update_lock_file_and_prefix, + lock_file::{ReinstallPackages, UpdateMode}, +}; +use rattler::package_cache::PackageCache; +use rattler_conda_types::Platform; +use rattler_lock::DEFAULT_ENVIRONMENT_NAME; + +use crate::cli_config::{LockAndInstallConfig, WorkspaceConfig}; + +/// Mount a pixi environment as a virtual filesystem. +/// +/// In interactive mode (default), the environment is mounted and the command +/// blocks until Ctrl+C is pressed. In managed mode (--managed), the process +/// daemonizes and waits for SIGTERM — this is used internally by `pixi run` +/// and `pixi shell` when `experimental.environment-backend = "mount"`. +#[derive(Parser, Debug)] +pub struct Args { + #[clap(flatten)] + workspace_config: WorkspaceConfig, + + #[clap(flatten)] + lock_and_install_config: LockAndInstallConfig, + + #[clap(flatten)] + config: ConfigCli, + + /// The environment to mount. + #[arg(long, short)] + environment: Option, + + /// Mount point override. Defaults to the environment directory. + #[arg(long)] + mount_point: Option, + + // --- Hidden sidecar flags (used by pixi run/shell internally) --- + /// Run as a managed sidecar process. + #[arg(long, hide = true)] + managed: bool, + + /// Path to the pidfile (used with --managed). + #[arg(long, hide = true)] + pidfile: Option, + + /// File descriptor number for the readiness pipe (used with --managed, Unix only). + #[arg(long, hide = true)] + ready_fd: Option, + + /// Named event for readiness signaling (used with --managed, Windows only). + #[arg(long, hide = true)] + ready_event: Option, +} + +pub async fn execute(args: Args) -> miette::Result<()> { + let config = Config::from(args.config.clone()); + let workspace = WorkspaceLocator::for_cli() + .with_search_start(args.workspace_config.workspace_locator_start()) + .locate()? + .with_cli_config(config); + + let environment = workspace.environment_from_name_or_env_var(args.environment)?; + let env_dir = environment.dir(); + let mount_point = args.mount_point.unwrap_or_else(|| env_dir.clone()); + let platform = environment + .best_declared_platform() + .map(|p| p.subdir()) + .unwrap_or_else(Platform::current); + + let env_name = environment.name().as_str(); + let env_name = if env_name.is_empty() { + DEFAULT_ENVIRONMENT_NAME + } else { + env_name + }; + + // Determine overlay based on read-only config + let overlay_dir = if workspace.config().mount_read_only() { + None + } else { + Some(pixi_core::environment::mount_sidecar::MountGuard::overlay_dir(&env_dir)) + }; + + // The user-facing warning for an adopted overlay is printed client-side by + // `ensure_mount` (the sidecar's stderr goes to a log file), so here `warn` + // and `ignore` both map to "adopt"; only `error` refuses inside rattler_fs. + let overlay_mismatch = map_overlay_mismatch(workspace.config().mount_overlay_mismatch()); + + let transport = match workspace.config().mount_backend() { + pixi_config::MountBackend::Auto => rattler_fs::Transport::Auto, + pixi_config::MountBackend::Nfs => rattler_fs::Transport::Nfs, + pixi_config::MountBackend::Fuse => rattler_fs::Transport::Fuse, + }; + + // Reject an unavailable transport early with an actionable message instead + // of failing deep inside the mount (e.g. `fuse` on macOS needs rattler_fs + // built with the `fuse` feature / macFUSE; NFS is the macOS default). + if !transport.is_available() { + return Err(miette::miette!( + "mount transport {transport:?} is not available on this platform/build. \ + On macOS the FUSE backend requires macFUSE and rattler_fs built with the \ + `fuse` feature; use the default `mount-backend = \"nfs\"` instead." + )); + } + + if args.managed { + // The managed sidecar is spawned by ensure_mount after the parent has + // already solved and cached packages. Just load the existing lock file + // and package cache — no solve needed. + let lock_file_path = workspace.lock_file_path(); + let lock_file = rattler_lock::LockFile::from_path(&lock_file_path).into_diagnostic()?; + let package_cache = PackageCache::new( + pixi_config::get_cache_dir()?.join(pixi_consts::consts::CONDA_PACKAGE_CACHE_DIR), + ); + + let env_hash = rattler_fs::compute_env_hash(&lock_file, env_name, platform) + .map_err(|e| miette::miette!("failed to compute env hash: {e}"))?; + + let grace_period = workspace.config().mount_grace_period(); + + execute_managed( + &lock_file, + env_name, + platform, + &package_cache, + &mount_point, + overlay_dir, + overlay_mismatch, + &env_hash, + &lock_file_path, + transport, + grace_period, + args.pidfile.as_deref(), + args.ready_fd, + args.ready_event.as_deref(), + ) + .await + } else { + // Interactive mode: ensure lock file is up-to-date and packages are + // cached (but skip the hardlink install — the mount replaces it). + let (lock_file_data, _prefix) = get_update_lock_file_and_prefix( + &environment, + None, + UpdateMode::Revalidate, + UpdateLockFileOptions { + lock_file_usage: args.lock_and_install_config.lock_file_usage()?, + no_install: true, + upgrade_lock_file_format: false, + max_concurrent_solves: workspace.config().max_concurrent_solves(), + }, + ReinstallPackages::default(), + &pixi_core::environment::InstallFilter::default(), + ) + .await?; + + let env_hash = rattler_fs::compute_env_hash(&lock_file_data.lock_file, env_name, platform) + .map_err(|e| miette::miette!("failed to compute env hash: {e}"))?; + + // Interactive mounts don't go through `ensure_mount`, so do the + // overlay-mismatch check (warn / error naming the config option) here, + // before mounting. The managed path is handled by `ensure_mount`. + if let Some(overlay_dir) = &overlay_dir { + pixi_core::environment::mount_sidecar::warn_or_error_on_overlay_mismatch( + overlay_dir, + env_name, + &env_hash, + workspace.config().mount_overlay_mismatch(), + )?; + } + + execute_interactive( + &lock_file_data.lock_file, + env_name, + platform, + &lock_file_data.package_cache, + &mount_point, + overlay_dir, + overlay_mismatch, + &env_hash, + transport, + ) + .await + } +} + +/// Map the pixi overlay-mismatch config to the rattler_fs mount policy. Both +/// `warn` and `ignore` adopt the overlay (rattler_fs has no separate "warn" +/// state — pixi owns the user-facing message); only `error` refuses. +fn map_overlay_mismatch(policy: pixi_config::OverlayMismatch) -> rattler_fs::OverlayMismatch { + match policy { + pixi_config::OverlayMismatch::Error => rattler_fs::OverlayMismatch::Error, + pixi_config::OverlayMismatch::Warn | pixi_config::OverlayMismatch::Ignore => { + rattler_fs::OverlayMismatch::Adopt + } + } +} + +#[allow(clippy::too_many_arguments)] +async fn execute_interactive( + lock_file: &rattler_lock::LockFile, + environment_name: &str, + platform: Platform, + package_cache: &PackageCache, + mount_point: &std::path::Path, + overlay_dir: Option, + overlay_mismatch: rattler_fs::OverlayMismatch, + env_hash: &str, + transport: rattler_fs::Transport, +) -> miette::Result<()> { + fs_err::create_dir_all(mount_point).into_diagnostic()?; + + let config = if let Some(overlay_dir) = overlay_dir { + rattler_fs::MountConfig::new_writable( + mount_point.to_path_buf(), + Some(overlay_dir), + transport, + env_hash.to_string(), + ) + } else { + rattler_fs::MountConfig::new_read_only_if_supported( + mount_point.to_path_buf(), + transport, + env_hash.to_string(), + ) + } + .with_overlay_mismatch(overlay_mismatch); + + let _handle = rattler_fs::build_and_mount( + lock_file, + environment_name, + platform, + package_cache, + &config, + ) + .await + .map_err(|e| miette::miette!("failed to mount: {e}"))?; + + eprintln!( + "Mounted at {}. Press Ctrl+C to unmount.", + mount_point.display() + ); + + // Wait for Ctrl+C or SIGTERM + #[cfg(unix)] + { + let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .into_diagnostic()?; + tokio::select! { + _ = tokio::signal::ctrl_c() => {}, + _ = sigterm.recv() => {}, + } + } + #[cfg(not(unix))] + tokio::signal::ctrl_c().await.into_diagnostic()?; + + // _handle drops here, triggering unmount + Ok(()) +} + +#[allow(clippy::too_many_arguments, unused_variables)] +async fn execute_managed( + lock_file: &rattler_lock::LockFile, + environment_name: &str, + platform: Platform, + package_cache: &PackageCache, + mount_point: &std::path::Path, + overlay_dir: Option, + overlay_mismatch: rattler_fs::OverlayMismatch, + env_hash: &str, + lock_file_path: &std::path::Path, + transport: rattler_fs::Transport, + grace_period: u64, + pidfile: Option<&std::path::Path>, + ready_fd: Option, + ready_event: Option<&str>, +) -> miette::Result<()> { + fs_err::create_dir_all(mount_point).into_diagnostic()?; + + // Re-set CLOEXEC on the readiness pipe's write end. The parent handed it to + // us with CLOEXEC cleared so we could inherit it across exec; re-set it now, + // before mounting spawns helper processes (`sudo mount`/`fusermount3`), so + // those helpers don't inherit it. An inherited, held write end would defer + // the parent's readiness EOF and stall it for the full timeout. + #[cfg(unix)] + if let Some(fd) = ready_fd { + unsafe { + let flags = libc::fcntl(fd, libc::F_GETFD); + if flags >= 0 { + libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC); + } + } + } + + // Read-only when there is no writable overlay. Captured before `overlay_dir` + // is moved into the config below, so it can go into the sidecar record. + let read_only = overlay_dir.is_none(); + + let config = if let Some(overlay_dir) = overlay_dir { + rattler_fs::MountConfig::new_writable( + mount_point.to_path_buf(), + Some(overlay_dir), + transport, + env_hash.to_string(), + ) + } else { + rattler_fs::MountConfig::new_read_only_if_supported( + mount_point.to_path_buf(), + transport, + env_hash.to_string(), + ) + } + .with_overlay_mismatch(overlay_mismatch); + + let handle = rattler_fs::build_and_mount( + lock_file, + environment_name, + platform, + package_cache, + &config, + ) + .await + .map_err(|e| miette::miette!("failed to mount: {e}"))?; + + // Write the sidecar record (pid + start time + identity). Written after the + // mount is live and before signaling readiness, so a present, valid record + // implies a mounted sidecar for this exact environment. + if let Some(pidfile) = pidfile { + pixi_core::environment::mount_sidecar::write_current_sidecar_state( + pidfile, + env_hash, + lock_file_path, + transport.resolve().name(), + read_only, + mount_point, + )?; + } + + // Signal readiness via pipe (Unix) or named event (Windows) + #[cfg(unix)] + if let Some(fd) = ready_fd { + use std::os::unix::io::FromRawFd; + let mut pipe = unsafe { std::fs::File::from_raw_fd(fd) }; + use std::io::Write; + let _ = pipe.write_all(b"ready\n"); + // pipe is dropped/closed here + } + + #[cfg(windows)] + if let Some(event_name) = ready_event { + use std::ffi::OsStr; + use std::os::windows::ffi::OsStrExt; + let event_name_wide: Vec = OsStr::new(event_name) + .encode_wide() + .chain(Some(0)) + .collect(); + unsafe { + let handle = windows_sys::Win32::System::Threading::OpenEventW( + windows_sys::Win32::System::Threading::EVENT_MODIFY_STATE, + 0, + event_name_wide.as_ptr(), + ); + if !handle.is_null() { + windows_sys::Win32::System::Threading::SetEvent(handle); + windows_sys::Win32::Foundation::CloseHandle(handle); + } + } + } + + // Poll for client activity using the lock file. When no clients hold a + // shared lock for `grace_period` seconds, shut down. + // + // On grace-period expiry we keep the exclusive probe lock and carry it + // (as `Some(probe_file)`) through teardown: an arriving client blocks on + // it and, once we exit and the pidfile is gone, correctly respawns rather + // than attaching to the mount we are tearing down. Signal-driven shutdown + // holds nothing (`None`). + #[cfg(unix)] + let _teardown_lock: Option = { + use std::os::unix::io::AsRawFd; + + let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .into_diagnostic()?; + + let (lock_path, _) = pixi_core::environment::mount_sidecar::coordination_paths(mount_point); + + let probe_file = std::fs::OpenOptions::new() + .read(true) + .open(&lock_path) + .into_diagnostic()?; + let probe_fd = probe_file.as_raw_fd(); + + let mut idle_seconds: u64 = 0; + let mut probe_errors: u32 = 0; + let mut interval = tokio::time::interval(std::time::Duration::from_secs(1)); + // Skip the immediate first tick to avoid racing with the parent + // acquiring LOCK_SH after receiving the readiness signal. + interval.tick().await; + + let hold_lock = loop { + tokio::select! { + _ = interval.tick() => { + let ret = unsafe { libc::flock(probe_fd, libc::LOCK_EX | libc::LOCK_NB) }; + if ret == 0 { + // No clients hold shared locks. + idle_seconds += 1; + probe_errors = 0; + if idle_seconds >= grace_period { + tracing::info!( + "grace period expired ({grace_period}s), shutting down sidecar" + ); + // Keep the exclusive lock through teardown. + break true; + } + // Not expired yet: release so clients can lock. + unsafe { libc::flock(probe_fd, libc::LOCK_UN); } + } else { + let err = std::io::Error::last_os_error(); + if err.raw_os_error() == Some(libc::EWOULDBLOCK) { + // A client holds a shared lock: reset the idle timer. + idle_seconds = 0; + probe_errors = 0; + } else { + // A genuine error (not contention). Don't treat it as + // "clients active" forever — that would make the + // sidecar immortal. Exit after a few consecutive ones. + probe_errors += 1; + tracing::warn!("sidecar lock probe failed: {err}"); + if probe_errors >= 5 { + tracing::error!( + "sidecar lock probe failing repeatedly; shutting down" + ); + break false; + } + } + } + } + _ = sigterm.recv() => { + tracing::debug!("sidecar received SIGTERM, shutting down"); + break false; + } + _ = tokio::signal::ctrl_c() => { + tracing::debug!("sidecar received Ctrl+C, shutting down"); + break false; + } + } + }; + + if hold_lock { Some(probe_file) } else { None } + }; + + #[cfg(not(unix))] + let _teardown_lock: Option = { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{ + LOCKFILE_EXCLUSIVE_LOCK, LOCKFILE_FAIL_IMMEDIATELY, LockFileEx, UnlockFileEx, + }; + + let (lock_path, _) = pixi_core::environment::mount_sidecar::coordination_paths(mount_point); + + let probe_file = std::fs::OpenOptions::new() + .read(true) + .open(&lock_path) + .into_diagnostic()?; + let probe_handle = probe_file.as_raw_handle(); + + let mut idle_seconds: u64 = 0; + let mut interval = tokio::time::interval(std::time::Duration::from_secs(1)); + interval.tick().await; + + let hold_lock = loop { + tokio::select! { + _ = interval.tick() => { + let mut overlapped: windows_sys::Win32::System::IO::OVERLAPPED = + unsafe { std::mem::zeroed() }; + let got_exclusive = unsafe { + LockFileEx( + probe_handle, + LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY, + 0, + 1, + 0, + &mut overlapped, + ) != 0 + }; + if got_exclusive { + idle_seconds += 1; + if idle_seconds >= grace_period { + tracing::info!( + "grace period expired ({grace_period}s), shutting down sidecar" + ); + // Keep the exclusive lock through teardown (do not + // UnlockFileEx); released when the handle closes. + break true; + } + // Not expired yet: release so clients can lock. + unsafe { UnlockFileEx(probe_handle, 0, 1, 0, &mut overlapped); } + } else { + idle_seconds = 0; + } + } + _ = tokio::signal::ctrl_c() => { + tracing::debug!("sidecar received Ctrl+C, shutting down"); + break false; + } + } + }; + + if hold_lock { Some(probe_file) } else { None } + }; + + // Tear down while still holding the probe lock (if we broke on grace + // expiry). Unmount BEFORE removing the pidfile so a present, valid record + // always implies a live mount. Explicit unmount surfaces errors that Drop + // would silently swallow. + if let Err(e) = handle.unmount().await { + tracing::error!("sidecar unmount failed: {e}"); + } + if let Some(pidfile) = pidfile { + let _ = fs_err::remove_file(pidfile); + } + // The probe lock (if held) is released here, after teardown completed. + drop(_teardown_lock); + Ok(()) +} diff --git a/crates/pixi_cli/src/shell.rs b/crates/pixi_cli/src/shell.rs index 400080daed..907dd41a4c 100644 --- a/crates/pixi_cli/src/shell.rs +++ b/crates/pixi_cli/src/shell.rs @@ -351,14 +351,20 @@ pub async fn execute(args: Args) -> miette::Result<()> { &InstallFilter::default(), ) .await?; - let lock_file = lock_file_data.into_lock_file(); + // Keep `lock_file_data` alive for the whole interactive shell: under the + // mount backend it owns the MountGuard (a shared flock) that stops the + // sidecar from unmounting the environment mid-session. Borrow the lock file + // rather than consuming it — `into_lock_file()` would drop the guard here, + // and the shell (started below via a PtySession, which does not replace this + // process) would then lose its mount ~grace-period seconds later. + let lock_file = lock_file_data.as_lock_file(); // Get the environment variables we need to set activate the environment in the shell. let env = get_activated_environment_variables( workspace.env_vars(), &environment, CurrentEnvVarBehavior::Exclude, - Some(&lock_file), + Some(lock_file), workspace.config().force_activate(), workspace.config().experimental_activation_cache_usage(), ) diff --git a/crates/pixi_cli/src/shell_hook.rs b/crates/pixi_cli/src/shell_hook.rs index 6fa34d248c..f184dc0ee0 100644 --- a/crates/pixi_cli/src/shell_hook.rs +++ b/crates/pixi_cli/src/shell_hook.rs @@ -167,6 +167,19 @@ pub async fn execute(args: Args) -> miette::Result<()> { let environment = workspace.environment_from_name_or_env_var(args.environment)?; + // The mount backend serves the environment from a sidecar kept alive by a + // client holding a shared lock. `shell-hook` prints an activation script and + // exits, so nothing holds that lock for the shell that later sources the + // script — the mount would be torn down mid-session once the grace period + // elapses. Refuse rather than emit a script that silently breaks. + if workspace.config().environment_backend() == pixi_config::EnvironmentBackend::Mount { + miette::bail!( + "`pixi shell-hook` is not supported with the mount environment backend: \ + the sourced script outlives pixi and cannot keep the mount alive. \ + Use `pixi shell` or `pixi run` instead." + ); + } + let (lock_file_data, _prefix) = get_update_lock_file_and_prefix( &environment, Some(pixi_reporters::TopLevelProgress::from_global()), diff --git a/crates/pixi_cli/src/umount.rs b/crates/pixi_cli/src/umount.rs new file mode 100644 index 0000000000..d08f0c8481 --- /dev/null +++ b/crates/pixi_cli/src/umount.rs @@ -0,0 +1,72 @@ +use std::path::PathBuf; + +use clap::Parser; +use miette::miette; +use pixi_config::{Config, ConfigCli}; +use pixi_core::WorkspaceLocator; +use pixi_core::environment::mount_sidecar; + +use crate::cli_config::WorkspaceConfig; + +/// Unmount a pixi environment virtual filesystem. +/// +/// Terminates the mount sidecar process and unmounts the environment directory. +#[derive(Parser, Debug)] +pub struct Args { + #[clap(flatten)] + workspace_config: WorkspaceConfig, + + #[clap(flatten)] + config: ConfigCli, + + /// The environment to unmount. + #[arg(long, short)] + environment: Option, + + /// Mount point override. Defaults to the environment directory. + #[arg(long)] + mount_point: Option, + + /// Unmount even if other pixi processes are currently using the mount. + #[arg(long)] + force: bool, +} + +pub async fn execute(args: Args) -> miette::Result<()> { + let config = Config::from(args.config); + let workspace = WorkspaceLocator::for_cli() + .with_search_start(args.workspace_config.workspace_locator_start()) + .locate()? + .with_cli_config(config); + + let environment = workspace.environment_from_name_or_env_var(args.environment)?; + let env_dir = environment.dir(); + let mount_point = args.mount_point.unwrap_or_else(|| env_dir.clone()); + + // Refuse to unmount out from under active clients unless forced. This is + // advisory (see `clients_attached`); a rare false positive is resolved by + // passing `--force`. + if !args.force && mount_sidecar::clients_attached(&mount_point) { + return Err(miette!( + "environment mount at {} is in use by another pixi process; \ + pass --force to unmount anyway", + mount_point.display() + )); + } + + // Terminate the sidecar (SIGTERM → SIGKILL), remove the coordination record, + // and force-unmount any residue — unmount happens before the record is + // removed, and the transport is taken from the record. Runs on a blocking + // thread because it may sleep while waiting for the sidecar to exit. + let mp = mount_point.clone(); + let torn_down = tokio::task::spawn_blocking(move || mount_sidecar::teardown_mount(&mp)) + .await + .map_err(|e| miette!("umount task failed: {e}"))??; + + if torn_down { + eprintln!("Unmounted {}", mount_point.display()); + } else { + eprintln!("Not mounted: {}", mount_point.display()); + } + Ok(()) +} diff --git a/crates/pixi_config/src/lib.rs b/crates/pixi_config/src/lib.rs index 2817c592e9..37ad5d1e3d 100644 --- a/crates/pixi_config/src/lib.rs +++ b/crates/pixi_config/src/lib.rs @@ -919,6 +919,89 @@ impl Default for DetachedEnvironments { } } +/// How environments are materialised on disk. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum EnvironmentBackend { + /// Traditional: extract and hardlink packages into the environment directory. + #[default] + Link, + /// Virtual: serve packages via a mounted filesystem (FUSE or NFS). + Mount, +} + +impl std::str::FromStr for EnvironmentBackend { + type Err = String; + fn from_str(s: &str) -> Result { + match s { + "link" => Ok(Self::Link), + "mount" => Ok(Self::Mount), + _ => Err(format!( + "unknown environment backend: {s} (expected 'link' or 'mount')" + )), + } + } +} + +/// Transport backend for mounted environments. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum MountBackend { + /// Auto-detect: NFS on macOS, FUSE on Linux. + #[default] + Auto, + /// NFS userspace server (no kernel extension required on macOS). + Nfs, + /// FUSE (requires libfuse3 on Linux, macFUSE on macOS). + Fuse, +} + +impl std::str::FromStr for MountBackend { + type Err = String; + fn from_str(s: &str) -> Result { + match s { + "auto" => Ok(Self::Auto), + "nfs" => Ok(Self::Nfs), + "fuse" => Ok(Self::Fuse), + _ => Err(format!( + "unknown mount backend: {s} (expected 'auto', 'nfs', or 'fuse')" + )), + } + } +} + +/// What to do when a writable mount's persistent overlay was created for a +/// different version of the environment (for example after `pixi add` changes +/// the lock file). The overlay may hold files the user wants to keep, such as +/// the results of `pip install`, so reusing it ("adopting" it for the new +/// environment) is the default. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum OverlayMismatch { + /// Refuse to mount, so nothing is silently reused or lost. The user resolves + /// it by removing the overlay or changing this setting. + Error, + /// Reuse ("adopt") the overlay for the new environment, printing a warning. + #[default] + Warn, + /// Reuse the overlay for the new environment silently. + Ignore, +} + +impl std::str::FromStr for OverlayMismatch { + type Err = String; + fn from_str(s: &str) -> Result { + match s { + "error" => Ok(Self::Error), + "warn" => Ok(Self::Warn), + "ignore" => Ok(Self::Ignore), + _ => Err(format!( + "unknown overlay mismatch policy: {s} (expected 'error', 'warn', or 'ignore')" + )), + } + } +} + #[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub struct ExperimentalConfig { @@ -928,6 +1011,34 @@ pub struct ExperimentalConfig { #[serde(default)] #[serde(skip_serializing_if = "Option::is_none")] pub use_environment_activation_cache: Option, + + /// How environments are materialised: "link" (default) or "mount". + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub environment_backend: Option, + + /// Transport for mounted environments: "auto" (default), "nfs", or "fuse". + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub mount_backend: Option, + + /// When true, mounted environments are read-only (no writable overlay). + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub mount_read_only: Option, + + /// How long (in seconds) the mount sidecar stays alive after the last + /// client disconnects. Defaults to 120. + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub mount_grace_period: Option, + + /// What to do when a writable mount's persistent overlay was created for a + /// different version of the environment: "error", "warn" (default), or + /// "ignore". See [`OverlayMismatch`]. + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub mount_overlay_mismatch: Option, } impl ExperimentalConfig { @@ -936,6 +1047,11 @@ impl ExperimentalConfig { use_environment_activation_cache: other .use_environment_activation_cache .or(self.use_environment_activation_cache), + environment_backend: other.environment_backend.or(self.environment_backend), + mount_backend: other.mount_backend.or(self.mount_backend), + mount_read_only: other.mount_read_only.or(self.mount_read_only), + mount_grace_period: other.mount_grace_period.or(self.mount_grace_period), + mount_overlay_mismatch: other.mount_overlay_mismatch.or(self.mount_overlay_mismatch), } } pub fn use_environment_activation_cache(&self) -> bool { @@ -944,6 +1060,11 @@ impl ExperimentalConfig { pub fn is_default(&self) -> bool { self.use_environment_activation_cache.is_none() + && self.environment_backend.is_none() + && self.mount_backend.is_none() + && self.mount_read_only.is_none() + && self.mount_grace_period.is_none() + && self.mount_overlay_mismatch.is_none() } } @@ -1360,6 +1481,7 @@ impl From for Config { } else { None }, + ..Default::default() }, pinning_strategy: cli.pinning_strategy, allow_symbolic_links: cli.no_symbolic_links.then_some(false), @@ -1775,6 +1897,11 @@ impl Config { "default-channels", "detached-environments", "experimental", + "experimental.environment-backend", + "experimental.mount-backend", + "experimental.mount-grace-period", + "experimental.mount-overlay-mismatch", + "experimental.mount-read-only", "experimental.use-environment-activation-cache", "mirrors", "pinning-strategy", @@ -1959,6 +2086,92 @@ impl Config { self.experimental.use_environment_activation_cache() } + /// How environments are materialised. Env var `PIXI_ENVIRONMENT_BACKEND` + /// takes precedence over the config file value; a set-but-unparseable value + /// is warned about and ignored. + pub fn environment_backend(&self) -> EnvironmentBackend { + if let Ok(v) = std::env::var("PIXI_ENVIRONMENT_BACKEND") { + match v.parse::() { + Ok(backend) => return backend, + Err(e) => tracing::warn!("ignoring invalid PIXI_ENVIRONMENT_BACKEND={v:?}: {e}"), + } + } + self.experimental.environment_backend.unwrap_or_default() + } + + /// Transport for mounted environments. Env var `PIXI_MOUNT_BACKEND` + /// takes precedence over the config file value. An env var that is set but + /// unparseable is warned about and ignored rather than silently dropped. + pub fn mount_backend(&self) -> MountBackend { + if let Ok(v) = std::env::var("PIXI_MOUNT_BACKEND") { + match v.parse::() { + Ok(backend) => return backend, + Err(e) => tracing::warn!("ignoring invalid PIXI_MOUNT_BACKEND={v:?}: {e}"), + } + } + self.experimental.mount_backend.unwrap_or_default() + } + + /// Whether mounted environments are read-only. Env var + /// `PIXI_MOUNT_READ_ONLY` takes precedence. An env var that is set but + /// unparseable is warned about and ignored rather than silently dropped. + pub fn mount_read_only(&self) -> bool { + if let Ok(v) = std::env::var("PIXI_MOUNT_READ_ONLY") { + match v.parse::() { + Ok(read_only) => return read_only, + Err(_) => tracing::warn!( + "ignoring invalid PIXI_MOUNT_READ_ONLY={v:?} (expected `true` or `false`)" + ), + } + } + self.experimental.mount_read_only.unwrap_or(false) + } + + /// Grace period in seconds for the mount sidecar after the last client + /// disconnects. Env var `PIXI_MOUNT_GRACE_PERIOD` takes precedence over the + /// config file value. Defaults to 120 seconds. A configured value of `0` + /// would tear the mount down almost immediately after every use (thrashing), + /// so it is clamped up to 1 second with a warning. + pub fn mount_grace_period(&self) -> u64 { + let configured = std::env::var("PIXI_MOUNT_GRACE_PERIOD") + .ok() + .and_then(|v| match v.parse::() { + Ok(n) => Some(n), + Err(_) => { + tracing::warn!("ignoring invalid PIXI_MOUNT_GRACE_PERIOD={v:?}"); + None + } + }) + .or(self.experimental.mount_grace_period); + match configured { + Some(0) => { + tracing::warn!( + "mount grace period of 0 would remount on nearly every command; \ + clamping to 1 second" + ); + 1 + } + Some(v) => v, + None => 120, + } + } + + /// Policy for reusing a writable mount's persistent overlay when it was + /// created for a different version of the environment. Env var + /// `PIXI_MOUNT_OVERLAY_MISMATCH` takes precedence over the config file value. + /// Defaults to [`OverlayMismatch::Warn`]. + pub fn mount_overlay_mismatch(&self) -> OverlayMismatch { + if let Ok(v) = std::env::var("PIXI_MOUNT_OVERLAY_MISMATCH") { + match v.parse::() { + Ok(policy) => return policy, + Err(e) => { + tracing::warn!("ignoring invalid PIXI_MOUNT_OVERLAY_MISMATCH={v:?}: {e}") + } + } + } + self.experimental.mount_overlay_mismatch.unwrap_or_default() + } + /// Retrieve the value for the max_concurrent_solves field. pub fn max_concurrent_solves(&self) -> usize { self.concurrency.solves @@ -2238,6 +2451,32 @@ impl Config { self.experimental.use_environment_activation_cache = value.map(|v| v.parse()).transpose().into_diagnostic()?; } + "environment-backend" => { + self.experimental.environment_backend = value + .map(|v| v.parse::()) + .transpose() + .map_err(|e| miette!("{e}"))?; + } + "mount-backend" => { + self.experimental.mount_backend = value + .map(|v| v.parse::()) + .transpose() + .map_err(|e| miette!("{e}"))?; + } + "mount-read-only" => { + self.experimental.mount_read_only = + value.map(|v| v.parse()).transpose().into_diagnostic()?; + } + "mount-grace-period" => { + self.experimental.mount_grace_period = + value.map(|v| v.parse()).transpose().into_diagnostic()?; + } + "mount-overlay-mismatch" => { + self.experimental.mount_overlay_mismatch = value + .map(|v| v.parse::()) + .transpose() + .map_err(|e| miette!("{e}"))?; + } _ => return Err(err), } } @@ -2775,6 +3014,7 @@ UNUSED = "unused" pinning_strategy: Some(PinningStrategy::NoPin), experimental: ExperimentalConfig { use_environment_activation_cache: Some(true), + ..Default::default() }, loaded_from: Vec::from([PathBuf::from_str("test").unwrap()]), shell: ShellConfig { diff --git a/crates/pixi_config/src/snapshots/pixi_config__tests__config_merge_multiple.snap b/crates/pixi_config/src/snapshots/pixi_config__tests__config_merge_multiple.snap index d700319f6b..6139e6ad47 100644 --- a/crates/pixi_config/src/snapshots/pixi_config__tests__config_merge_multiple.snap +++ b/crates/pixi_config/src/snapshots/pixi_config__tests__config_merge_multiple.snap @@ -129,6 +129,10 @@ Config { }, experimental: ExperimentalConfig { use_environment_activation_cache: None, + environment_backend: None, + mount_backend: None, + mount_read_only: None, + mount_grace_period: None, }, concurrency: ConcurrencyConfig { solves: 1, diff --git a/crates/pixi_core/Cargo.toml b/crates/pixi_core/Cargo.toml index bc1d7db065..2030f77a7d 100644 --- a/crates/pixi_core/Cargo.toml +++ b/crates/pixi_core/Cargo.toml @@ -62,6 +62,7 @@ pypi_modifiers = { workspace = true } rattler = { workspace = true } rattler_conda_types = { workspace = true } rattler_digest = { workspace = true } +rattler_fs = { workspace = true } rattler_lock = { workspace = true } rattler_networking = { workspace = true, default-features = false } rattler_repodata_gateway = { workspace = true } @@ -73,6 +74,7 @@ serde = { workspace = true } serde_ignored = { workspace = true } serde_json = { workspace = true } strsim = { workspace = true } +sysinfo = { workspace = true } tabwriter = { workspace = true } temp-env = { workspace = true } tempfile = { workspace = true } @@ -111,6 +113,9 @@ xxhash-rust = { workspace = true } [target.'cfg(unix)'.dependencies] libc = { workspace = true } +[target.'cfg(windows)'.dependencies] +windows-sys = { workspace = true } + [dev-dependencies] insta = { workspace = true, features = ["yaml", "glob", "filters"] } pixi_build_backend_passthrough = { workspace = true } diff --git a/crates/pixi_core/src/environment/mod.rs b/crates/pixi_core/src/environment/mod.rs index 1e1ca8f815..2b4ed1bfc7 100644 --- a/crates/pixi_core/src/environment/mod.rs +++ b/crates/pixi_core/src/environment/mod.rs @@ -1,6 +1,7 @@ pub(crate) mod conda_metadata; mod conda_prefix; pub mod list; +pub mod mount_sidecar; pub use conda_prefix::{CondaPrefixUpdated, CondaPrefixUpdater, CondaPrefixUpdaterBuilder}; use dialoguer::theme::ColorfulTheme; use futures::{FutureExt, StreamExt, TryStreamExt, stream}; diff --git a/crates/pixi_core/src/environment/mount_sidecar.rs b/crates/pixi_core/src/environment/mount_sidecar.rs new file mode 100644 index 0000000000..ec60719be6 --- /dev/null +++ b/crates/pixi_core/src/environment/mount_sidecar.rs @@ -0,0 +1,1374 @@ +//! Mount sidecar lifecycle management. +//! +//! Manages the lifecycle of a daemonized mount process for a pixi environment. +//! Uses flock-based reference counting to coordinate between multiple pixi +//! processes sharing the same mounted environment. +//! +//! ## Protocol +//! +//! Per-environment coordination files (in the parent directory of the mount +//! point to avoid routing I/O through the NFS server): +//! - `{name}.rattler-fs.lock` — flock coordination file +//! - `{name}.rattler-fs.pid` — sidecar process PID +//! +//! ### Client side (pixi run/shell): +//! 1. Try `flock(LOCK_EX | LOCK_NB)` on lock file +//! - Success + sidecar alive → reuse (grace period): downgrade to LOCK_SH +//! - Success + sidecar dead → start sidecar, downgrade to LOCK_SH +//! - EWOULDBLOCK → mount exists: acquire LOCK_SH, verify sidecar alive +//! 2. Run user's command +//! 3. On drop: release LOCK_SH (sidecar manages its own lifetime) +//! +//! ### Sidecar side: +//! 1. Mount, write PID, signal readiness +//! 2. Poll for client activity: try LOCK_EX on lock file every second +//! - Success (no clients) → increment idle counter +//! - Fail (clients active) → reset idle counter +//! 3. When idle >= grace period → unmount and exit + +#[cfg(unix)] +use std::os::unix::io::{AsRawFd, FromRawFd}; +use std::{ + fs::{File, OpenOptions}, + path::{Path, PathBuf}, +}; + +#[cfg(unix)] +use std::io::{BufRead, BufReader}; + +use miette::{IntoDiagnostic, miette}; +use rattler_conda_types::Platform; +use serde::{Deserialize, Serialize}; + +const LOCK_FILENAME: &str = ".rattler-fs.lock"; +const PID_FILENAME: &str = ".rattler-fs.pid"; +const OVERLAY_DIRNAME: &str = ".rattler-fs-overlay"; +const LOG_FILENAME: &str = ".rattler-fs.log"; + +/// Derive the coordination file paths for a given mount point. +/// +/// Both the lock file and PID file live in the parent directory of the mount +/// point, prefixed with the mount point's basename. This avoids routing I/O +/// through the NFS server that the sidecar hosts. +pub fn coordination_paths(mount_point: &Path) -> (PathBuf, PathBuf) { + let parent = mount_point.parent().expect("mount_point has no parent"); + let basename = mount_point + .file_name() + .expect("mount_point has no file name") + .to_string_lossy(); + ( + parent.join(format!("{basename}{LOCK_FILENAME}")), + parent.join(format!("{basename}{PID_FILENAME}")), + ) +} + +/// Path to the sidecar's log file (its captured stderr), beside the other +/// coordination files. The sidecar can outlive the spawning client's terminal, +/// so its diagnostics go here rather than to a possibly-dead inherited pty. +pub fn log_path(mount_point: &Path) -> PathBuf { + let parent = mount_point.parent().expect("mount_point has no parent"); + let basename = mount_point + .file_name() + .expect("mount_point has no file name") + .to_string_lossy(); + parent.join(format!("{basename}{LOG_FILENAME}")) +} + +/// A `Stdio` for the sidecar's log file (truncated per session so it stays +/// bounded), falling back to null if it can't be opened. +fn sidecar_log_stdio(mount_point: &Path) -> std::process::Stdio { + OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(log_path(mount_point)) + .map(std::process::Stdio::from) + .unwrap_or_else(|_| std::process::Stdio::null()) +} + +/// Read the tail of the sidecar log (up to `max_bytes`) to surface in an error +/// when the sidecar failed to become ready. Empty if the log is unreadable. +fn tail_log(mount_point: &Path, max_bytes: usize) -> String { + match fs_err::read(log_path(mount_point)) { + Ok(bytes) => { + let start = bytes.len().saturating_sub(max_bytes); + String::from_utf8_lossy(&bytes[start..]).trim().to_string() + } + Err(_) => String::new(), + } +} + +/// Persisted sidecar record — replaces the old bare-PID pidfile. +/// +/// Written by the sidecar after it has mounted and before it signals +/// readiness, and removed only after it unmounts. The invariant the rest of +/// the protocol relies on is: *this file exists and validates ⟹ a live, +/// non-zombie sidecar owns a mount for exactly this +/// `(env_hash, transport, read_only)`* (mount health is additionally checked +/// on the reuse path via [`is_mounted`]). +/// +/// `pid` alone is not enough — PIDs are recycled and pidfiles outlive reboots +/// — so `start_time` (seconds since the Unix epoch, via `sysinfo`) is stored +/// alongside it: a recycled PID has a different start time, and a stale +/// post-reboot pidfile matches no live process. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SidecarState { + pub pid: u32, + pub start_time: u64, + /// Environment identity hash (see `rattler_fs::compute_env_hash`). Recorded + /// for diagnostics. + pub env_hash: String, + /// Hash of the `pixi.lock` bytes the sidecar mounted from. The reuse check + /// compares this against the client's current lock file, so a client whose + /// lock file changed does not attach to a sidecar still serving the old + /// environment. Hashing the file bytes (rather than recomputing an env hash + /// with its own env-name/platform normalization) keeps the client and + /// sidecar in exact agreement with zero drift. + pub lock_hash: String, + pub transport: String, + pub read_only: bool, + pub mount_point: PathBuf, +} + +/// Hash the bytes of a `pixi.lock` file for the reuse-freshness check. Returns +/// an empty string if unreadable, which simply forces a conservative respawn. +/// Uses `DefaultHasher` (fixed seed → identical across the client and sidecar +/// processes); a non-cryptographic hash is sufficient for change detection. +fn hash_lock_file(lock_file_path: &Path) -> String { + use std::hash::{Hash, Hasher}; + match fs_err::read(lock_file_path) { + Ok(bytes) => { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + bytes.hash(&mut hasher); + format!("{:016x}", hasher.finish()) + } + Err(_) => String::new(), + } +} + +/// Query a process's start time (seconds since the Unix epoch), returning +/// `None` if the process does not exist or is a zombie. Cross-platform via +/// `sysinfo`, so there is a single liveness path on every OS. +fn live_process_start_time(pid: u32) -> Option { + use sysinfo::{Pid, ProcessStatus, ProcessesToUpdate, System}; + let mut sys = System::new(); + let spid = Pid::from_u32(pid); + sys.refresh_processes(ProcessesToUpdate::Some(&[spid]), true); + let process = sys.process(spid)?; + if matches!(process.status(), ProcessStatus::Zombie) { + return None; + } + Some(process.start_time()) +} + +/// Read and parse the sidecar record. Returns `None` if the file is missing +/// or is not a valid record — which includes the legacy bare-PID format, so an +/// old pidfile reads as "no valid sidecar" and is cleaned up as stale. +pub fn read_sidecar_state(pid_path: &Path) -> Option { + let bytes = fs_err::read(pid_path).ok()?; + serde_json::from_slice(&bytes).ok() +} + +/// Atomically write the sidecar record (write-tmp → rename) so readers never +/// observe a torn record. +fn write_sidecar_state(pid_path: &Path, state: &SidecarState) -> miette::Result<()> { + let json = serde_json::to_vec_pretty(state).into_diagnostic()?; + pixi_utils::atomic_write::atomic_write_sync(pid_path, &json).into_diagnostic()?; + Ok(()) +} + +/// Build the record for the *current* process and write it atomically. Called +/// by the managed sidecar once its mount is live, before signaling readiness. +pub fn write_current_sidecar_state( + pid_path: &Path, + env_hash: &str, + lock_file_path: &Path, + transport: &str, + read_only: bool, + mount_point: &Path, +) -> miette::Result<()> { + let pid = std::process::id(); + let start_time = live_process_start_time(pid).unwrap_or(0); + let state = SidecarState { + pid, + start_time, + env_hash: env_hash.to_string(), + lock_hash: hash_lock_file(lock_file_path), + transport: transport.to_string(), + read_only, + mount_point: mount_point.to_path_buf(), + }; + write_sidecar_state(pid_path, &state) +} + +/// RAII guard that holds a shared flock on the mount coordination file. +/// +/// When dropped, releases the shared lock. The sidecar manages its own +/// lifetime via its polling loop and grace period. +pub struct MountGuard { + #[allow(dead_code)] + lock_file: File, + #[allow(dead_code)] + lock_path: PathBuf, +} + +impl MountGuard { + /// The path to the overlay directory for this environment. + /// + /// Lives in the parent directory of `env_dir` (not inside the mount point) + /// to avoid routing writes through the NFS server. + pub fn overlay_dir(env_dir: &Path) -> PathBuf { + let parent = env_dir.parent().expect("env_dir has no parent"); + let name = env_dir + .file_name() + .expect("env_dir has no file name") + .to_string_lossy(); + parent.join(format!("{name}{OVERLAY_DIRNAME}")) + } +} + +/// Whether a live, non-zombie sidecar owns the record at `pid_path`. +/// +/// Cross-platform: reads the [`SidecarState`] record and requires both the PID +/// to exist and its start time to match, which defeats PID recycling, stale +/// post-reboot pidfiles, and zombies. This asserts *process* liveness, not +/// *mount* health — the reuse path additionally checks [`is_mounted`]. +pub fn is_sidecar_alive(pid_path: &Path) -> bool { + match read_sidecar_state(pid_path) { + Some(state) => live_process_start_time(state.pid) == Some(state.start_time), + None => false, + } +} + +/// Whether the sidecar recorded at `pid_path` can be reused by a client that +/// expects the given lock-file hash and read-only mode. Requires all of: a +/// live, non-zombie process (start time matches), a matching identity (the lock +/// file has not changed and the mode is the same — otherwise the sidecar serves +/// a stale environment), and an actually-live mount. This is the single reuse +/// predicate the coordination logic gates on. +fn is_reusable(env_dir: &Path, pid_path: &Path, lock_hash: &str, read_only: bool) -> bool { + let Some(state) = read_sidecar_state(pid_path) else { + return false; + }; + live_process_start_time(state.pid) == Some(state.start_time) + && state.lock_hash == lock_hash + && state.read_only == read_only + && is_mounted(env_dir) +} + +// ─── Unix implementation ──────────────────────────────────────────────────── + +/// Check if a path is a mount point — **without stat'ing the mount itself**. +/// +/// The old device-ID comparison called `fs::metadata(path)` on the mount +/// point, which is exactly the operation that misbehaves when the mount is +/// broken: a dead FUSE mount returns `ENOTCONN` (so the mount looked +/// *un*mounted and recovery/`pixi umount` silently no-op'd), and a dead NFS +/// hard-mount **blocks forever** (hanging `pixi run`/`pixi umount`). We instead +/// consult the kernel mount table, which lists dead mounts without touching +/// them. A path present in the table — even with a dead server — counts as +/// mounted, so callers treat it as "mounted, possibly broken" and force-unmount. +#[cfg(target_os = "linux")] +fn is_mountpoint(path: &Path) -> bool { + let Ok(mountinfo) = fs_err::read_to_string("/proc/self/mountinfo") else { + return false; + }; + mountinfo_contains(&mountinfo, path) +} + +/// Parse `/proc/self/mountinfo` and report whether `path` is a mount point. +/// Split out for unit testing without a real mount. +#[cfg(target_os = "linux")] +fn mountinfo_contains(mountinfo: &str, path: &Path) -> bool { + let target = path.to_string_lossy(); + let target = target.trim_end_matches('/'); + for line in mountinfo.lines() { + // Fields are space-separated; index 4 is the mount point, with + // whitespace/control bytes octal-escaped (e.g. `\040` for space). + if let Some(field) = line.split(' ').nth(4) + && unescape_octal(field).trim_end_matches('/') == target + { + return true; + } + } + false +} + +/// Decode the `\NNN` octal escapes that `/proc/self/mountinfo` uses for space, +/// tab, newline and backslash in path fields. +#[cfg(target_os = "linux")] +fn unescape_octal(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = String::with_capacity(s.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'\\' + && i + 3 < bytes.len() + && bytes[i + 1..=i + 3] + .iter() + .all(|b| (b'0'..=b'7').contains(b)) + && let Ok(code) = u8::from_str_radix(&s[i + 1..=i + 3], 8) + { + out.push(code as char); + i += 4; + continue; + } + out.push(bytes[i] as char); + i += 1; + } + out +} + +/// Check if a path is a mount point (macOS). Uses `getmntinfo(MNT_NOWAIT)`, +/// which reads the kernel mount table without touching the mounts, so it does +/// not hang on a dead NFS mount the way `stat` does. **Review-only:** written +/// for correctness parity with Linux but not exercised on the Linux CI here. +#[cfg(target_os = "macos")] +fn is_mountpoint(path: &Path) -> bool { + use std::ffi::CStr; + + let mut mntbufp: *mut libc::statfs = std::ptr::null_mut(); + let count = unsafe { libc::getmntinfo(&mut mntbufp, libc::MNT_NOWAIT) }; + if count <= 0 { + return false; + } + let target = path.to_string_lossy(); + let target = target.trim_end_matches('/'); + let mounts = unsafe { std::slice::from_raw_parts(mntbufp, count as usize) }; + mounts.iter().any(|m| { + let mnt = unsafe { CStr::from_ptr(m.f_mntonname.as_ptr()) }; + mnt.to_string_lossy().trim_end_matches('/') == target + }) +} + +/// Other Unix platforms don't support the mount backend; treat nothing as +/// mounted so the coordination code still compiles. +#[cfg(all(unix, not(target_os = "linux"), not(target_os = "macos")))] +fn is_mountpoint(_path: &Path) -> bool { + false +} + +/// Warn about — or refuse — reusing a writable mount's persistent overlay when +/// it was created for a different version of the environment (its recorded env +/// hash no longer matches `env_hash`, e.g. after `pixi add` changed the lock +/// file). +/// +/// The overlay may hold files the user wants to keep (the results of +/// `pip install`), so the default is to reuse ("adopt") it. pixi owns this +/// user-facing message in every mount path — the client side of `ensure_mount` +/// for `pixi run` / `pixi shell` (the sidecar's own stderr goes to a log file), +/// and `pixi mount` directly for the interactive path — while the mount itself +/// honours the mapped rattler_fs policy as a backstop. +/// +/// Returns an error only under [`pixi_config::OverlayMismatch::Error`]; `Warn` +/// prints a warning and `Ignore` is silent, both letting the mount proceed. A +/// missing overlay (no recorded hash) is "nothing to check". `environment_name` +/// and `env_hash` are used only for messaging and comparison, so callers pass +/// the same normalised name and hash the mount will use. +pub fn warn_or_error_on_overlay_mismatch( + overlay_dir: &Path, + environment_name: &str, + env_hash: &str, + overlay_mismatch: pixi_config::OverlayMismatch, +) -> miette::Result<()> { + let Some(recorded) = rattler_fs::overlay::recorded_env_hash(overlay_dir) else { + // No persistent overlay yet (read-only mount or first writable mount). + return Ok(()); + }; + if recorded == env_hash { + return Ok(()); + } + + match overlay_mismatch { + pixi_config::OverlayMismatch::Error => Err(miette!( + help = format!( + "reuse it for the new environment by setting \ + `mount-overlay-mismatch` to \"warn\" (the default) or \"ignore\", \ + or discard it and rebuild with:\n\n rm -rf {}", + overlay_dir.display() + ), + "the mount overlay at {} was created for a different version of \ + environment '{}'. It may contain files you want to keep (for example \ + the results of `pip install`).", + overlay_dir.display(), + environment_name, + )), + pixi_config::OverlayMismatch::Warn => { + tracing::warn!( + "reusing the mount overlay at {} for a changed version of environment \ + '{}'; files written to the previous version (e.g. `pip install`) are \ + kept. Set `mount-overlay-mismatch = \"error\"` to refuse instead, or \ + \"ignore\" to silence this warning.", + overlay_dir.display(), + environment_name, + ); + Ok(()) + } + pixi_config::OverlayMismatch::Ignore => Ok(()), + } +} + +/// Enforce the overlay-mismatch policy on the `ensure_mount` respawn path (while +/// we hold the exclusive lock and the mount is idle). A thin wrapper over +/// [`warn_or_error_on_overlay_mismatch`] that sources the env hash from disk, +/// since `ensure_mount` — unlike `pixi mount` — has not computed it. +/// +/// A missing/unreadable overlay or lock file is treated as "nothing to check", +/// so any genuine problem surfaces later at mount time rather than here. +fn enforce_overlay_mismatch_policy( + env_dir: &Path, + lock_file_path: &Path, + environment_name: &str, + platform: Platform, + overlay_mismatch: pixi_config::OverlayMismatch, +) -> miette::Result<()> { + let overlay_dir = MountGuard::overlay_dir(env_dir); + // Avoid parsing the lock file when there is no overlay to compare against. + if rattler_fs::overlay::recorded_env_hash(&overlay_dir).is_none() { + return Ok(()); + } + + // Compute the env hash the mount *will* use, matching the sidecar exactly: + // an empty env name normalises to the default, same as `pixi mount`. + let env_name = if environment_name.is_empty() { + rattler_lock::DEFAULT_ENVIRONMENT_NAME + } else { + environment_name + }; + let Ok(lock_file) = rattler_lock::LockFile::from_path(lock_file_path) else { + return Ok(()); + }; + let Ok(env_hash) = rattler_fs::compute_env_hash(&lock_file, env_name, platform) else { + return Ok(()); + }; + + warn_or_error_on_overlay_mismatch(&overlay_dir, env_name, &env_hash, overlay_mismatch) +} + +/// Ensure a mount is running for the given environment directory. +/// +/// If no sidecar is running, starts one (by invoking `pixi mount --managed`). +/// If a sidecar is alive in its grace period, reuses it. +/// Returns a guard that keeps the shared flock alive. +#[cfg(unix)] +pub async fn ensure_mount( + env_dir: &Path, + workspace_root: &Path, + environment_name: &str, + lock_file_path: &Path, + read_only: bool, + platform: Platform, + overlay_mismatch: pixi_config::OverlayMismatch, +) -> miette::Result { + fs_err::create_dir_all(env_dir).into_diagnostic()?; + + let (lock_path, pid_path) = coordination_paths(env_dir); + + // Identity the reuse check gates on: a sidecar serving a different lock file + // (env changed) or a different mode must not be reused. + let lock_hash = hash_lock_file(lock_file_path); + + let lock_file = OpenOptions::new() + .create(true) + .write(true) + .truncate(false) + .open(&lock_path) + .into_diagnostic()?; + + let fd = lock_file.as_raw_fd(); + + // Fast path: try a non-blocking exclusive lock. Success means no other + // client holds the lock, so we are the only process permitted to mutate + // mount state and may inspect/fix it under exclusivity. + let got_ex = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) } == 0; + + if !got_ex { + let err = std::io::Error::last_os_error(); + if err.raw_os_error() != Some(libc::EWOULDBLOCK) { + return Err(miette!("failed to acquire lock: {err}")); + } + + // Another process holds the lock: either a starting client's exclusive + // lock (we block until it downgrades) or other clients' shared locks. + // Take a blocking shared lock. + if unsafe { libc::flock(fd, libc::LOCK_SH) } != 0 { + return Err(miette!( + "failed to acquire shared lock: {}", + std::io::Error::last_os_error() + )); + } + + // Reuse only if the sidecar is alive, its mount is actually live, and it + // serves the same lock file and mode. A live PID with a dead mount + // (zombie, dead NFS server, killed mount), or one serving a now-stale + // lock file, must not be reused. + if is_reusable(env_dir, &pid_path, &lock_hash, read_only) { + return Ok(MountGuard { + lock_file, + lock_path, + }); + } + + // Not reusable while other clients hold the lock. Two cases: + // - a *healthy* mount serving a different environment (e.g. the lock + // file changed under `pixi add`): it is in active use by those + // clients, so refuse rather than block indefinitely or yank it out + // from under them. The change is picked up once the mount is idle + // (the next use remounts it — see the exclusive path below). + // - a *broken* mount (dead sidecar / dead server): fall through to the + // blocking upgrade + respawn to recover it. + if is_sidecar_alive(&pid_path) && is_mounted(env_dir) { + return Err(miette!( + "environment at {} is mounted from a different lock file and is in use by \ + another pixi process. Close any active `pixi shell` / `pixi run` for it \ + (or run `pixi umount`) and retry.", + env_dir.display() + )); + } + + // The mount is broken but other clients still hold shared locks, so we + // cannot clean up safely (that would force-unmount a mount a concurrent + // client may have just started). Rather than hot-spin via recursion, + // upgrade shared -> exclusive *blocking*: this waits for the stragglers + // to drain instead of burning CPU. The upgrade is not atomic, so the + // exclusive-held fix-up below re-checks state to cover the gap. + if unsafe { libc::flock(fd, libc::LOCK_EX) } != 0 { + return Err(miette!( + "failed to upgrade to exclusive lock: {}", + std::io::Error::last_os_error() + )); + } + } + + // We hold the exclusive lock (fast path or blocking upgrade). We are the + // sole process that may touch mount state. Reuse a healthy sidecar, + // otherwise clean up the broken/absent mount and start a fresh one — all + // under the lock (never in the unlocked window that let a stale cleanup + // nuke a live successor mount). + if is_reusable(env_dir, &pid_path, &lock_hash, read_only) { + tracing::debug!("sidecar alive, mounted, and identity matches; reusing"); + } else { + refuse_if_physical_install(env_dir)?; + enforce_overlay_mismatch_policy( + env_dir, + lock_file_path, + environment_name, + platform, + overlay_mismatch, + )?; + cleanup_stale_state(env_dir, &pid_path)?; + start_sidecar( + env_dir, + workspace_root, + environment_name, + &lock_file, + &pid_path, + ) + .await?; + } + + // Downgrade to a shared lock for the duration of the caller's work. + if unsafe { libc::flock(fd, libc::LOCK_SH) } != 0 { + return Err(miette!( + "failed to downgrade to shared lock: {}", + std::io::Error::last_os_error() + )); + } + + Ok(MountGuard { + lock_file, + lock_path, + }) +} + +/// Start the sidecar mount process. +/// +/// Creates a readiness pipe, forks the sidecar via `pixi mount --managed`, +/// and waits for it to signal readiness. +#[cfg(unix)] +async fn start_sidecar( + env_dir: &Path, + workspace_root: &Path, + environment_name: &str, + _lock_file: &File, + pid_path: &Path, +) -> miette::Result<()> { + // Create a pipe for readiness signaling, with both ends CLOEXEC so neither + // leaks into the sidecar's helper subprocesses (a hung `sudo mount` / + // `fusermount3` holding the write end would defer the readiness EOF and + // stall us for the full timeout). The pre_exec below re-enables inheritance + // of the write end for the sidecar itself, which re-sets CLOEXEC on it + // before spawning any helper. Linux has atomic `pipe2`; elsewhere fall back + // to `pipe` + `fcntl` (a small race window on other Unixes, acceptable). + let mut pipe_fds = [0i32; 2]; + #[cfg(target_os = "linux")] + let pipe_rc = unsafe { libc::pipe2(pipe_fds.as_mut_ptr(), libc::O_CLOEXEC) }; + #[cfg(not(target_os = "linux"))] + let pipe_rc = { + let rc = unsafe { libc::pipe(pipe_fds.as_mut_ptr()) }; + if rc == 0 { + for &fd in &pipe_fds { + unsafe { + let flags = libc::fcntl(fd, libc::F_GETFD); + if flags >= 0 { + libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC); + } + } + } + } + rc + }; + if pipe_rc != 0 { + return Err(miette!( + "failed to create pipe: {}", + std::io::Error::last_os_error() + )); + } + let (read_fd, write_fd) = (pipe_fds[0], pipe_fds[1]); + + // Find the pixi binary + let pixi_exe = std::env::current_exe().into_diagnostic()?; + + let env_dir_str = env_dir.display().to_string(); + let write_fd_str = write_fd.to_string(); + let pid_path_str = pid_path.display().to_string(); + + // Spawn the sidecar as a detached child process. + let mut cmd = std::process::Command::new(&pixi_exe); + cmd.args([ + "mount", + "--managed", + "-e", + environment_name, + "--mount-point", + &env_dir_str, + "--pidfile", + &pid_path_str, + "--ready-fd", + &write_fd_str, + ]) + .current_dir(workspace_root) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(sidecar_log_stdio(env_dir)); + + // Detach the sidecar into its own session, and let it inherit the + // readiness pipe's write end. + unsafe { + use std::os::unix::process::CommandExt; + let fd = write_fd; + cmd.pre_exec(move || { + // setsid() makes the sidecar a session leader with no controlling + // terminal, so a Ctrl-C (SIGINT) or terminal close (SIGHUP) on the + // spawning client's terminal no longer reaches it. Without this, one + // client's Ctrl-C tears down a mount other clients are still using + // (and SIGHUP, which the sidecar does not handle, killed it with no + // unmount at all). setsid() succeeds because the freshly forked + // child is never already a process-group leader. + if libc::setsid() < 0 { + return Err(std::io::Error::last_os_error()); + } + // Clear CLOEXEC on the readiness pipe's write end so the child keeps + // it across exec. + let flags = libc::fcntl(fd, libc::F_GETFD); + if flags < 0 { + return Err(std::io::Error::last_os_error()); + } + if libc::fcntl(fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC) < 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + + let mut child = cmd.spawn().into_diagnostic()?; + + // Close the write end in the parent + unsafe { + libc::close(write_fd); + } + + // Wait for readiness signal from the sidecar + let read_file = unsafe { File::from_raw_fd(read_fd) }; + let mut reader = BufReader::new(read_file); + let mut line = String::new(); + + // Use a timeout to avoid blocking forever if the sidecar fails + let readiness = tokio::task::spawn_blocking(move || reader.read_line(&mut line).map(|_| line)); + + match tokio::time::timeout(std::time::Duration::from_secs(30), readiness).await { + Ok(Ok(Ok(msg))) if msg.starts_with("ready") => { + tracing::debug!("mount sidecar is ready"); + Ok(()) + } + Ok(Ok(Ok(msg))) => { + // The sidecar exited before signaling ready (pipe closed → empty + // read) or reported an error. It is likely already gone, but kill it + // for good measure so we never leak a half-started sidecar. + let _ = child.kill(); + let msg = msg.trim(); + let detail = if msg.is_empty() { + tail_log(env_dir, 4096) + } else { + msg.to_string() + }; + Err(miette!("mount sidecar failed to start: {detail}")) + } + Ok(Ok(Err(e))) => { + let _ = child.kill(); + Err(miette!("failed to read from sidecar pipe: {e}")) + } + Ok(Err(e)) => { + let _ = child.kill(); + Err(miette!("sidecar readiness task failed: {e}")) + } + Err(_) => { + // Timeout — kill the child if still running + let _ = child.kill(); + let log = tail_log(env_dir, 4096); + let hint = if log.is_empty() { + String::new() + } else { + format!("\nsidecar log:\n{log}") + }; + Err(miette!( + "timed out waiting for mount sidecar to become ready{hint}" + )) + } + } +} + +// ─── Windows implementation ───────────────────────────────────────────────── + +/// Check if a path is an active ProjFS virtualization root. +/// +/// ProjFS doesn't create mount points. Instead we check if the sidecar PID +/// file exists and the sidecar is alive. +#[cfg(windows)] +fn is_mountpoint(path: &Path) -> bool { + let (_, pid_path) = coordination_paths(path); + is_sidecar_alive(&pid_path) +} + +/// Ensure a mount is running for the given environment directory (Windows). +/// +/// Uses `LockFileEx` for coordination instead of `flock`. +#[cfg(windows)] +pub async fn ensure_mount( + env_dir: &Path, + workspace_root: &Path, + environment_name: &str, + lock_file_path: &Path, + read_only: bool, + platform: Platform, + overlay_mismatch: pixi_config::OverlayMismatch, +) -> miette::Result { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Foundation::ERROR_LOCK_VIOLATION; + use windows_sys::Win32::Storage::FileSystem::{ + LOCKFILE_EXCLUSIVE_LOCK, LOCKFILE_FAIL_IMMEDIATELY, LockFileEx, UnlockFileEx, + }; + + fs_err::create_dir_all(env_dir).into_diagnostic()?; + + let (lock_path, pid_path) = coordination_paths(env_dir); + let lock_hash = hash_lock_file(lock_file_path); + + let lock_file = OpenOptions::new() + .create(true) + .write(true) + .read(true) + .truncate(false) + .open(&lock_path) + .into_diagnostic()?; + + let handle = lock_file.as_raw_handle(); + let mut overlapped: windows_sys::Win32::System::IO::OVERLAPPED = unsafe { std::mem::zeroed() }; + + // Try exclusive lock (non-blocking) + let got_exclusive = unsafe { + LockFileEx( + handle, + LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY, + 0, + 1, + 0, + &mut overlapped, + ) != 0 + }; + + if got_exclusive { + // We got exclusive lock — no other clients. + if is_reusable(env_dir, &pid_path, &lock_hash, read_only) { + tracing::debug!("sidecar alive, mounted, and identity matches; reusing"); + } else { + refuse_if_physical_install(env_dir)?; + cleanup_stale_state(env_dir, &pid_path)?; + start_sidecar( + env_dir, + workspace_root, + environment_name, + &lock_file, + &pid_path, + ) + .await?; + } + + // Release exclusive lock, then acquire shared lock + unsafe { + overlapped = std::mem::zeroed(); + UnlockFileEx(handle, 0, 1, 0, &mut overlapped); + // Acquire shared (non-exclusive) lock — blocking + overlapped = std::mem::zeroed(); + if LockFileEx(handle, 0, 0, 1, 0, &mut overlapped) == 0 { + return Err(miette!( + "failed to acquire shared lock: {}", + std::io::Error::last_os_error() + )); + } + } + } else { + let err = std::io::Error::last_os_error(); + if err.raw_os_error() == Some(ERROR_LOCK_VIOLATION as i32) { + // Mount is starting or running. Acquire shared lock (blocking). + unsafe { + if LockFileEx(handle, 0, 0, 1, 0, &mut overlapped) == 0 { + return Err(miette!( + "failed to acquire shared lock: {}", + std::io::Error::last_os_error() + )); + } + } + + // Verify the sidecar is reusable (alive, mounted, matching identity) + if !is_reusable(env_dir, &pid_path, &lock_hash, read_only) { + // A healthy mount serving a different environment, in use by + // other clients: refuse rather than tear it out from under them. + if is_sidecar_alive(&pid_path) && is_mounted(env_dir) { + return Err(miette!( + "environment at {} is mounted from a different lock file and is in \ + use by another pixi process. Close any active `pixi shell` / \ + `pixi run` for it (or run `pixi umount`) and retry.", + env_dir.display() + )); + } + unsafe { + overlapped = std::mem::zeroed(); + UnlockFileEx(handle, 0, 1, 0, &mut overlapped); + } + drop(lock_file); + cleanup_stale_state(env_dir, &pid_path)?; + return Box::pin(ensure_mount( + env_dir, + workspace_root, + environment_name, + lock_file_path, + read_only, + platform, + overlay_mismatch, + )) + .await; + } + } else { + return Err(miette!("failed to acquire lock: {err}")); + } + } + + Ok(MountGuard { + lock_file, + lock_path, + }) +} + +/// Start the sidecar mount process (Windows). +/// +/// Uses a named event for readiness signaling instead of a pipe/fd. +#[cfg(windows)] +async fn start_sidecar( + env_dir: &Path, + workspace_root: &Path, + environment_name: &str, + _lock_file: &File, + pid_path: &Path, +) -> miette::Result<()> { + // Use a unique named event for readiness signaling. + // Include a hash of env_dir to prevent collisions when multiple + // environments are mounted concurrently or PIDs are recycled. + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + env_dir.hash(&mut hasher); + let event_name = format!( + "Local\\pixi-mount-ready-{}-{:x}", + std::process::id(), + hasher.finish() + ); + + // Create a named event + use std::ffi::OsStr; + use std::os::windows::ffi::OsStrExt; + let event_name_wide: Vec = OsStr::new(&event_name) + .encode_wide() + .chain(Some(0)) + .collect(); + + let event_handle = unsafe { + windows_sys::Win32::System::Threading::CreateEventW( + std::ptr::null(), + 1, // manual reset + 0, // initial state: not signaled + event_name_wide.as_ptr(), + ) + }; + if event_handle.is_null() { + return Err(miette!( + "failed to create readiness event: {}", + std::io::Error::last_os_error() + )); + } + + let pixi_exe = std::env::current_exe().into_diagnostic()?; + let env_dir_str = env_dir.display().to_string(); + let pid_path_str = pid_path.display().to_string(); + + let mut cmd = std::process::Command::new(&pixi_exe); + cmd.args([ + "mount", + "--managed", + "-e", + environment_name, + "--mount-point", + &env_dir_str, + "--pidfile", + &pid_path_str, + "--ready-event", + &event_name, + ]) + .current_dir(workspace_root) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(sidecar_log_stdio(env_dir)); + + // Detach the sidecar from the spawning client's console and process group, + // the Windows analogue of setsid() on Unix: a Ctrl-C or console close on the + // parent must not tear down a mount other clients still use. Review-only: + // not exercised on the Linux CI here. + { + use std::os::windows::process::CommandExt; + use windows_sys::Win32::System::Threading::{CREATE_NEW_PROCESS_GROUP, DETACHED_PROCESS}; + cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP); + } + + let mut child = cmd.spawn().into_diagnostic()?; + + // Wait for readiness event with timeout. + // Cast to isize for Send safety — Windows HANDLEs are just kernel object + // pointers and are safe to use from any thread. + let event_handle_isize = event_handle as isize; + let wait_result = tokio::task::spawn_blocking(move || unsafe { + let h = event_handle_isize as windows_sys::Win32::Foundation::HANDLE; + let result = windows_sys::Win32::System::Threading::WaitForSingleObject(h, 30_000); + windows_sys::Win32::Foundation::CloseHandle(h); + result + }) + .await + .into_diagnostic()?; + + use windows_sys::Win32::Foundation::{WAIT_ABANDONED, WAIT_OBJECT_0, WAIT_TIMEOUT}; + + match wait_result { + WAIT_OBJECT_0 => { + tracing::debug!("mount sidecar is ready"); + Ok(()) + } + WAIT_TIMEOUT => { + let _ = child.kill(); + Err(miette!( + "timed out waiting for mount sidecar to become ready" + )) + } + WAIT_ABANDONED => { + let _ = child.kill(); + Err(miette!( + "sidecar readiness event was abandoned (sidecar may have crashed)" + )) + } + other => { + let _ = child.kill(); + Err(miette!( + "WaitForSingleObject returned unexpected status: 0x{:08x}", + other + )) + } + } +} + +// ─── Platform-independent code ────────────────────────────────────────────── + +/// Best-effort terminate a process by PID — SIGTERM (or SIGKILL when `force`) +/// on Unix, `TerminateProcess` on Windows. Callers MUST have already confirmed +/// the PID's start time matches the recorded sidecar, so this never kills a +/// recycled PID. +fn terminate_process(pid: u32, force: bool) { + #[cfg(unix)] + unsafe { + let sig = if force { libc::SIGKILL } else { libc::SIGTERM }; + libc::kill(pid as libc::pid_t, sig); + } + #[cfg(windows)] + unsafe { + let _ = force; + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::System::Threading::{ + OpenProcess, PROCESS_TERMINATE, TerminateProcess, + }; + let handle = OpenProcess(PROCESS_TERMINATE, 0, pid); + if !handle.is_null() { + TerminateProcess(handle, 1); + CloseHandle(handle); + } + } +} + +/// Clean up stale state left by a previous sidecar, so a fresh mount can start. +/// +/// MUST be called while holding the exclusive coordination lock: it +/// force-unmounts and terminates processes, which would race a concurrent +/// client's fresh mount if done unlocked. +fn cleanup_stale_state(env_dir: &Path, pid_path: &Path) -> miette::Result<()> { + // If a sidecar process is still alive at this point, terminate it — the + // ensure-mount recovery path reaches here only for an unhealthy mount, and + // `pixi umount` reaches here to stop a healthy one. Terminating it (which is + // start-time-verified, so never a recycled PID) makes it unmount and release + // the overlay lock; otherwise a respawn would block on that lock and hit the + // readiness timeout. Bounded SIGTERM → SIGKILL escalation. + if let Some(state) = read_sidecar_state(pid_path) + && live_process_start_time(state.pid) == Some(state.start_time) + { + tracing::debug!("terminating unhealthy sidecar pid {}", state.pid); + terminate_process(state.pid, false); + let mut exited = false; + for i in 0..30 { + if live_process_start_time(state.pid) != Some(state.start_time) { + exited = true; + break; + } + if i == 20 { + // Ignored SIGTERM for ~2s — escalate. + terminate_process(state.pid, true); + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + if !exited { + tracing::warn!("sidecar pid {} did not exit after SIGKILL", state.pid); + } + } + + // Remove the (now stale) pidfile and log file. + if pid_path.exists() { + tracing::debug!("cleaning up stale pidfile at {}", pid_path.display()); + let _ = fs_err::remove_file(pid_path); + } + let _ = fs_err::remove_file(log_path(env_dir)); + + // Force-unmount any mount the dead/killed sidecar left behind. + if is_mountpoint(env_dir) { + tracing::debug!("force-unmounting stale mount at {}", env_dir.display()); + force_unmount(env_dir)?; + } + + Ok(()) +} + +/// Map the transport name recorded in the sidecar state back to a +/// [`rattler_fs::Transport`]. +fn transport_from_name(name: &str) -> rattler_fs::Transport { + match name { + "fuse" => rattler_fs::Transport::Fuse, + "nfs" => rattler_fs::Transport::Nfs, + "projfs" => rattler_fs::Transport::ProjFs, + _ => rattler_fs::Transport::Auto, + } +} + +/// Force-unmount a mount point using the transport the sidecar recorded +/// (falling back to the platform default). Best-effort backstop for a stale +/// mount left by a crashed or killed sidecar. +/// +/// Delegates to the transport-aware [`rattler_fs::force_unmount`] instead of the +/// old FUSE-only `fusermount3` shell-out, which silently failed on Linux NFS +/// mounts. On Windows, ProjFS virtualization stops when the owning sidecar +/// process exits, so terminating the sidecar (the caller's job) is the teardown; +/// there is nothing to unmount here. +pub fn force_unmount(mount_point: &Path) -> miette::Result<()> { + let (_, pid_path) = coordination_paths(mount_point); + let transport = read_sidecar_state(&pid_path) + .map(|s| transport_from_name(&s.transport)) + .unwrap_or(rattler_fs::Transport::Auto); + + #[cfg(unix)] + { + rattler_fs::force_unmount(mount_point, transport) + .map_err(|e| miette!("failed to force-unmount {}: {e}", mount_point.display())) + } + #[cfg(windows)] + { + let _ = transport; + Ok(()) + } +} + +/// Check if a mount is currently active for the given environment. +pub fn is_mounted(env_dir: &Path) -> bool { + is_mountpoint(env_dir) +} + +/// Whether `env_dir` holds a physical (link-backend) conda install rather than +/// being empty and ready to mount. A live or stale mount is *not* a physical +/// install — those are handled by cleanup — so this is false while mounted; it +/// only detects a real on-disk install that a mount would silently shadow. +fn has_physical_install(env_dir: &Path) -> bool { + !is_mounted(env_dir) && env_dir.join("conda-meta").exists() +} + +/// Refuse to mount over a pre-existing link-backend install: the mount would +/// hide it (and it would reappear on unmount), wasting space and confusing +/// anything that inspects the directory. Point the user at removing it. +fn refuse_if_physical_install(env_dir: &Path) -> miette::Result<()> { + if has_physical_install(env_dir) { + return Err(miette!( + "environment at {} already has a link-backend (physical) install, which \ + the mount would silently hide. Remove it first (e.g. `rm -rf {}`), or keep \ + `experimental.environment-backend = \"link\"` for this environment.", + env_dir.display(), + env_dir.display() + )); + } + Ok(()) +} + +/// Whether another pixi process currently holds the coordination lock for this +/// mount (i.e. a `pixi run`/`pixi shell` is actively using it). Used by +/// `pixi umount` to refuse a disruptive unmount unless `--force` is given. +/// +/// This is a point-in-time probe: it can momentarily read `true` while the +/// sidecar holds its per-second probe lock, so treat it as advisory. +pub fn clients_attached(mount_point: &Path) -> bool { + let (lock_path, _) = coordination_paths(mount_point); + let Ok(file) = OpenOptions::new().read(true).write(true).open(&lock_path) else { + return false; // no lock file → nothing attached + }; + #[cfg(unix)] + { + use std::os::unix::io::AsRawFd; + let fd = file.as_raw_fd(); + if unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) } == 0 { + unsafe { libc::flock(fd, libc::LOCK_UN) }; + false + } else { + true + } + } + #[cfg(windows)] + { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{ + LOCKFILE_EXCLUSIVE_LOCK, LOCKFILE_FAIL_IMMEDIATELY, LockFileEx, UnlockFileEx, + }; + let handle = file.as_raw_handle(); + let mut ov: windows_sys::Win32::System::IO::OVERLAPPED = unsafe { std::mem::zeroed() }; + if unsafe { + LockFileEx( + handle, + LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY, + 0, + 1, + 0, + &mut ov, + ) != 0 + } { + unsafe { UnlockFileEx(handle, 0, 1, 0, &mut ov) }; + false + } else { + true + } + } +} + +/// Tear down the sidecar and mount at `mount_point` for `pixi umount`: +/// terminate the sidecar (start-time-verified, SIGTERM → SIGKILL), remove the +/// coordination record, and force-unmount any residue. Returns whether anything +/// was actually torn down (so the caller can report accurately). Shares the +/// implementation with stale-state recovery. +pub fn teardown_mount(mount_point: &Path) -> miette::Result { + let (_, pid_path) = coordination_paths(mount_point); + let was_active = is_sidecar_alive(&pid_path) || is_mounted(mount_point); + cleanup_stale_state(mount_point, &pid_path)?; + Ok(was_active) +} + +#[cfg(test)] +mod tests { + use super::*; + use fs_err as fs; + use tempfile::TempDir; + + #[test] + fn test_coordination_paths() { + let mount_point = PathBuf::from("/tmp/envs/default"); + let (lock_path, pid_path) = coordination_paths(&mount_point); + assert_eq!( + lock_path, + PathBuf::from("/tmp/envs/default.rattler-fs.lock") + ); + assert_eq!(pid_path, PathBuf::from("/tmp/envs/default.rattler-fs.pid")); + } + + #[test] + fn test_is_sidecar_alive_nonexistent_pid() { + let tmp = TempDir::new().unwrap(); + let pid_path = tmp.path().join("test.pid"); + assert!(!is_sidecar_alive(&pid_path)); + } + + #[test] + fn test_is_sidecar_alive_invalid_pid() { + let tmp = TempDir::new().unwrap(); + let pid_path = tmp.path().join("test.pid"); + fs::write(&pid_path, "not_a_number\n").unwrap(); + assert!(!is_sidecar_alive(&pid_path)); + } + + fn fake_state(pid: u32, start_time: u64) -> SidecarState { + SidecarState { + pid, + start_time, + env_hash: "sha256:test".to_string(), + lock_hash: "deadbeef".to_string(), + transport: "fuse".to_string(), + read_only: true, + mount_point: PathBuf::from("/tmp/env"), + } + } + + #[test] + fn test_hash_lock_file_changes_with_content() { + let tmp = TempDir::new().unwrap(); + let lock = tmp.path().join("pixi.lock"); + fs::write(&lock, b"version: 6\npackages: []\n").unwrap(); + let h1 = hash_lock_file(&lock); + assert!(!h1.is_empty()); + // Same content → same hash (client and sidecar agree). + assert_eq!(h1, hash_lock_file(&lock)); + // Changed content → different hash (reuse is invalidated). + fs::write(&lock, b"version: 6\npackages: [changed]\n").unwrap(); + assert_ne!(h1, hash_lock_file(&lock)); + // Missing file → empty (forces a conservative respawn). + assert_eq!(hash_lock_file(&tmp.path().join("absent.lock")), ""); + } + + #[test] + fn test_is_sidecar_alive_dead_pid() { + let tmp = TempDir::new().unwrap(); + let pid_path = tmp.path().join("test.pid"); + // A valid record whose PID (almost certainly) does not exist. + write_sidecar_state(&pid_path, &fake_state(99_999_999, 1)).unwrap(); + assert!(!is_sidecar_alive(&pid_path)); + } + + #[test] + fn test_is_sidecar_alive_current_process() { + let tmp = TempDir::new().unwrap(); + let pid_path = tmp.path().join("test.pid"); + // Records the current process with its real start time. + write_current_sidecar_state(&pid_path, "sha256:h", tmp.path(), "fuse", true, tmp.path()) + .unwrap(); + assert!(is_sidecar_alive(&pid_path)); + } + + #[test] + fn test_is_sidecar_alive_rejects_recycled_pid() { + // Same (live) PID but a start time that does not match must NOT count as + // alive — this is the PID-recycling / stale-post-reboot defense (#6). + let tmp = TempDir::new().unwrap(); + let pid_path = tmp.path().join("test.pid"); + let live_pid = std::process::id(); + let real_start = live_process_start_time(live_pid).expect("own process is live"); + write_sidecar_state(&pid_path, &fake_state(live_pid, real_start.wrapping_add(1))).unwrap(); + assert!(!is_sidecar_alive(&pid_path)); + } + + #[test] + fn test_legacy_bare_pid_is_treated_as_stale() { + // An old bare-PID pidfile (pre-record format) is not valid JSON, so it + // reads as "no sidecar" and gets cleaned up rather than trusted. + let tmp = TempDir::new().unwrap(); + let pid_path = tmp.path().join("test.pid"); + let pid = std::process::id(); + fs::write(&pid_path, format!("{pid}\n")).unwrap(); + assert!(read_sidecar_state(&pid_path).is_none()); + assert!(!is_sidecar_alive(&pid_path)); + } + + #[test] + fn test_cleanup_stale_state_removes_dead_pidfile() { + let tmp = TempDir::new().unwrap(); + let env_dir = tmp.path().join("env"); + fs::create_dir_all(&env_dir).unwrap(); + let pid_path = env_dir.join(PID_FILENAME); + fs::write(&pid_path, "99999999\n").unwrap(); + + cleanup_stale_state(&env_dir, &pid_path).unwrap(); + assert!(!pid_path.exists()); + } + + #[test] + fn test_overlay_dir() { + let env_dir = PathBuf::from("/tmp/test-env"); + assert_eq!( + MountGuard::overlay_dir(&env_dir), + PathBuf::from("/tmp/test-env.rattler-fs-overlay") + ); + } + + #[cfg(unix)] + #[test] + fn test_is_mountpoint_regular_dir() { + let tmp = TempDir::new().unwrap(); + // A regular directory is not a mount point. + assert!(!is_mountpoint(tmp.path())); + } + + #[cfg(unix)] + #[test] + fn test_has_physical_install() { + let tmp = TempDir::new().unwrap(); + let env = tmp.path().join("env"); + fs::create_dir_all(&env).unwrap(); + // Empty (fresh) env dir → not a physical install → mounting is allowed. + assert!(!has_physical_install(&env)); + assert!(refuse_if_physical_install(&env).is_ok()); + // A conda-meta directory marks a physical (link-backend) install. + fs::create_dir_all(env.join("conda-meta")).unwrap(); + assert!(has_physical_install(&env)); + assert!(refuse_if_physical_install(&env).is_err()); + } + + #[cfg(target_os = "linux")] + #[test] + fn test_mountinfo_contains() { + // Two real-ish mountinfo lines; index 4 is the mount point, and one of + // them uses the `\040` octal escape for a space. + let sample = "\ +36 35 98:0 / / rw,relatime shared:1 - ext4 /dev/root rw +41 36 0:33 / /home/user/my\\040env rw,relatime shared:2 - fuse conda-packages rw +42 36 0:34 / /mnt/other rw - nfs server:/ rw +"; + assert!(mountinfo_contains(sample, Path::new("/"))); + assert!(mountinfo_contains( + sample, + Path::new("/home/user/my env") // unescaped space + )); + assert!(mountinfo_contains(sample, Path::new("/mnt/other/"))); // trailing slash tolerated + assert!(!mountinfo_contains(sample, Path::new("/not/mounted"))); + } +} diff --git a/crates/pixi_core/src/lock_file/update.rs b/crates/pixi_core/src/lock_file/update.rs index 57c3e293db..c617ae0874 100644 --- a/crates/pixi_core/src/lock_file/update.rs +++ b/crates/pixi_core/src/lock_file/update.rs @@ -610,6 +610,11 @@ pub struct LockFileDerivedData<'p> { /// [`Self::resolver`] and reused across all downstream consumers. Kept /// private so all interaction goes through the accessor method. resolver: once_cell::sync::OnceCell>, + + /// Active mount guards for environments using the "mount" backend. + /// Kept alive so the shared flock persists until this struct is dropped. + #[cfg(unix)] + pub mount_guards: DashMap, } /// The mode to use when updating a prefix. @@ -650,6 +655,8 @@ impl<'p> LockFileDerivedData<'p> { glob_hash_cache, build_caches: Default::default(), resolver: Default::default(), + #[cfg(unix)] + mount_guards: Default::default(), } } @@ -781,6 +788,29 @@ impl<'p> LockFileDerivedData<'p> { if update_mode == UpdateMode::QuickValidate && let Some(prefix) = self.cached_prefix(environment, &hash) { + // Even when the prefix is cached, we still need to ensure the mount + // is active and acquire a shared lock so the sidecar isn't killed + // while we're using it. + #[cfg(unix)] + if self.workspace.config().environment_backend() + == pixi_config::EnvironmentBackend::Mount + { + let env_dir = environment.dir(); + let guard = crate::environment::mount_sidecar::ensure_mount( + &env_dir, + self.workspace.root(), + environment.name().as_str(), + &self.workspace.lock_file_path(), + self.workspace.config().mount_read_only(), + environment + .best_declared_platform() + .map(|p| p.subdir()) + .unwrap_or_else(Platform::current), + self.workspace.config().mount_overlay_mismatch(), + ) + .await?; + self.mount_guards.insert(environment.name().clone(), guard); + } return prefix; } @@ -941,6 +971,56 @@ impl<'p> LockFileDerivedData<'p> { ))?; } + // If using mount backend, mount the environment instead of + // linking packages. + #[cfg(unix)] + if self.workspace.config().environment_backend() + == pixi_config::EnvironmentBackend::Mount + { + let env_dir = environment.dir(); + let guard = crate::environment::mount_sidecar::ensure_mount( + &env_dir, + self.workspace.root(), + environment.name().as_str(), + &self.workspace.lock_file_path(), + self.workspace.config().mount_read_only(), + environment + .best_declared_platform() + .map(|p| p.subdir()) + .unwrap_or_else(Platform::current), + self.workspace.config().mount_overlay_mismatch(), + ) + .await?; + + // Store the guard so it lives as long as this LockFileDerivedData + self.mount_guards + .insert(environment.name().clone(), guard); + + tracing::info!( + "environment '{}' mounted at {}", + environment.name().fancy_display(), + env_dir.display() + ); + return Ok(UpdatedPrefix { + prefix: Prefix::new(env_dir), + }); + } + + // Refuse to link-install into an environment that is currently + // mounted by the mount backend: the hardlink writes would go + // through the mount into the overlay (or hit EROFS), not produce a + // real on-disk install. Tell the user to unmount first. + #[cfg(unix)] + if crate::environment::mount_sidecar::is_mounted(&environment.dir()) { + miette::bail!( + "environment '{}' is currently mounted by the mount backend; \ + unmount it first with `pixi umount -e {}` before installing with \ + the link backend.", + environment.name().fancy_display(), + environment.name().as_str() + ); + } + let platform = best_declared_platform; let locked_env = self.locked_env(environment)?; let subset = InstallSubset::new( @@ -2684,6 +2764,8 @@ impl<'p> UpdateContext<'p> { glob_hash_cache: self.glob_hash_cache, build_caches: self.outdated_envs.build_caches, resolver: Default::default(), + #[cfg(unix)] + mount_guards: Default::default(), }) } }