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