Skip to main content

ic_host_process/custody/
mod.rs

1//! Inherited descriptor custody and exact existing-sidecar quiescence.
2//!
3//! Callers own sidecar names, retained identities, command admission, environment
4//! names, waiting policy and durable intent. This module never serializes records,
5//! removes sidecars, kills descendants or grants retry or remote-effect authority.
6
7use command_fds::CommandFdExt;
8use ic_host_fs::durable::{self, RegularFileLockError};
9use std::{
10    fmt,
11    fs::{self, File, Metadata},
12    io,
13    os::{fd::AsRawFd, unix::fs::MetadataExt},
14    path::{Path, PathBuf},
15    process::{Child, Command},
16    thread,
17    time::{Duration, Instant},
18};
19
20/// Native regular-file identity; callers own persistence and namespace custody.
21#[derive(Clone, Copy, Debug, Eq, PartialEq)]
22pub struct FileIdentity {
23    /// Filesystem device number.
24    pub device: u64,
25    /// Inode number on that device.
26    pub inode: u64,
27}
28
29impl FileIdentity {
30    /// Observe a caller-owned regular descriptor without reopening its path.
31    ///
32    /// # Errors
33    /// Returns metadata failures or rejects a non-regular descriptor.
34    pub fn from_file(file: &File) -> io::Result<Self> {
35        let metadata = file.metadata()?;
36        if !metadata.is_file() {
37            return Err(io::Error::from(io::ErrorKind::InvalidInput));
38        }
39        Ok(Self::from_metadata(&metadata))
40    }
41
42    fn from_metadata(metadata: &Metadata) -> Self {
43        Self {
44            device: metadata.dev(),
45            inode: metadata.ino(),
46        }
47    }
48
49    fn require(self, metadata: &Metadata, path: &Path) -> Result<(), CustodyError> {
50        if !metadata.is_file() || Self::from_metadata(metadata) != self {
51            return Err(CustodyError::IdentityChanged {
52                path: path.to_path_buf(),
53            });
54        }
55        Ok(())
56    }
57}
58
59/// One command's inheritable sidecar custody, acquired without waiting.
60///
61/// Dropping closes the owner descriptor without unlocking inherited copies.
62/// Descendants must preserve that descriptor for their entire relevant lifetime.
63#[derive(Debug)]
64pub struct CommandCustody {
65    file: File,
66    path: PathBuf,
67    identity: FileIdentity,
68    dispatched: bool,
69}
70
71impl CommandCustody {
72    /// Open/create a private regular sidecar in an existing parent and try its lock.
73    ///
74    /// Existing bytes and permissions are preserved. Creation is not synchronized.
75    /// Only the final component is no-follow; callers trust and stabilize parents.
76    /// # Errors
77    /// Returns entry, lock contention and native I/O failures, retaining the sidecar.
78    pub fn acquire(path: &Path) -> Result<Self, CustodyError> {
79        let file = durable::open_private_regular_lock_file(path).map_err(CustodyError::Lock)?;
80        try_lock(&file, path)?;
81        Ok(Self {
82            identity: FileIdentity::from_file(&file)?,
83            file,
84            path: path.to_path_buf(),
85            dispatched: false,
86        })
87    }
88
89    /// Read the observed identity for caller-owned retention before dispatch.
90    #[must_use]
91    pub const fn identity(&self) -> FileIdentity {
92        self.identity
93    }
94
95    /// Read the caller-selected sidecar path.
96    #[must_use]
97    pub fn path(&self) -> &Path {
98        &self.path
99    }
100
101    /// Attempt one command with a duplicated descriptor named by its environment.
102    ///
103    /// Recheck the sidecar identity before consuming the single spawn allowance.
104    /// Duplication and spawn failures consume that allowance too. The owner stays
105    /// close-on-exec; the command's parent-side copy is closed before returning.
106    /// Callers own argv/environment admission, output bounds, deadlines and reaping.
107    /// # Errors
108    /// Returns replacement, repeated-dispatch, descriptor or spawn failures.
109    pub fn spawn(
110        &mut self,
111        mut command: Command,
112        descriptor_environment: &str,
113    ) -> Result<Child, CustodyError> {
114        if self.dispatched {
115            return Err(CustodyError::AlreadyDispatched {
116                path: self.path.clone(),
117            });
118        }
119        self.identity
120            .require(&fs::symlink_metadata(&self.path)?, &self.path)?;
121        self.dispatched = true;
122        let descriptor = rustix::io::fcntl_dupfd_cloexec(&self.file, 3).map_err(io::Error::from)?;
123        command.env(descriptor_environment, descriptor.as_raw_fd().to_string());
124        command.preserved_fds(vec![descriptor]);
125        let child = command.spawn();
126        drop(command);
127        child.map_err(CustodyError::Io)
128    }
129
130    /// Close owner custody and probe exact quiescence within caller-selected grace.
131    ///
132    /// Retry only contention. A zero grace performs one probe; polling must be
133    /// nonzero. Filesystem operations have no wall-clock deadline. Failure retains
134    /// the original sidecar and establishes no remote outcome or retry authority.
135    /// # Errors
136    /// Returns invalid timing, active inherited holders, replacement or I/O failure.
137    pub fn finish(
138        self,
139        grace: Duration,
140        poll_interval: Duration,
141    ) -> Result<QuiescenceGuard, CustodyError> {
142        if poll_interval.is_zero() {
143            return Err(io::Error::from(io::ErrorKind::InvalidInput).into());
144        }
145        let deadline = Instant::now()
146            .checked_add(grace)
147            .ok_or_else(|| io::Error::from(io::ErrorKind::InvalidInput))?;
148        let Self {
149            file,
150            path,
151            identity,
152            ..
153        } = self;
154        drop(file);
155        loop {
156            match QuiescenceGuard::acquire(&path, identity) {
157                Err(CustodyError::InFlight { .. }) if Instant::now() < deadline => {
158                    thread::sleep(
159                        poll_interval.min(deadline.saturating_duration_since(Instant::now())),
160                    );
161                }
162                result => return result,
163            }
164        }
165    }
166}
167
168/// Fresh non-spawning exclusive custody over an exact existing regular sidecar.
169///
170/// Drop explicitly unlocks, including transient duplicated/inherited copies of
171/// this guard. Unlike command custody, this guard cannot intentionally dispatch.
172#[derive(Debug)]
173pub struct QuiescenceGuard {
174    file: File,
175    identity: FileIdentity,
176}
177
178impl QuiescenceGuard {
179    /// Admit the retained identity before locking, then recheck the opened descriptor.
180    ///
181    /// Missing sidecars are never created. A different regular inode rejects before
182    /// lock contention can obscure replacement. Special entries use Host FS admission.
183    /// # Errors
184    /// Returns active custody, replacement, missing/unsafe entries or native I/O errors.
185    pub fn acquire(path: &Path, expected: FileIdentity) -> Result<Self, CustodyError> {
186        let metadata = fs::symlink_metadata(path)?;
187        if metadata.is_file() {
188            expected.require(&metadata, path)?;
189        }
190        let file = durable::open_existing_regular_lock_file(path).map_err(CustodyError::Lock)?;
191        try_lock(&file, path)?;
192        expected.require(&file.metadata()?, path)?;
193        Ok(Self {
194            file,
195            identity: expected,
196        })
197    }
198
199    /// Read the exact admitted identity.
200    #[must_use]
201    pub const fn identity(&self) -> FileIdentity {
202        self.identity
203    }
204}
205
206impl Drop for QuiescenceGuard {
207    fn drop(&mut self) {
208        let _ = durable::unlock(&self.file);
209    }
210}
211
212fn try_lock(file: &File, path: &Path) -> Result<(), CustodyError> {
213    durable::try_lock_exclusive(file).map_err(|source| {
214        if source.kind() == io::ErrorKind::WouldBlock {
215            CustodyError::InFlight {
216                path: path.to_path_buf(),
217            }
218        } else {
219            CustodyError::Io(source)
220        }
221    })
222}
223
224/// Typed local custody refusal; no variant authorizes reissue or cleanup.
225#[derive(Debug)]
226pub enum CustodyError {
227    /// An owner or inherited holder still has the exclusive lock.
228    InFlight {
229        /// Caller-selected sidecar.
230        path: PathBuf,
231    },
232    /// Retained regular-file identity no longer matches.
233    IdentityChanged {
234        /// Caller-selected sidecar.
235        path: PathBuf,
236    },
237    /// This owner already attempted its one spawn.
238    AlreadyDispatched {
239        /// Caller-selected sidecar.
240        path: PathBuf,
241    },
242    /// Host FS sidecar admission failed, preserving the original typed cause.
243    Lock(RegularFileLockError),
244    /// Native filesystem, descriptor, timing or spawn failure.
245    Io(io::Error),
246}
247
248impl fmt::Display for CustodyError {
249    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
250        f.write_str(match self {
251            Self::InFlight { .. } => "command custody remains in flight",
252            Self::IdentityChanged { .. } => "command custody identity changed",
253            Self::AlreadyDispatched { .. } => "command spawn already attempted",
254            Self::Lock(_) => "command sidecar admission failed",
255            Self::Io(_) => "command custody I/O failed",
256        })
257    }
258}
259
260impl std::error::Error for CustodyError {
261    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
262        match self {
263            Self::Lock(e) => Some(e),
264            Self::Io(e) => Some(e),
265            _ => None,
266        }
267    }
268}
269
270impl From<io::Error> for CustodyError {
271    fn from(source: io::Error) -> Self {
272        Self::Io(source)
273    }
274}
275
276#[cfg(test)]
277mod tests;