From 2f631e62ee54f2ad49e5a5ab0bd416e415bc6fdf Mon Sep 17 00:00:00 2001 From: jj Date: Mon, 7 Sep 2026 22:25:15 +0200 Subject: [PATCH 1/2] worker: keep directory construction retries on the same lock After a directory construction fails, another caller may already be waiting on its lock. Removing that lock from the map lets a new caller create a different lock and construct the same cache path concurrently with the waiting retry. Keep the lock mapped while other callers hold references to it. Remove it only when the map and the finishing caller are its sole owners, with the map locked to prevent new callers from racing the reference check. Add a regression using the existing get_or_create API that verifies a new request waits for the retry's lock and cleanup removes the lock after the final caller finishes. --- nativelink-worker/src/directory_cache.rs | 76 +++++++++++++++++++----- 1 file changed, 60 insertions(+), 16 deletions(-) diff --git a/nativelink-worker/src/directory_cache.rs b/nativelink-worker/src/directory_cache.rs index 259e74810..41f61aa69 100644 --- a/nativelink-worker/src/directory_cache.rs +++ b/nativelink-worker/src/directory_cache.rs @@ -491,10 +491,8 @@ impl DirectoryCache { let _guard = construction_lock.lock().await; // Run the construction/materialization under the per-digest guard, - // then drop the per-digest mutex from the stampede map regardless of - // outcome so it cannot grow unbounded. The guard (`_guard`) is still - // held until the end of this function — `forget_construction_lock` - // only unmaps the Arc; any waiter already cloned it before blocking. + // then remove its lock from the map if no other callers are waiting. + // Waiters must keep sharing this flight even if construction failed. let result = self .construct_and_materialize(digest, dest_path, protos, prefetch_on_miss, lease) .await; @@ -548,8 +546,8 @@ impl DirectoryCache { } /// The cache-miss body, run while holding the per-digest construction - /// guard. Split out so `get_or_create_entry` can unconditionally clean up - /// the construction-lock map entry afterwards on every exit path. + /// guard. Split out so `get_or_create_entry` can retire an unused + /// construction lock after either success or failure. /// `protos` and `prefetch_on_miss` are documented on /// [`Self::get_or_create_entry`]. async fn construct_and_materialize( @@ -784,17 +782,20 @@ impl DirectoryCache { )) } - /// Drops the per-digest construction mutex from the stampede map once - /// construction (or the post-construction recheck) for `digest` is done. - /// Without this the map grows unbounded over the worker's lifetime. - /// - /// Safe to call while holding the construction guard: a concurrent waiter - /// already cloned the `Arc` before blocking, so removing the map - /// entry only prevents *future* callers from joining this exact mutex — - /// they will create a fresh one, re-check the cache, find the entry, and - /// take the fast hardlink path. It never causes a redundant construct. + /// Removes an idle construction lock after the last caller finishes. + /// Keep it mapped while another caller owns an `Arc`: after a failed + /// construction that caller may retry before a cache entry exists, and + /// giving new callers a different mutex would allow concurrent publication + /// to the same path. The map lock prevents new clones during this check. async fn forget_construction_lock(&self, digest: &DigestInfo) { - self.construction_locks.lock().await.remove(digest); + let mut locks = self.construction_locks.lock().await; + // The map and this function's caller each own one strong reference. + if locks + .get(digest) + .is_some_and(|lock| Arc::strong_count(lock) == 2) + { + locks.remove(digest); + } } /// Constructs a directory from the CAS at the given path and returns the @@ -2315,4 +2316,47 @@ mod tests { assert!(dest.join("test.txt").exists()); Ok(()) } + + #[nativelink_test] + async fn queued_construction_retry_excludes_new_requests() -> Result<(), Error> { + let temp_dir = TempDir::new().unwrap(); + let (store, digest) = setup_test_store(&temp_dir).await; + let cache = DirectoryCache::new( + DirectoryCacheConfig { + cache_root: temp_dir.path().join("cache"), + ..Default::default() + }, + store, + ) + .await?; + // Reproduce a failed constructor handing its lock to a waiting retry. + // No entry has been published yet, so new requests must join + // that same flight rather than race to replace the same cache path. + let constructor = Arc::new(Mutex::new(())); + cache + .construction_locks + .lock() + .await + .insert(digest, constructor.clone()); + let retry = constructor.clone(); + let retry_guard = retry.lock().await; + cache.forget_construction_lock(&digest).await; + drop(constructor); + + let dest = temp_dir.path().join("dest"); + let request = cache.get_or_create(digest, &dest); + tokio::pin!(request); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(200), request.as_mut()) + .await + .is_err(), + "a new request bypassed an existing construction retry" + ); + drop(retry_guard); + drop(retry); + assert!(!request.await?); + assert!(dest.join("test.txt").exists()); + assert!(cache.construction_locks.lock().await.is_empty()); + Ok(()) + } } From 98b2a357cefa301bbfdcd06204a8edb1ca7ede3f Mon Sep 17 00:00:00 2001 From: jj Date: Mon, 7 Sep 2026 22:25:21 +0200 Subject: [PATCH 2/2] worker: add option to mount cached input directories as read-only Chromium builds for macOS use the same Mac SDK across thousands of compile jobs. The directory cache already shares the prepared SDK, but each job still recreates its directories and links, then deletes them afterward. Add experimental_readonly_input_mounts so Linux workers can mount selected cached directories read-only into each job instead. This avoids repeated filesystem work for large, unchanged inputs such as SDKs. The option is disabled by default. Mounted directories stay in the cache until job cleanup finishes, and incompatible jobs use normal input setup. --- .../vocabularies/TraceMachina/accept.txt | 1 + nativelink-config/src/cas_server.rs | 24 ++ nativelink-worker/BUILD.bazel | 1 + nativelink-worker/src/directory_cache.rs | 167 +++++++++-- nativelink-worker/src/input_mounts.rs | 192 +++++++++++++ nativelink-worker/src/lib.rs | 1 + nativelink-worker/src/local_worker.rs | 10 + nativelink-worker/src/namespace_utils.rs | 115 +++++++- .../src/running_actions_manager.rs | 166 +++++++++-- .../tests/directory_cache_test.rs | 175 ++++++++++++ .../tests/namespace_utils_test.rs | 74 +++++ .../tests/running_actions_manager_test.rs | 268 ++++++++++++++++++ .../docs/explanations/architecture.mdx | 3 + .../docs/reference/nativelink-config/main.mdx | 3 +- 14 files changed, 1145 insertions(+), 55 deletions(-) create mode 100644 nativelink-worker/src/input_mounts.rs diff --git a/.github/styles/config/vocabularies/TraceMachina/accept.txt b/.github/styles/config/vocabularies/TraceMachina/accept.txt index 377d537d5..afb44886d 100644 --- a/.github/styles/config/vocabularies/TraceMachina/accept.txt +++ b/.github/styles/config/vocabularies/TraceMachina/accept.txt @@ -316,6 +316,7 @@ deserialization Dockerised Merkle subtree +subtrees hardlink multiplicatively SELinux diff --git a/nativelink-config/src/cas_server.rs b/nativelink-config/src/cas_server.rs index 1131699a2..3367af693 100644 --- a/nativelink-config/src/cas_server.rs +++ b/nativelink-config/src/cas_server.rs @@ -1048,6 +1048,30 @@ pub struct LocalWorkerConfig { #[serde(default)] pub experimental_active_input_leases: bool, + /// Reuse immutable input directories through read-only bind mounts on Linux. + /// The worker prepares each selected subtree once per REAPI `Directory` + /// digest in the directory cache, then mounts it into each action instead of + /// recreating its filesystem entries. + /// + /// Paths are relative to the action input root, for example `["out/x/sdk"]`. + /// They must be nonempty, canonical, and non-overlapping. Absolute paths, + /// empty path components, and `.`/`..` components are rejected. + /// Requires `directory_cache`, `use_namespaces: true`, and + /// `use_mount_namespace: true`. + /// + /// Actions declaring overlapping outputs, working inside a selected subtree, + /// or using a persistent worker fall back to ordinary materialization. + /// Output paths reached through symlink aliases cannot be checked for + /// overlap. Use this only with trusted workloads that do not modify the + /// selected trees; read-only input mounts are not a security boundary. + /// + /// Cache entries remain pinned until action cleanup and may temporarily + /// exceed the cache's eviction budget. Leave headroom on the underlying disk. + /// + /// Default: [] (disabled) + #[serde(default)] + pub experimental_readonly_input_mounts: Vec, + /// Whether to use namespaces to isolate the execution. This is only available /// on Linux. It is highly recommended as it avoids a number of issues with /// zombie processes and also provides additional hermeticity. If explicitly set diff --git a/nativelink-worker/BUILD.bazel b/nativelink-worker/BUILD.bazel index 899b84cf4..5c93167fc 100644 --- a/nativelink-worker/BUILD.bazel +++ b/nativelink-worker/BUILD.bazel @@ -11,6 +11,7 @@ rust_library( name = "nativelink-worker", srcs = [ "src/directory_cache.rs", + "src/input_mounts.rs", "src/lib.rs", "src/local_worker.rs", "src/persistent_worker/live_worker.rs", diff --git a/nativelink-worker/src/directory_cache.rs b/nativelink-worker/src/directory_cache.rs index 41f61aa69..2ce21cbfc 100644 --- a/nativelink-worker/src/directory_cache.rs +++ b/nativelink-worker/src/directory_cache.rs @@ -171,6 +171,22 @@ impl Drop for EntryPin { } } +/// A prepared tree held against eviction for the lifetime of this handle. +/// A cache hit can return this handle without walking or recreating the tree. +/// Consumers must expose the prepared tree read-only. +#[derive(Debug)] +pub struct PreparedDirectory { + path: PathBuf, + size: u64, + pin: EntryPin, +} + +impl PreparedDirectory { + pub fn path(&self) -> &Path { + &self.path + } +} + /// Drop guard for an in-progress temp construction tree: if the owning /// future is dropped before [`Self::disarm`] (cancellation — e.g. /// `buffer_unordered` drops sibling constructions on the first error), the @@ -441,6 +457,35 @@ impl DirectoryCache { Ok(hit) } + /// Acquires a reusable tree without copying it to an action workspace. + /// The returned handle must outlive every mount or other use of its path. + pub async fn prepare_for_mount( + &self, + digest: DigestInfo, + lease: Option<&dyn DigestLease>, + ) -> Result { + self.maybe_log_summary(); + acquire_digest(lease, &digest); + if let Some(prepared) = self.acquire_entry(&digest).await { + return Ok(prepared); + } + let lock = { + let mut locks = self.construction_locks.lock().await; + locks + .entry(digest) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone() + }; + let _guard = lock.lock().await; + let result = if let Some(prepared) = self.acquire_entry(&digest).await { + Ok(prepared) + } else { + self.construct_entry(digest, None, true, lease).await + }; + self.forget_construction_lock(&digest).await; + result + } + /// Core get-or-create flow shared by root materializations /// ([`Self::get_or_create`]) and, with `experimental_subtree_caching` /// enabled, subtree materializations (`create_subdirectory`). Returns @@ -466,7 +511,7 @@ impl DirectoryCache { acquire_digest(lease, &digest); // Fast path: serve from an existing entry. - if let Some(size) = self.try_materialize_from_cache(&digest, dest_path).await { + if let Some(size) = self.try_materialize_from_cache(&digest, dest_path).await? { return Ok((true, size)); } @@ -514,33 +559,44 @@ impl DirectoryCache { /// case the dead entry has been invalidated (removed + tombstoned, so it /// cannot fail every future request forever) and the possibly partially /// populated destination cleared, so the caller can construct fresh. + /// If another caller still holds the entry, return the error without + /// invalidating it: that caller may be using its tree through a mount. async fn try_materialize_from_cache( &self, digest: &DigestInfo, dest_path: &Path, - ) -> Option { - let (cache_path, size, pin) = self.acquire_entry(digest).await?; + ) -> Result, Error> { + let Some(PreparedDirectory { + path: cache_path, + size, + pin, + }) = self.acquire_entry(digest).await + else { + return Ok(None); + }; debug!(?digest, ?cache_path, "Directory cache HIT"); match hardlink_directory_tree(&cache_path, dest_path).await { Ok(method) => { drop(pin); self.record_clone_method(method); - Some(size) + Ok(Some(size)) } Err(e) => { + if !self.invalidate_entry(digest, &pin).await { + return Err(e).err_tip(|| "Cannot rebuild a directory while it is in use"); + } warn!( ?digest, error = ?e, "Failed to hardlink from cache, invalidating entry and reconstructing" ); - self.invalidate_entry(digest, &pin).await; drop(pin); // The failed walk may have partially populated the // destination; clear it so the reconstruction starts clean // (`hardlink_directory_tree` refuses an existing destination // and `create_file` fails on leftovers). Self::remove_tree_best_effort(dest_path).await; - None + Ok(None) } } } @@ -562,10 +618,28 @@ impl DirectoryCache { // we waited on the construction lock. If the entry turns out to be // damaged it is invalidated and we fall through to rebuild it fresh // (exactly once — we hold the construction lock). - if let Some(size) = self.try_materialize_from_cache(&digest, dest_path).await { + if let Some(size) = self.try_materialize_from_cache(&digest, dest_path).await? { return Ok((true, size)); } + let prepared = self + .construct_entry(digest, protos, prefetch_on_miss, lease) + .await?; + let method = hardlink_directory_tree(prepared.path(), dest_path) + .await + .err_tip(|| "Failed to hardlink newly cached directory")?; + self.record_clone_method(method); + Ok((false, prepared.size)) + } + + /// Constructs and publishes a pinned entry, without copying it elsewhere. + async fn construct_entry( + &self, + digest: DigestInfo, + protos: Option<&HashMap>, + prefetch_on_miss: bool, + lease: Option<&dyn DigestLease>, + ) -> Result { // Construct the directory into a UNIQUE temp path first, then // atomically publish it to the canonical `cache_root/` path // via rename. Constructing at the canonical path directly would @@ -630,11 +704,9 @@ impl DirectoryCache { // the expensive filesystem deletion is dispatched off the lock so // eviction I/O never serializes other callers. // - // The new entry is inserted pre-pinned. The hardlink-to-destination - // below runs unlocked, and a concurrent caller for an unrelated - // digest could otherwise pick this brand-new entry as an eviction - // victim and delete its tree mid-hardlink. Dropping the pin releases - // it once the hardlink is done. + // Publish already pinned: a concurrent insertion must not evict the + // tree before its caller has finished copying or mounting it. The + // returned handle keeps the pin alive for the entire use. let ref_count = Arc::new(AtomicUsize::new(1)); let pin = EntryPin { ref_count: Arc::clone(&ref_count), @@ -664,14 +736,11 @@ impl DirectoryCache { }; Self::dispatch_evictions(tombstones); - // Hardlink to destination (unlocked). The entry is pinned so it - // cannot be evicted from under this hardlink. - let result = hardlink_directory_tree(&cache_path, dest_path).await; - drop(pin); - let method = result.err_tip(|| "Failed to hardlink newly cached directory")?; - self.record_clone_method(method); - - Ok((false, size)) + Ok(PreparedDirectory { + path: cache_path, + size, + pin, + }) } /// Removes a cache entry whose on-disk tree failed to materialize @@ -684,17 +753,24 @@ impl DirectoryCache { /// attempt: invalidation is skipped if the map now holds a DIFFERENT /// entry for this digest (single-flight already rebuilt it), identified /// by refcount-handle pointer identity. - async fn invalidate_entry(&self, digest: &DigestInfo, pin: &EntryPin) { + /// Returns `false` if another caller still pins the entry. + async fn invalidate_entry(&self, digest: &DigestInfo, pin: &EntryPin) -> bool { let tombstones = { let mut cache = self.cache.write().await; let is_same_entry = cache .get(digest) .is_some_and(|m| Arc::ptr_eq(&m.ref_count, &pin.ref_count)); if !is_same_entry { - return; + return true; + } + // The failed materialization owns one pin. Other pins may belong + // to mounted actions, whose source must not be renamed or deleted. + // Holding the write lock prevents new pins during this check. + if pin.ref_count.load(Ordering::SeqCst) > 1 { + return false; } let Some(metadata) = cache.remove(digest) else { - return; + return true; }; self.map_entries.fetch_sub(1, Ordering::Relaxed); self.map_size_bytes @@ -702,6 +778,7 @@ impl DirectoryCache { self.tombstone_victims(vec![metadata.path]).await }; Self::dispatch_evictions(tombstones); + true } /// Best-effort `remove_dir_all` that treats `NotFound` as success and @@ -760,26 +837,26 @@ impl DirectoryCache { } /// If `digest` is cached, pins it against eviction and returns a - /// snapshot of its on-disk path, its recorded size, and the RAII pin. + /// handle holding its on-disk path, recorded size, and the RAII pin. /// Runs under the cache READ lock — the refcount and LRU timestamp are /// atomics, so hits do not serialize on the write lock. The increment is /// race-free against eviction because eviction requires the write lock, /// which excludes every read-lock holder: an entry with an outstanding /// pin is always observed with a non-zero count by `evict_lru`. - async fn acquire_entry(&self, digest: &DigestInfo) -> Option<(PathBuf, u64, EntryPin)> { + async fn acquire_entry(&self, digest: &DigestInfo) -> Option { let cache = self.cache.read().await; let metadata = cache.get(digest)?; metadata .last_access .store(unix_nanos_now(), Ordering::Relaxed); metadata.ref_count.fetch_add(1, Ordering::SeqCst); - Some(( - metadata.path.clone(), - metadata.size, - EntryPin { + Some(PreparedDirectory { + path: metadata.path.clone(), + size: metadata.size, + pin: EntryPin { ref_count: Arc::clone(&metadata.ref_count), }, - )) + }) } /// Removes an idle construction lock after the last caller finishes. @@ -2359,4 +2436,34 @@ mod tests { assert!(cache.construction_locks.lock().await.is_empty()); Ok(()) } + + #[nativelink_test] + async fn failed_materialization_preserves_mounted_entry() -> Result<(), Error> { + let temp_dir = TempDir::new().unwrap(); + let (store, digest) = setup_test_store(&temp_dir).await; + let cache = DirectoryCache::new( + DirectoryCacheConfig { + cache_root: temp_dir.path().join("cache"), + ..Default::default() + }, + store, + ) + .await?; + let prepared = cache.prepare_for_mount(digest, None).await?; + // An invalid destination must not invalidate another action's mount. + let dest = temp_dir.path().join("dest"); + fs::write(&dest, b"not a directory").await?; + assert!(cache.get_or_create(digest, &dest).await.is_err()); + let cached = cache.acquire_entry(&digest).await.unwrap(); + assert!( + Arc::ptr_eq(&prepared.pin.ref_count, &cached.pin.ref_count), + "failed materialization replaced a pinned mount source" + ); + assert!(prepared.path().join("test.txt").exists()); + drop(cached); + drop(prepared); + fs::remove_file(&dest).await?; + assert!(cache.get_or_create(digest, &dest).await?); + Ok(()) + } } diff --git a/nativelink-worker/src/input_mounts.rs b/nativelink-worker/src/input_mounts.rs new file mode 100644 index 000000000..461d1cf17 --- /dev/null +++ b/nativelink-worker/src/input_mounts.rs @@ -0,0 +1,192 @@ +// Copyright 2026 The NativeLink Authors. All rights reserved. +// +// Licensed under the Functional Source License, Version 1.1, Apache 2.0 Future License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// See LICENSE file for details +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::collections::HashSet; +use std::path::{Component, Path, PathBuf}; +use std::sync::Arc; + +use nativelink_error::{Code, Error, ResultExt, make_err}; +use nativelink_proto::build::bazel::remote::execution::v2::{Command, Directory}; +use nativelink_store::ac_utils::get_and_decode_digest; +use nativelink_store::fast_slow_store::FastSlowStore; +use nativelink_util::common::DigestInfo; +use parking_lot::Mutex; + +use crate::directory_cache::{DigestLease, DirectoryCache, PreparedDirectory}; + +/// Selected immutable input subtrees and their cache pins for one action. +/// Pins remain held through cleanup, including after cancelled preparation. +#[derive(Debug)] +pub struct InputMounts { + cache: Arc, + targets: HashSet, + prepared: Mutex>, +} + +/// Validates that mount paths are canonical, nonempty, and non-overlapping. +/// Paths are relative to the action input root. Mounting the whole root would +/// prevent the action from creating outputs. +pub fn validate_paths(paths: &[String]) -> Result<(), Error> { + for (i, path) in paths.iter().enumerate() { + if !is_canonical_relative(path) + || paths[..i] + .iter() + .any(|other| paths_overlap(Path::new(path), Path::new(other))) + { + return Err(make_err!( + Code::InvalidArgument, + "Read-only input mount paths must be nonempty, canonical, non-overlapping relative paths: {path:?}" + )); + } + } + Ok(()) +} + +/// Checks only the ancestors of selected paths, without walking their contents. +/// Actions without a matching directory should keep using the ordinary cache. +/// `paths` must have passed `validate_paths`. +pub async fn has_mountable_inputs( + cas_store: &FastSlowStore, + root_digest: DigestInfo, + paths: &[String], + lease: Option<&dyn DigestLease>, +) -> Result { + 'target: for path in paths { + let mut digest = root_digest; + for component in path.split('/') { + if let Some(lease) = lease { + lease.acquire(&digest); + } + let directory = get_and_decode_digest::(cas_store, digest.into()).await?; + let Some(child) = directory + .directories + .iter() + .find(|child| child.name == component) + else { + continue 'target; + }; + digest = child + .digest + .as_ref() + .err_tip(|| "Missing input directory digest")? + .try_into()?; + } + return Ok(true); + } + Ok(false) +} + +fn is_canonical_relative(path: &str) -> bool { + !path.is_empty() + && !path.contains(['\\', '\0']) + && path.split('/').all(|part| !matches!(part, "" | "." | "..")) +} + +// Outputs are relative to the command's working directory and may use `..` +// (for example Chromium's ../clang-crashreports). Normalize lexically within +// the input root before checking overlap. Symlink aliases remain a workload +// constraint; resolving them would require walking the input tree on every hit. +fn resolve_output(cwd: &str, output: &str) -> Option { + if output.contains(['\\', '\0']) { + return None; + } + let mut path = PathBuf::from(cwd); + for component in Path::new(output).components() { + match component { + Component::Normal(part) => path.push(part), + Component::CurDir => {} + Component::ParentDir => { + if !path.pop() { + return None; + } + } + Component::RootDir | Component::Prefix(_) => return None, + } + } + Some(path) +} + +fn paths_overlap(a: &Path, b: &Path) -> bool { + a.starts_with(b) || b.starts_with(a) +} + +impl InputMounts { + /// Creates mount state for a compatible command, or returns `None` to use + /// ordinary materialization. Declared outputs must not overlap a selected + /// subtree: the output collector cannot see the child's private mounts. + pub fn for_command( + cache: Arc, + paths: &[String], + root: &Path, + command: &Command, + ) -> Option> { + let cwd = &command.working_directory; + if !cwd.is_empty() && !is_canonical_relative(cwd) { + return None; + } + if paths.iter().any(|path| Path::new(cwd).starts_with(path)) { + return None; + } + for output in command + .output_paths + .iter() + .chain(&command.output_files) + .chain(&command.output_directories) + { + // Empty output paths designate the whole working directory. + let output = resolve_output(cwd, output)?; + if paths + .iter() + .any(|path| paths_overlap(&output, Path::new(path))) + { + return None; + } + } + Some(Arc::new(Self { + cache, + targets: paths.iter().map(|path| root.join(path)).collect(), + prepared: Mutex::new(Vec::new()), + })) + } + + /// Prepares and pins a selected subtree before the input walk descends into it. + /// Returns `true` when the caller should leave an empty mount point instead + /// of materializing the subtree, or `false` when the path is not selected. + pub async fn prepare( + &self, + path: &Path, + digest: DigestInfo, + lease: Option<&dyn DigestLease>, + ) -> Result { + if !self.targets.contains(path) { + return Ok(false); + } + let directory = self.cache.prepare_for_mount(digest, lease).await?; + self.prepared.lock().push((path.to_owned(), directory)); + Ok(true) + } + + /// Allocates mount paths and reads mount flags before entering pre-exec. + #[cfg(target_os = "linux")] + pub fn bind_mounts(&self) -> Result, Error> { + self.prepared + .lock() + .iter() + .map(|(target, directory)| { + crate::namespace_utils::ReadOnlyBindMount::new(directory.path(), target) + .map_err(Error::from) + }) + .collect() + } +} diff --git a/nativelink-worker/src/lib.rs b/nativelink-worker/src/lib.rs index dd95a8986..83e955c09 100644 --- a/nativelink-worker/src/lib.rs +++ b/nativelink-worker/src/lib.rs @@ -13,6 +13,7 @@ // limitations under the License. pub mod directory_cache; +pub mod input_mounts; pub mod local_worker; #[cfg(target_os = "linux")] pub mod namespace_utils; diff --git a/nativelink-worker/src/local_worker.rs b/nativelink-worker/src/local_worker.rs index f1a22cab3..fe2b7f6d0 100644 --- a/nativelink-worker/src/local_worker.rs +++ b/nativelink-worker/src/local_worker.rs @@ -713,6 +713,14 @@ pub async fn new_local_worker( )); } + #[cfg(not(target_os = "linux"))] + if !config.experimental_readonly_input_mounts.is_empty() { + return Err(make_err!( + Code::Unavailable, + "Read-only input mounts not supported on non-Linux OSes" + )); + } + let running_actions_manager = Arc::new(RunningActionsManagerImpl::new(RunningActionsManagerArgs { root_action_directory: config.work_directory.clone(), @@ -732,6 +740,8 @@ pub async fn new_local_worker( directory_cache, active_input_leases: config.experimental_active_input_leases, #[cfg(target_os = "linux")] + readonly_input_mounts: config.experimental_readonly_input_mounts.clone(), + #[cfg(target_os = "linux")] use_namespaces, })?); let local_worker = LocalWorker::new_with_connection_factory_and_actions_manager( diff --git a/nativelink-worker/src/namespace_utils.rs b/nativelink-worker/src/namespace_utils.rs index bb9d34208..6041b2661 100644 --- a/nativelink-worker/src/namespace_utils.rs +++ b/nativelink-worker/src/namespace_utils.rs @@ -301,6 +301,7 @@ impl Drop for OwnedFd { fn perform_remount( root_action_directory: &core::ffi::CStr, action_directory: &core::ffi::CStr, + input_mounts: &[ReadOnlyBindMount], ) -> Result<(), Error> { // Make the mount namespace private to avoid changes propagating back to the host. // SAFETY: mount is async-signal-safe. We pass a null pointer for the source and valid @@ -318,6 +319,12 @@ fn perform_remount( return Err(Error::last_os_error()); } + // Install inputs while cache paths are still visible. The recursive action + // bind below preserves these mounts when its parent is masked. Resolving + // sources here also keeps them in this mount namespace, unlike descriptors + // opened by the parent before unshare. + mount_readonly_inputs(input_mounts)?; + // Bind mount the action directory to itself to "save" its current contents before // we mask its parent. // SAFETY: mount is async-signal-safe. We pass valid C-string pointers for the paths. @@ -391,6 +398,96 @@ fn perform_remount( Ok(()) } +/// Paths for a bind mount, allocated in the parent before the pre-exec hook. +/// The caller must pin the cache entry until the action exits and its mounts are released. +#[derive(Debug)] +pub struct ReadOnlyBindMount { + source: std::ffi::CString, + target: std::ffi::CString, + remount_flags: libc::c_ulong, +} + +impl ReadOnlyBindMount { + pub fn new(source: &std::path::Path, target: &std::path::Path) -> Result { + use std::os::unix::ffi::OsStrExt; + + // Linux statvfs flag, not exposed by the libc crate on musl. + const ST_RELATIME: libc::c_ulong = 0x1000; + + // The child changes its working directory before running pre-exec. + let source = std::path::absolute(source)?; + let target = std::path::absolute(target)?; + let source = std::ffi::CString::new(source.as_os_str().as_bytes())?; + let mut stats = core::mem::MaybeUninit::::uninit(); + // SAFETY: the source is a valid C string and stats is writable storage. + if unsafe { libc::statvfs(source.as_ptr(), stats.as_mut_ptr()) } != 0 { + return Err(Error::last_os_error()); + } + // SAFETY: statvfs initialized the structure on success. + let flags = unsafe { stats.assume_init() }.f_flag; + let mut remount_flags = + libc::MS_BIND | libc::MS_REMOUNT | libc::MS_RDONLY | libc::MS_NOSUID | libc::MS_NODEV; + // A user namespace cannot clear inherited locked mount flags. Preserve + // execute and atime restrictions while adding read-only protection. + for (stat_flag, mount_flag) in [ + (libc::ST_NOEXEC, libc::MS_NOEXEC), + (libc::ST_NOATIME, libc::MS_NOATIME), + (libc::ST_NODIRATIME, libc::MS_NODIRATIME), + (ST_RELATIME, libc::MS_RELATIME), + ] { + if flags & stat_flag != 0 { + remount_flags |= mount_flag; + } + } + Ok(Self { + source, + target: std::ffi::CString::new(target.as_os_str().as_bytes())?, + remount_flags, + }) + } +} + +/// Installs prepared input trees inside the private mount namespace, before +/// masking the action root. +/// +/// Call only from the child's pre-exec hook: all strings and flags are +/// prepared beforehand, and this function uses only mount syscalls and errno. +/// Failure aborts the spawn rather than executing against empty placeholders. +fn mount_readonly_inputs(mounts: &[ReadOnlyBindMount]) -> Result<(), Error> { + for mount in mounts { + // SAFETY: both paths are valid, preallocated C strings; the source + // cache entry is kept alive by the action through its cleanup. + if unsafe { + libc::mount( + mount.source.as_ptr(), + mount.target.as_ptr(), + core::ptr::null(), + libc::MS_BIND, + core::ptr::null(), + ) + } != 0 + { + return Err(Error::last_os_error()); + } + // MS_RDONLY on the initial bind is ignored by Linux; remount the + // action's bind explicitly. This leaves the worker's cache mount alone. + // SAFETY: this modifies only the bind in the child's private namespace. + if unsafe { + libc::mount( + core::ptr::null(), + mount.target.as_ptr(), + core::ptr::null(), + mount.remount_flags, + core::ptr::null(), + ) + } != 0 + { + return Err(Error::last_os_error()); + } + } + Ok(()) +} + /// A hook for a `Command::spawn` to create the process in a new namespace. /// This creates a stub process that the Command points at which forwards /// SIGKILL to the actual process in the new user, PID, UTS and IPC @@ -403,6 +500,22 @@ pub fn configure_namespace( root_action_directory: &core::ffi::CStr, action_directory: &core::ffi::CStr, ) -> std::io::Result<()> { + configure_namespace_with_input_mounts(mount, root_action_directory, action_directory, &[]) +} + +/// Like `configure_namespace`, with prepared immutable input trees. Mounts are +/// installed only after making the namespace private, and before masking the +/// action root. All paths must be allocated and cache entries pinned by the +/// parent. This function is async-signal-safe. +pub fn configure_namespace_with_input_mounts( + mount: bool, + root_action_directory: &core::ffi::CStr, + action_directory: &core::ffi::CStr, + input_mounts: &[ReadOnlyBindMount], +) -> std::io::Result<()> { + if !mount && !input_mounts.is_empty() { + return Err(Error::from_raw_os_error(libc::EINVAL)); + } // SAFETY: It is always safe to call geteuid on Posix. let uid = unsafe { libc::geteuid() }; // SAFETY: It is always safe to call getegid on Posix. @@ -444,7 +557,7 @@ pub fn configure_namespace( // Configure the mount namespace if enabled. if mount { - perform_remount(root_action_directory, action_directory).unwrap(); + perform_remount(root_action_directory, action_directory, input_mounts)?; } // Set hostname to "nativelink" to ensure reproducibility. diff --git a/nativelink-worker/src/running_actions_manager.rs b/nativelink-worker/src/running_actions_manager.rs index 323448bad..9c040a1a2 100644 --- a/nativelink-worker/src/running_actions_manager.rs +++ b/nativelink-worker/src/running_actions_manager.rs @@ -82,6 +82,7 @@ use tonic::Request; use tracing::{debug, error, info, trace, warn}; use uuid::Uuid; +use crate::input_mounts::InputMounts; use crate::persistent_worker::{ Input as PersistentWorkerInput, PersistentWorkerPool, WireFormat, WorkRequest, WorkerKey, }; @@ -459,19 +460,33 @@ pub fn download_to_directory<'a>( digest: &'a DigestInfo, current_directory: &'a str, ) -> BoxFuture<'a, Result<(), Error>> { - download_to_directory_with_lease(cas_store, filesystem_store, digest, current_directory, None) + download_to_directory_with_mounts( + cas_store, + filesystem_store, + digest, + current_directory, + None, + None, + ) } -fn download_to_directory_with_lease<'a>( +fn download_to_directory_with_mounts<'a>( cas_store: &'a FastSlowStore, filesystem_store: Pin<&'a FilesystemStore>, digest: &'a DigestInfo, current_directory: &'a str, input_lease: Option>, + input_mounts: Option>, ) -> BoxFuture<'a, Result<(), Error>> { async move { - let (dirs, files, inline_nodes) = - collect_download_links(cas_store, digest, current_directory, input_lease).await?; + let (dirs, files, inline_nodes) = collect_download_links( + cas_store, + digest, + current_directory, + input_lease, + input_mounts, + ) + .await?; let (exec_files, plain_files): (Vec<_>, Vec<_>) = files.into_iter().partition(|file| file.executable); @@ -669,11 +684,23 @@ fn collect_download_links<'a>( digest: &'a DigestInfo, current_directory: &'a str, input_lease: Option>, + input_mounts: Option>, ) -> BoxFuture<'a, Result> { async move { if let Some(input_lease) = &input_lease { input_lease.lease_digest(digest); } + if let Some(mounts) = &input_mounts { + let lease = input_lease + .as_deref() + .map(|lease| -> &dyn crate::directory_cache::DigestLease { lease }); + if mounts + .prepare(Path::new(current_directory), *digest, lease) + .await? + { + return Ok((Vec::new(), Vec::new(), Vec::new())); + } + } let directory = get_and_decode_digest::(cas_store, digest.into()) .await .err_tip(|| "Converting digest to Directory")?; @@ -732,6 +759,7 @@ fn collect_download_links<'a>( input_lease.lease_digest(&digest); } let input_lease = input_lease.clone(); + let input_mounts = input_mounts.clone(); futures.push( async move { let (mut sub_dirs, files, inline) = collect_download_links( @@ -739,6 +767,7 @@ fn collect_download_links<'a>( &digest, &new_directory_path, input_lease, + input_mounts, ) .await .err_tip(|| format!("in download_to_directory : {new_directory_path}"))?; @@ -1023,12 +1052,13 @@ async fn prepare_action_inputs_with_lease( } // Traditional path (cache disabled or failed) - download_to_directory_with_lease( + download_to_directory_with_mounts( cas_store, filesystem_store, digest, work_directory, input_lease, + None, ) .await } @@ -1475,6 +1505,7 @@ struct RunningActionImplState { // that prevented the action from running, upload failures, timeouts, exc... // but we have (or could have) the action results (like stderr/stdout). error: Option, + input_mounts: Option>, } #[derive(Debug)] @@ -1528,6 +1559,7 @@ impl RunningActionImpl { resource_usage: None, execution_metadata, error: None, + input_mounts: None, }), // Set to true only after the action is inserted into the manager. // The constructor can fail the operation-id uniqueness check @@ -1560,17 +1592,61 @@ impl RunningActionImpl { (self.running_actions_manager.callbacks.now_fn)(); } let command = { + let manager = &self.running_actions_manager; + #[cfg(target_os = "linux")] + let mount_paths = &manager.readonly_input_mounts; + #[cfg(not(target_os = "linux"))] + let mount_paths: &[String] = &[]; // Download and build out our input files/folders. Also fetch and decode our Command. - let command_fut = self.metrics().get_proto_command_from_store.wrap(async { - get_and_decode_digest::( - self.running_actions_manager.cas_store.as_ref(), - self.action_info.command_digest.into(), + let get_command = || { + self.metrics().get_proto_command_from_store.wrap(async { + get_and_decode_digest::( + manager.cas_store.as_ref(), + self.action_info.command_digest.into(), + ) + .await + .err_tip(|| "Converting command_digest to Command") + }) + }; + // Mount eligibility depends on the command's output paths. Keep + // command and input downloads concurrent when mounts cannot apply. + let prefetched_command = if !mount_paths.is_empty() + && action_supports_persistent_workers(&self.action_info).is_none() + { + Some(get_command().await?) + } else { + None + }; + let mut input_mounts = match (&prefetched_command, &manager.directory_cache) { + (Some(command), Some(cache)) => InputMounts::for_command( + cache.clone(), + mount_paths, + Path::new(&self.work_directory), + command, + ), + _ => None, + }; + if input_mounts.is_some() + && !crate::input_mounts::has_mountable_inputs( + &manager.cas_store, + self.action_info.input_root_digest, + mount_paths, + self.input_lease + .as_deref() + .map(|lease| -> &dyn crate::directory_cache::DigestLease { lease }), ) - .await - .err_tip(|| "Converting command_digest to Command") - }); - let filesystem_store_pin = - Pin::new(self.running_actions_manager.filesystem_store.as_ref()); + .await? + { + input_mounts = None; + } + self.state.lock().input_mounts.clone_from(&input_mounts); + let command_fut = async { + match prefetched_command { + Some(command) => Ok(command), + None => get_command().await, + } + }; + let filesystem_store_pin = Pin::new(manager.filesystem_store.as_ref()); let (command, ()) = try_join(command_fut, async { fs::create_dir(&self.work_directory) .await @@ -1581,14 +1657,29 @@ impl RunningActionImpl { // Use directory cache if available for better performance. self.metrics() .download_to_directory - .wrap(prepare_action_inputs_with_lease( - &self.running_actions_manager.directory_cache, - &self.running_actions_manager.cas_store, - filesystem_store_pin, - &self.action_info.input_root_digest, - &self.work_directory, - self.input_lease.clone(), - )) + .wrap(async { + if input_mounts.is_some() { + download_to_directory_with_mounts( + &manager.cas_store, + filesystem_store_pin, + &self.action_info.input_root_digest, + &self.work_directory, + self.input_lease.clone(), + input_mounts, + ) + .await + } else { + prepare_action_inputs_with_lease( + &manager.directory_cache, + &manager.cas_store, + filesystem_store_pin, + &self.action_info.input_root_digest, + &self.work_directory, + self.input_lease.clone(), + ) + .await + } + }) .await }) .await?; @@ -1928,14 +2019,21 @@ impl RunningActionImpl { let action_directory = std::ffi::CString::new(self.action_directory.clone()) .err_tip(|| "In RunningActionImpl::inner_execute()")?; + let input_mounts = self.state.lock().input_mounts.clone(); + let bind_mounts = input_mounts + .as_ref() + .map(|mounts| mounts.bind_mounts()) + .transpose()? + .unwrap_or_default(); // SAFETY: This function is specifically designed to operate in a async-signal-safe // environment. unsafe { command_builder.pre_exec(move || { - crate::namespace_utils::configure_namespace( + crate::namespace_utils::configure_namespace_with_input_mounts( matches!(use_namespaces, UseNamespaces::YesAndMount), &root_action_directory, &action_directory, + &bind_mounts, ) }); } @@ -2575,9 +2673,11 @@ impl Drop for RunningActionImpl { let running_actions_manager = self.running_actions_manager.clone(); let action_directory = self.action_directory.clone(); let input_lease = self.input_lease.clone(); + let input_mounts = self.state.get_mut().input_mounts.take(); background_spawn!("running_action_impl_drop", async move { let cleanup_result = do_cleanup(&running_actions_manager, &operation_id, &action_directory).await; + drop(input_mounts); if let Some(input_lease) = input_lease { input_lease.release().await; } @@ -2688,6 +2788,7 @@ impl RunningAction for RunningActionImpl { .await; self.has_manager_entry.store(false, Ordering::Release); self.did_cleanup.store(true, Ordering::Release); + self.state.lock().input_mounts.take(); // The work directory and manager entry are gone before the // lease is released. If this future is cancelled while the // release task is finishing, Drop still observes a completed @@ -3056,6 +3157,8 @@ pub struct RunningActionsManagerArgs<'a> { /// exceed their configured `max_bytes` / `max_count` eviction limits. pub active_input_leases: bool, #[cfg(target_os = "linux")] + pub readonly_input_mounts: Vec, + #[cfg(target_os = "linux")] pub use_namespaces: UseNamespaces, } @@ -3111,6 +3214,8 @@ pub struct RunningActionsManagerImpl { /// Whether active action inputs are leased against eviction in the local /// CAS tiers (opt-in via `experimental_active_input_leases`). active_input_leases: bool, + #[cfg(target_os = "linux")] + readonly_input_mounts: Vec, persistent_worker_pool: PersistentWorkerPool, } @@ -3119,6 +3224,19 @@ impl RunningActionsManagerImpl { args: RunningActionsManagerArgs<'_>, callbacks: Callbacks, ) -> Result { + #[cfg(target_os = "linux")] + { + crate::input_mounts::validate_paths(&args.readonly_input_mounts)?; + if !args.readonly_input_mounts.is_empty() + && (!matches!(args.use_namespaces, UseNamespaces::YesAndMount) + || args.directory_cache.is_none()) + { + return Err(make_err!( + Code::InvalidArgument, + "Read-only input mounts require Linux mount namespaces and a directory cache" + )); + } + } // Sadly because of some limitations of how Any works we need to clone more times than optimal. let filesystem_store = args .cas_store @@ -3157,6 +3275,8 @@ impl RunningActionsManagerImpl { cleanup_complete_notify: Arc::new(Notify::new()), directory_cache: args.directory_cache, active_input_leases: args.active_input_leases, + #[cfg(target_os = "linux")] + readonly_input_mounts: args.readonly_input_mounts, persistent_worker_pool: PersistentWorkerPool::default(), #[cfg(target_os = "linux")] use_namespaces: args.use_namespaces, diff --git a/nativelink-worker/tests/directory_cache_test.rs b/nativelink-worker/tests/directory_cache_test.rs index b26025a55..feee628ff 100644 --- a/nativelink-worker/tests/directory_cache_test.rs +++ b/nativelink-worker/tests/directory_cache_test.rs @@ -1645,3 +1645,178 @@ async fn get_tree_prefetch_follows_server_pagination() -> Result<(), Error> { Ok(()) } + +#[nativelink_test] +async fn prepared_mount_tree_is_shared_and_pinned_until_release() -> Result<(), Error> { + let slow = MemoryStore::new(&MemorySpec::default()); + let cache = Arc::new( + DirectoryCache::new( + DirectoryCacheConfig { + max_entries: 1, + cache_root: make_temp_path("mount_cache").into(), + ..Default::default() + }, + make_cas_store(slow.clone()).await, + ) + .await?, + ); + let mut digests = Vec::new(); + for name in ["sdk-a", "sdk-b", "sdk-c"] { + let directory = ProtoDirectory { + directories: vec![DirectoryNode { + name: name.to_owned(), + digest: Some(proto_digest(&ProtoDirectory::default()).into()), + }], + ..Default::default() + }; + let digest = proto_digest(&directory); + slow.update_oneshot(digest, directory.encode_to_vec().into()) + .await?; + digests.push(digest); + } + let empty = ProtoDirectory::default(); + slow.update_oneshot(proto_digest(&empty), empty.encode_to_vec().into()) + .await?; + let mut handles = + futures::future::try_join_all((0..16).map(|_| cache.prepare_for_mount(digests[0], None))) + .await?; + let path = handles[0].path().to_owned(); + assert!(handles.iter().all(|handle| handle.path() == path)); + assert!(path.join("sdk-a").is_dir()); + let other = cache.prepare_for_mount(digests[1], None).await?; + assert!( + path.join("sdk-a").is_dir(), + "active mount source was evicted" + ); + // Even one remaining action must protect the shared source. + let last = handles.pop().unwrap(); + drop(handles); + assert!(path.exists()); + drop(last); + let third = cache.prepare_for_mount(digests[2], None).await?; + // Eviction renames the old source under the cache lock; deletion may follow + // asynchronously. The other two still-pinned trees must remain accessible. + assert!(!path.exists()); + assert!(other.path().join("sdk-b").is_dir()); + assert!(third.path().join("sdk-c").is_dir()); + Ok(()) +} + +#[nativelink_test] +async fn readonly_mount_paths_and_output_fallback() -> Result<(), Error> { + use nativelink_proto::build::bazel::remote::execution::v2::Command; + use nativelink_worker::input_mounts::{InputMounts, validate_paths}; + let paths = vec!["out/x/sdk".to_owned()]; + validate_paths(&paths)?; + for invalid in [ + "", "/sdk", "../sdk", "sdk/../x", "sdk//x", "./sdk", "sdk/", "sdk\\x", "sdk\0", + ] { + assert!( + validate_paths(&[invalid.to_owned()]).is_err(), + "{invalid:?}" + ); + } + assert!(validate_paths(&["sdk".into(), "sdk/headers".into()]).is_err()); + assert!(validate_paths(&["sdk".into(), "sdk".into()]).is_err()); + validate_paths(&["sdk".into(), "sdk2".into()])?; + let cache = Arc::new( + DirectoryCache::new( + DirectoryCacheConfig { + cache_root: make_temp_path("mount_fallback").into(), + ..Default::default() + }, + make_cas_store(MemoryStore::new(&MemorySpec::default())).await, + ) + .await?, + ); + let eligible = |command: Command| { + InputMounts::for_command(cache.clone(), &paths, Path::new("/action"), &command).is_some() + }; + assert!(eligible(Command { + working_directory: "out/x".into(), + output_paths: vec!["obj/a.o".into(), "../clang-crashreports".into()], + ..Default::default() + })); + for output in [ + "sdk", + "sdk/generated.h", + "", + "obj/../sdk/file", + "../../../escape", + "/absolute", + ] { + assert!( + !eligible(Command { + working_directory: "out/x".into(), + output_paths: vec![output.into()], + ..Default::default() + }), + "{output}" + ); + } + assert!(!eligible(Command { + output_directories: vec!["out".into()], + ..Default::default() + })); + assert!(!eligible(Command { + output_files: vec!["out/x/sdk/file".into()], + ..Default::default() + })); + assert!(!eligible(Command { + working_directory: "out/x/sdk/include".into(), + ..Default::default() + })); + Ok(()) +} + +#[nativelink_test] +async fn mount_selection_requires_an_input_directory() -> Result<(), Error> { + use nativelink_worker::input_mounts::has_mountable_inputs; + + let store = MemoryStore::new(&MemorySpec::default()); + // The selected SDK's contents are deliberately absent: selection should + // read its ancestors, not download the SDK or an unrelated subtree. + let sdk = ProtoDirectory { + directories: vec![DirectoryNode { + name: "headers".into(), + digest: Some(proto_digest(&ProtoDirectory::default()).into()), + }], + ..Default::default() + }; + let branch = ProtoDirectory { + directories: vec![DirectoryNode { + name: "sdk".into(), + digest: Some(proto_digest(&sdk).into()), + }], + ..Default::default() + }; + let root = ProtoDirectory { + directories: vec![ + branch.directories[0].clone(), + DirectoryNode { + name: "out".into(), + digest: Some(proto_digest(&branch).into()), + }, + ], + symlinks: vec![SymlinkNode { + name: "alias".into(), + target: "sdk".into(), + ..Default::default() + }], + ..Default::default() + }; + let digest = proto_digest(&root); + store + .update_oneshot(digest, root.encode_to_vec().into()) + .await?; + store + .update_oneshot(proto_digest(&branch), branch.encode_to_vec().into()) + .await?; + let cas = make_cas_store(store).await; + assert!(has_mountable_inputs(&cas, digest, &["out/sdk".into()], None).await?); + assert!(has_mountable_inputs(&cas, digest, &["sdk".into()], None).await?); + assert!(has_mountable_inputs(&cas, digest, &["absent".into(), "sdk".into()], None).await?); + assert!(!has_mountable_inputs(&cas, digest, &["out/x/sdk".into()], None).await?); + assert!(!has_mountable_inputs(&cas, digest, &["alias".into()], None).await?); + Ok(()) +} diff --git a/nativelink-worker/tests/namespace_utils_test.rs b/nativelink-worker/tests/namespace_utils_test.rs index b787f952a..bdc995933 100644 --- a/nativelink-worker/tests/namespace_utils_test.rs +++ b/nativelink-worker/tests/namespace_utils_test.rs @@ -309,3 +309,77 @@ async fn test_maybe_namespaced_child_try_wait() -> Result<(), Error> { Ok(()) } + +#[nativelink_test] +async fn readonly_input_mount_is_private_and_source_survives_masking() -> Result<(), Error> { + if !namespace_utils::namespaces_supported(true) { + eprintln!("SKIP: mount namespaces unavailable"); + return Ok(()); + } + let root = std::path::PathBuf::from(nativelink_util::common::make_temp_path("readonly_mount")); + let action = root.join("action"); + let target = action.join("sdk"); + // Intentionally put the source under the root that configure_namespace + // masks. Inputs must be mounted before the root is masked. + let source = root.join("cache"); + std::fs::create_dir_all(&target)?; + std::fs::create_dir_all(&source)?; + std::fs::write(source.join("header.h"), "sdk-content")?; + std::os::unix::fs::symlink("header.h", source.join("alias.h"))?; + let cwd = std::env::current_dir()?; + let relative_source = pathdiff::diff_paths(&source, &cwd).unwrap(); + let relative_target = pathdiff::diff_paths(&target, &cwd).unwrap(); + let mounts = vec![namespace_utils::ReadOnlyBindMount::new( + &relative_source, + &relative_target, + )?]; + let root_c = CString::new(root.as_os_str().as_encoded_bytes()).unwrap(); + let action_c = CString::new(action.as_os_str().as_encoded_bytes()).unwrap(); + let mut command = Command::new("sh"); + command.current_dir(&action); + command.arg("-c").arg( + "test \"$(cat \"$1/alias.h\")\" = sdk-content && ! touch \"$1/new.h\" && ! rm \"$1/header.h\"" + ).arg("sh").arg(&target); + // SAFETY: namespace setup and prepared bind mounts use only operations + // designed for the pre-exec child; no parent locks are held. + unsafe { + command.pre_exec(move || { + namespace_utils::configure_namespace_with_input_mounts( + true, &root_c, &action_c, &mounts, + ) + }); + } + let output = command.output()?; + assert!(output.status.success(), "{output:?}"); + assert!( + std::fs::read_dir(&target)?.next().is_none(), + "mount escaped into parent" + ); + assert_eq!( + std::fs::read_to_string(source.join("header.h"))?, + "sdk-content" + ); + assert!(!source.join("new.h").exists()); + + // A failed input mount must abort spawning, never run the command against + // an empty placeholder and accidentally cache an incorrect action result. + let mounts = vec![namespace_utils::ReadOnlyBindMount::new( + &source, + &target.join("missing"), + )?]; + let root_c = CString::new(root.to_str().unwrap())?; + let action_c = CString::new(action.to_str().unwrap())?; + let mut command = Command::new("sh"); + command.args(["-c", "exit 0"]); + // SAFETY: the pre-exec hook only uses prepared paths and namespace syscalls. + unsafe { + command.pre_exec(move || { + namespace_utils::configure_namespace_with_input_mounts( + true, &root_c, &action_c, &mounts, + ) + }); + } + assert!(command.spawn().is_err()); + std::fs::remove_dir_all(root)?; + Ok(()) +} diff --git a/nativelink-worker/tests/running_actions_manager_test.rs b/nativelink-worker/tests/running_actions_manager_test.rs index 6d18a5ea7..d4c92d5d8 100644 --- a/nativelink-worker/tests/running_actions_manager_test.rs +++ b/nativelink-worker/tests/running_actions_manager_test.rs @@ -158,6 +158,198 @@ mod tests { .await } + #[cfg(target_os = "linux")] + #[nativelink_test] + async fn readonly_inputs_reuse_subtrees_and_fall_back_for_outputs() -> Result<(), Error> { + if !namespace_utils::namespaces_supported(true) { + eprintln!("SKIP: mount namespaces unavailable"); + return Ok(()); + } + let (_fast, _slow, cas, _ac) = setup_stores().await?; + let root = make_temp_path("readonly_actions"); + fs::create_dir_all(&root).await?; + let cache = Arc::new( + DirectoryCache::new( + DirectoryCacheConfig { + cache_root: PathBuf::from(&root).join("cache"), + ..Default::default() + }, + cas.clone(), + ) + .await?, + ); + let manager = Arc::new(RunningActionsManagerImpl::new(RunningActionsManagerArgs { + root_action_directory: root.clone(), + execution_configuration: ExecutionConfiguration::default(), + cas_store: cas.clone(), + ac_store: None, + historical_store: Store::new(cas.clone()), + upload_action_result_config: &UploadActionResultConfig { + upload_ac_results_strategy: UploadCacheResultsStrategy::Never, + ..Default::default() + }, + max_action_timeout: Duration::from_secs(30), + max_upload_timeout: Duration::from_secs(30), + max_cleanup_wait: Duration::from_secs(30), + max_cleanup_backoff: Duration::from_millis(10), + timeout_handled_externally: false, + active_input_leases: true, + readonly_input_mounts: vec!["sdk".into()], + directory_cache: Some(cache.clone()), + use_namespaces: nativelink_worker::running_actions_manager::UseNamespaces::YesAndMount, + })?); + let tool = Bytes::from_static(b"#!/bin/sh\nprintf sdk-content"); + let tool_digest = compute_buf_digest(&tool, &mut DigestHasherFunc::Sha256.hasher()); + cas.as_ref().update_oneshot(tool_digest, tool).await?; + let sdk_digest = serialize_and_upload_message( + &Directory { + files: vec![FileNode { + name: "tool".into(), + digest: Some(tool_digest.into()), + is_executable: true, + ..Default::default() + }], + symlinks: vec![SymlinkNode { + name: "alias".into(), + target: "tool".into(), + ..Default::default() + }], + ..Default::default() + }, + cas.as_pin(), + &mut DigestHasherFunc::Sha256.hasher(), + ) + .await?; + let empty_digest = serialize_and_upload_message( + &Directory::default(), + cas.as_pin(), + &mut DigestHasherFunc::Sha256.hasher(), + ) + .await?; + for i in 0..4 { + // Change an unrelated input path, keeping the SDK digest stable. + let input_digest = serialize_and_upload_message( + &Directory { + directories: vec![ + DirectoryNode { + name: format!("other{i}"), + digest: Some(empty_digest.into()), + }, + DirectoryNode { + name: "sdk".into(), + digest: Some(sdk_digest.into()), + }, + ], + ..Default::default() + }, + cas.as_pin(), + &mut DigestHasherFunc::Sha256.hasher(), + ) + .await?; + let output_path = if i == 2 { "sdk/result" } else { "result" }; + let script = if i == 2 { + "./sdk/alias > sdk/result" + } else { + "./sdk/alias > result && ! touch sdk/forbidden" + }; + let command_digest = serialize_and_upload_message( + &Command { + arguments: vec!["/bin/sh".into(), "-c".into(), script.into()], + environment_variables: vec![EnvironmentVariable { + name: "PATH".into(), + value: env::var("PATH").unwrap(), + }], + output_paths: vec![output_path.into()], + ..Default::default() + }, + cas.as_pin(), + &mut DigestHasherFunc::Sha256.hasher(), + ) + .await?; + let action_digest = serialize_and_upload_message( + &Action { + command_digest: Some(command_digest.into()), + input_root_digest: Some(input_digest.into()), + ..Default::default() + }, + cas.as_pin(), + &mut DigestHasherFunc::Sha256.hasher(), + ) + .await?; + let action = manager + .create_and_add_action( + "test-worker".into(), + StartExecute { + execute_request: Some(ExecuteRequest { + action_digest: Some(action_digest.into()), + digest_function: ProtoDigestFunction::Sha256.into(), + ..Default::default() + }), + operation_id: OperationId::default().to_string(), + ..Default::default() + }, + ) + .await? + .prepare_action() + .await?; + if i < 2 { + assert!( + fs::read_dir(format!("{}/sdk", action.get_work_directory())) + .await? + .as_mut() + .next_entry() + .await? + .is_none(), + "parent workspace must contain only a mount point" + ); + assert_eq!( + cache.stats().await.entries, + 1, + "changed roots must reuse the SDK entry" + ); + assert_eq!(cache.stats().await.in_use_entries, 1); + } + if i == 3 { + // An abandoned prepared action must keep its source pinned + // until background cleanup removes the workspace, then release it. + let work_directory = action.get_work_directory().clone(); + assert_eq!(cache.stats().await.in_use_entries, 1); + drop(action); + tokio::time::timeout(Duration::from_secs(5), async { + while cache.stats().await.in_use_entries != 0 { + tokio::time::sleep(Duration::from_millis(1)).await; + } + }) + .await?; + assert!(!PathBuf::from(&work_directory).exists()); + continue; + } + let result = action + .clone() + .execute() + .await? + .upload_results() + .await? + .get_finished_result() + .await?; + assert_eq!(result.exit_code, 0); + assert_eq!(result.output_files.len(), 1); + let output = cas + .as_ref() + .get_part_unchunked(result.output_files[0].digest, 0, None) + .await?; + assert_eq!(output.as_ref(), b"sdk-content"); + action.cleanup().await?; + assert_eq!( + cache.stats().await.in_use_entries, + 0, + "cleanup must release the tree pin" + ); + } + fs::remove_dir_all(root).await?; + Ok(()) + } + const NOW_TIME: u64 = 10000; fn make_system_time(add_time: u64) -> SystemTime { @@ -353,6 +545,8 @@ mod tests { max_cleanup_backoff: Duration::from_millis(DEFAULT_MAX_CLEANUP_BACKOFF), timeout_handled_externally: false, active_input_leases: true, + #[cfg(target_os = "linux")] + readonly_input_mounts: Vec::new(), directory_cache: Some(directory_cache), #[cfg(target_os = "linux")] use_namespaces: use_namespaces(), @@ -511,6 +705,8 @@ mod tests { max_cleanup_backoff: Duration::from_millis(DEFAULT_MAX_CLEANUP_BACKOFF), timeout_handled_externally: false, active_input_leases: false, + #[cfg(target_os = "linux")] + readonly_input_mounts: Vec::new(), directory_cache: Some(directory_cache), #[cfg(target_os = "linux")] use_namespaces: use_namespaces(), @@ -1097,6 +1293,8 @@ mod tests { max_cleanup_backoff: Duration::from_millis(DEFAULT_MAX_CLEANUP_BACKOFF), timeout_handled_externally: false, active_input_leases: false, + #[cfg(target_os = "linux")] + readonly_input_mounts: Vec::new(), directory_cache: None, #[cfg(target_os = "linux")] use_namespaces: use_namespaces(), @@ -1225,6 +1423,8 @@ mod tests { max_cleanup_backoff: Duration::from_millis(DEFAULT_MAX_CLEANUP_BACKOFF), timeout_handled_externally: false, active_input_leases: false, + #[cfg(target_os = "linux")] + readonly_input_mounts: Vec::new(), directory_cache: None, #[cfg(target_os = "linux")] use_namespaces: use_namespaces(), @@ -1355,6 +1555,8 @@ mod tests { max_cleanup_backoff: Duration::from_millis(DEFAULT_MAX_CLEANUP_BACKOFF), timeout_handled_externally: false, active_input_leases: false, + #[cfg(target_os = "linux")] + readonly_input_mounts: Vec::new(), directory_cache: None, #[cfg(target_os = "linux")] use_namespaces: use_namespaces(), @@ -1540,6 +1742,8 @@ mod tests { max_cleanup_backoff: Duration::from_millis(DEFAULT_MAX_CLEANUP_BACKOFF), timeout_handled_externally: false, active_input_leases: false, + #[cfg(target_os = "linux")] + readonly_input_mounts: Vec::new(), directory_cache: None, #[cfg(target_os = "linux")] use_namespaces: use_namespaces(), @@ -1727,6 +1931,8 @@ mod tests { max_cleanup_backoff: Duration::from_millis(DEFAULT_MAX_CLEANUP_BACKOFF), timeout_handled_externally: false, active_input_leases: false, + #[cfg(target_os = "linux")] + readonly_input_mounts: Vec::new(), directory_cache: None, #[cfg(target_os = "linux")] use_namespaces: use_namespaces(), @@ -1983,6 +2189,8 @@ mod tests { max_cleanup_backoff: Duration::from_millis(DEFAULT_MAX_CLEANUP_BACKOFF), timeout_handled_externally: false, active_input_leases: false, + #[cfg(target_os = "linux")] + readonly_input_mounts: Vec::new(), directory_cache: None, #[cfg(target_os = "linux")] use_namespaces: use_namespaces(), @@ -2137,6 +2345,8 @@ mod tests { max_cleanup_backoff: Duration::from_millis(DEFAULT_MAX_CLEANUP_BACKOFF), timeout_handled_externally: false, active_input_leases: false, + #[cfg(target_os = "linux")] + readonly_input_mounts: Vec::new(), directory_cache: None, #[cfg(target_os = "linux")] use_namespaces: use_namespaces(), @@ -2282,6 +2492,8 @@ mod tests { max_cleanup_backoff: Duration::from_millis(DEFAULT_MAX_CLEANUP_BACKOFF), timeout_handled_externally: false, active_input_leases: false, + #[cfg(target_os = "linux")] + readonly_input_mounts: Vec::new(), directory_cache: None, #[cfg(target_os = "linux")] use_namespaces: use_namespaces(), @@ -2422,6 +2634,8 @@ mod tests { max_cleanup_backoff: Duration::from_millis(DEFAULT_MAX_CLEANUP_BACKOFF), timeout_handled_externally: false, active_input_leases: false, + #[cfg(target_os = "linux")] + readonly_input_mounts: Vec::new(), directory_cache: None, #[cfg(target_os = "linux")] use_namespaces: use_namespaces(), @@ -2630,6 +2844,8 @@ exit 0 max_cleanup_backoff: Duration::from_millis(DEFAULT_MAX_CLEANUP_BACKOFF), timeout_handled_externally: false, active_input_leases: false, + #[cfg(target_os = "linux")] + readonly_input_mounts: Vec::new(), directory_cache: None, #[cfg(target_os = "linux")] use_namespaces: use_namespaces(), @@ -2811,6 +3027,8 @@ exit 0 max_cleanup_backoff: Duration::from_millis(DEFAULT_MAX_CLEANUP_BACKOFF), timeout_handled_externally: false, active_input_leases: false, + #[cfg(target_os = "linux")] + readonly_input_mounts: Vec::new(), directory_cache: None, #[cfg(target_os = "linux")] use_namespaces: use_namespaces(), @@ -2986,6 +3204,8 @@ exit 1 max_cleanup_backoff: Duration::from_millis(DEFAULT_MAX_CLEANUP_BACKOFF), timeout_handled_externally: false, active_input_leases: false, + #[cfg(target_os = "linux")] + readonly_input_mounts: Vec::new(), directory_cache: None, #[cfg(target_os = "linux")] use_namespaces: use_namespaces(), @@ -3078,6 +3298,8 @@ exit 1 max_cleanup_backoff: Duration::from_millis(DEFAULT_MAX_CLEANUP_BACKOFF), timeout_handled_externally: false, active_input_leases: false, + #[cfg(target_os = "linux")] + readonly_input_mounts: Vec::new(), directory_cache: None, #[cfg(target_os = "linux")] use_namespaces: use_namespaces(), @@ -3157,6 +3379,8 @@ exit 1 max_cleanup_backoff: Duration::from_millis(DEFAULT_MAX_CLEANUP_BACKOFF), timeout_handled_externally: false, active_input_leases: false, + #[cfg(target_os = "linux")] + readonly_input_mounts: Vec::new(), directory_cache: None, #[cfg(target_os = "linux")] use_namespaces: use_namespaces(), @@ -3243,6 +3467,8 @@ exit 1 max_cleanup_backoff: Duration::from_millis(DEFAULT_MAX_CLEANUP_BACKOFF), timeout_handled_externally: false, active_input_leases: false, + #[cfg(target_os = "linux")] + readonly_input_mounts: Vec::new(), directory_cache: None, #[cfg(target_os = "linux")] use_namespaces: use_namespaces(), @@ -3350,6 +3576,8 @@ exit 1 max_cleanup_backoff: Duration::from_millis(DEFAULT_MAX_CLEANUP_BACKOFF), timeout_handled_externally: false, active_input_leases: false, + #[cfg(target_os = "linux")] + readonly_input_mounts: Vec::new(), directory_cache: None, #[cfg(target_os = "linux")] use_namespaces: use_namespaces(), @@ -3401,6 +3629,8 @@ exit 1 max_cleanup_backoff: Duration::from_millis(DEFAULT_MAX_CLEANUP_BACKOFF), timeout_handled_externally: false, active_input_leases: false, + #[cfg(target_os = "linux")] + readonly_input_mounts: Vec::new(), directory_cache: None, #[cfg(target_os = "linux")] use_namespaces: use_namespaces(), @@ -3473,6 +3703,8 @@ exit 1 max_cleanup_backoff: Duration::from_millis(DEFAULT_MAX_CLEANUP_BACKOFF), timeout_handled_externally: false, active_input_leases: false, + #[cfg(target_os = "linux")] + readonly_input_mounts: Vec::new(), directory_cache: None, #[cfg(target_os = "linux")] use_namespaces: use_namespaces(), @@ -3596,6 +3828,8 @@ exit 1 max_cleanup_backoff: Duration::from_millis(DEFAULT_MAX_CLEANUP_BACKOFF), timeout_handled_externally: false, active_input_leases: false, + #[cfg(target_os = "linux")] + readonly_input_mounts: Vec::new(), directory_cache: None, #[cfg(target_os = "linux")] use_namespaces: use_namespaces(), @@ -3688,6 +3922,8 @@ exit 1 max_cleanup_backoff: Duration::from_millis(DEFAULT_MAX_CLEANUP_BACKOFF), timeout_handled_externally: false, active_input_leases: false, + #[cfg(target_os = "linux")] + readonly_input_mounts: Vec::new(), directory_cache: None, #[cfg(target_os = "linux")] use_namespaces: use_namespaces(), @@ -3780,6 +4016,8 @@ exit 1 max_cleanup_backoff: Duration::from_millis(DEFAULT_MAX_CLEANUP_BACKOFF), timeout_handled_externally: false, active_input_leases: false, + #[cfg(target_os = "linux")] + readonly_input_mounts: Vec::new(), directory_cache: None, #[cfg(target_os = "linux")] use_namespaces: use_namespaces(), @@ -3869,6 +4107,8 @@ exit 1 max_cleanup_backoff: Duration::from_millis(DEFAULT_MAX_CLEANUP_BACKOFF), timeout_handled_externally: false, active_input_leases: false, + #[cfg(target_os = "linux")] + readonly_input_mounts: Vec::new(), directory_cache: None, #[cfg(target_os = "linux")] use_namespaces: use_namespaces(), @@ -4022,6 +4262,8 @@ exit 1 max_cleanup_backoff: Duration::from_millis(DEFAULT_MAX_CLEANUP_BACKOFF), timeout_handled_externally: false, active_input_leases: false, + #[cfg(target_os = "linux")] + readonly_input_mounts: Vec::new(), directory_cache: None, #[cfg(target_os = "linux")] use_namespaces: use_namespaces(), @@ -4197,6 +4439,8 @@ exit 1 max_cleanup_backoff: Duration::from_millis(DEFAULT_MAX_CLEANUP_BACKOFF), timeout_handled_externally: false, active_input_leases: false, + #[cfg(target_os = "linux")] + readonly_input_mounts: Vec::new(), directory_cache: None, #[cfg(target_os = "linux")] use_namespaces: use_namespaces(), @@ -4309,6 +4553,8 @@ exit 1 max_cleanup_backoff: Duration::from_millis(DEFAULT_MAX_CLEANUP_BACKOFF), timeout_handled_externally: false, active_input_leases: false, + #[cfg(target_os = "linux")] + readonly_input_mounts: Vec::new(), directory_cache: None, #[cfg(target_os = "linux")] use_namespaces: use_namespaces(), @@ -4423,6 +4669,8 @@ exit 1 max_cleanup_backoff: Duration::from_millis(DEFAULT_MAX_CLEANUP_BACKOFF), timeout_handled_externally: false, active_input_leases: false, + #[cfg(target_os = "linux")] + readonly_input_mounts: Vec::new(), directory_cache: None, // Pin namespaces off so this exercises the no-pre_exec/posix_spawn // path regardless of what the host kernel supports. @@ -4538,6 +4786,8 @@ exit 1 max_cleanup_backoff: Duration::from_millis(DEFAULT_MAX_CLEANUP_BACKOFF), timeout_handled_externally: false, active_input_leases: false, + #[cfg(target_os = "linux")] + readonly_input_mounts: Vec::new(), directory_cache: None, #[cfg(target_os = "linux")] use_namespaces: use_namespaces(), @@ -4748,6 +4998,8 @@ exit 1 max_cleanup_backoff: Duration::from_millis(DEFAULT_MAX_CLEANUP_BACKOFF), timeout_handled_externally: false, active_input_leases: false, + #[cfg(target_os = "linux")] + readonly_input_mounts: Vec::new(), directory_cache: None, #[cfg(target_os = "linux")] use_namespaces: use_namespaces(), @@ -4848,6 +5100,8 @@ done max_cleanup_backoff: Duration::from_millis(DEFAULT_MAX_CLEANUP_BACKOFF), timeout_handled_externally: false, active_input_leases: false, + #[cfg(target_os = "linux")] + readonly_input_mounts: Vec::new(), directory_cache: None, #[cfg(target_os = "linux")] use_namespaces: use_namespaces(), @@ -5033,6 +5287,8 @@ done max_cleanup_backoff: Duration::from_millis(DEFAULT_MAX_CLEANUP_BACKOFF), timeout_handled_externally: false, active_input_leases: false, + #[cfg(target_os = "linux")] + readonly_input_mounts: Vec::new(), directory_cache: None, #[cfg(target_os = "linux")] use_namespaces: use_namespaces(), @@ -5158,6 +5414,8 @@ done max_cleanup_backoff: Duration::from_millis(DEFAULT_MAX_CLEANUP_BACKOFF), timeout_handled_externally: false, active_input_leases: false, + #[cfg(target_os = "linux")] + readonly_input_mounts: Vec::new(), directory_cache: None, #[cfg(target_os = "linux")] use_namespaces: use_namespaces(), @@ -5304,6 +5562,8 @@ done max_cleanup_backoff: Duration::from_millis(DEFAULT_MAX_CLEANUP_BACKOFF), timeout_handled_externally: false, active_input_leases: false, + #[cfg(target_os = "linux")] + readonly_input_mounts: Vec::new(), directory_cache: None, #[cfg(target_os = "linux")] use_namespaces: use_namespaces(), @@ -5417,6 +5677,8 @@ done max_cleanup_backoff: Duration::from_millis(DEFAULT_MAX_CLEANUP_BACKOFF), timeout_handled_externally: false, active_input_leases: false, + #[cfg(target_os = "linux")] + readonly_input_mounts: Vec::new(), directory_cache: None, #[cfg(target_os = "linux")] use_namespaces: use_namespaces(), @@ -5561,6 +5823,8 @@ done max_cleanup_backoff: Duration::from_millis(DEFAULT_MAX_CLEANUP_BACKOFF), timeout_handled_externally: false, active_input_leases: false, + #[cfg(target_os = "linux")] + readonly_input_mounts: Vec::new(), directory_cache: None, #[cfg(target_os = "linux")] use_namespaces: use_namespaces(), @@ -5736,6 +6000,8 @@ done max_cleanup_backoff: Duration::from_millis(DEFAULT_MAX_CLEANUP_BACKOFF), timeout_handled_externally: false, active_input_leases: false, + #[cfg(target_os = "linux")] + readonly_input_mounts: Vec::new(), directory_cache: None, #[cfg(target_os = "linux")] use_namespaces: use_namespaces(), @@ -5895,6 +6161,8 @@ done max_cleanup_backoff: Duration::from_millis(DEFAULT_MAX_CLEANUP_BACKOFF), timeout_handled_externally: false, active_input_leases: false, + #[cfg(target_os = "linux")] + readonly_input_mounts: Vec::new(), directory_cache: None, #[cfg(target_os = "linux")] use_namespaces: use_namespaces(), diff --git a/web/apps/docs/content/docs/explanations/architecture.mdx b/web/apps/docs/content/docs/explanations/architecture.mdx index 814fdba7a..a22907f09 100644 --- a/web/apps/docs/content/docs/explanations/architecture.mdx +++ b/web/apps/docs/content/docs/explanations/architecture.mdx @@ -95,6 +95,9 @@ the merge rule, the ordering rule and the retry rule. A worker fetches the action's inputs from the CAS, materialises them into a directory, runs the command, uploads the outputs, and reports back. +Linux workers can optionally share cached input directories between actions +through [read-only mounts](/reference/nativelink-config/main#localworkerconfig), +avoiding repeated directory creation. Workers hold no durable state; a worker that dies mid-action costs you that action, not your cache. What they do hold is a local filesystem that is diff --git a/web/apps/docs/content/docs/reference/nativelink-config/main.mdx b/web/apps/docs/content/docs/reference/nativelink-config/main.mdx index 895903f1a..cd0576f65 100644 --- a/web/apps/docs/content/docs/reference/nativelink-config/main.mdx +++ b/web/apps/docs/content/docs/reference/nativelink-config/main.mdx @@ -5,7 +5,7 @@ full: true --- {/* AUTOGENERATED — do not edit by hand. - Source: nativelink-config @ main (a21edb0f) + Source: nativelink-config @ main (827b265b) Regenerate from web/: bun --filter @nativelink/docs gen:config-reference */} @@ -931,6 +931,7 @@ Configuration for `ExperimentalMongoDB` store. | `additional_environment` | map of string to [EnvironmentSource](#environmentsource) | — | — | An optional mapping of environment names to set for the execution as well as those specified in the action itself. If set, will set each key as an environment variable before executing the job with the value of the environment variable being the value of the property of the action being executed of that name or the fixed value. | | `directory_cache` | [DirectoryCacheConfig](#directorycacheconfig) | — | — | Optional directory cache configuration for improving performance by caching reconstructed input directories and using hardlinks instead of rebuilding them from CAS for every action. | | `experimental_active_input_leases` | boolean | — | false (eviction behavior is unchanged) | Optional and experimental: lease every digest in an active action's input Merkle closure in the worker's locally eviction-managed CAS tiers (the `cas_fast_slow_store`'s filesystem-backed stores) until the action has finished cleanup. This prevents `Lost inputs no longer available remotely` failures caused by local fast-tier eviction while inputs are being materialized under cache pressure. | +| `experimental_readonly_input_mounts` | array of string | — | [] (disabled) | Reuse immutable input directories through read-only bind mounts on Linux. The worker prepares each selected subtree once per REAPI `Directory` digest in the directory cache, then mounts it into each action instead of recreating its filesystem entries. | | `use_namespaces` | boolean | — | False | Whether to use namespaces to isolate the execution. This is only available on Linux. It is highly recommended as it avoids a number of issues with zombie processes and also provides additional hermeticity. If explicitly set to true and it is not supported the worker will exit with an error. | | `use_mount_namespace` | boolean | — | False | Whether to use a mount namespace to isolate the worker root. This is only available on Linux and when `use_namespaces` is true. It is highly recommended provides additional hermeticity. If explicitly set to true and it is not supported or `use_namespaces` is not set to true the worker will exit with an error. |