Skip to main content

canic_backup/persistence/command_lifetime_lock/
mod.rs

1//! Module: persistence::command_lifetime_lock
2//!
3//! Responsibility: prove one external command and its descendants are quiescent.
4//! Does not own: operation ordering, command execution, or effect reconciliation.
5
6use std::{
7    fs, io,
8    path::{Path, PathBuf},
9    thread,
10    time::{Duration, Instant},
11};
12
13#[cfg(unix)]
14use std::os::fd::AsRawFd;
15
16use super::file_lock::{self, FileLockError};
17
18const COMMAND_QUIESCENCE_GRACE: Duration = Duration::from_millis(250);
19
20#[derive(Clone, Copy, Debug)]
21pub struct CommandLifetimeHandle {
22    raw_fd: i32,
23}
24
25impl CommandLifetimeHandle {
26    #[must_use]
27    pub const fn raw_fd(self) -> i32 {
28        self.raw_fd
29    }
30}
31
32#[derive(Debug)]
33pub enum CommandLifetimeLockError {
34    InFlight { lock_path: String },
35    UnsafeEntry { lock_path: String, kind: String },
36    Io(io::Error),
37}
38
39#[derive(Debug)]
40pub struct CommandLifetimeLock {
41    file: fs::File,
42    path: PathBuf,
43}
44
45impl CommandLifetimeLock {
46    pub(crate) fn acquire(
47        journal_path: &Path,
48        operation_sequence: usize,
49    ) -> Result<Self, CommandLifetimeLockError> {
50        let path = command_lifetime_lock_path(journal_path, operation_sequence);
51        let file = file_lock::acquire(&path).map_err(|error| project_error(&path, error))?;
52        Ok(Self { file, path })
53    }
54
55    pub(crate) fn path(&self) -> &Path {
56        &self.path
57    }
58
59    #[cfg(unix)]
60    pub(crate) fn handle(&self) -> CommandLifetimeHandle {
61        CommandLifetimeHandle {
62            raw_fd: self.file.as_raw_fd(),
63        }
64    }
65
66    #[cfg(not(unix))]
67    pub(crate) const fn handle(&self) -> CommandLifetimeHandle {
68        CommandLifetimeHandle { raw_fd: -1 }
69    }
70
71    pub(crate) fn finish(self) -> Result<(), CommandLifetimeLockError> {
72        let Self { file, path } = self;
73        drop(file);
74
75        let deadline = Instant::now() + COMMAND_QUIESCENCE_GRACE;
76        loop {
77            match file_lock::acquire(&path) {
78                Ok(probe) => {
79                    drop(probe);
80                    return Ok(());
81                }
82                Err(FileLockError::Locked) if Instant::now() < deadline => {
83                    thread::sleep(Duration::from_millis(5));
84                }
85                Err(error) => return Err(project_error(&path, error)),
86            }
87        }
88    }
89}
90
91fn project_error(path: &Path, error: FileLockError) -> CommandLifetimeLockError {
92    match error {
93        FileLockError::Locked => CommandLifetimeLockError::InFlight {
94            lock_path: path.to_string_lossy().to_string(),
95        },
96        FileLockError::UnsafeEntry { kind } => CommandLifetimeLockError::UnsafeEntry {
97            lock_path: path.to_string_lossy().to_string(),
98            kind,
99        },
100        FileLockError::Io(error) => CommandLifetimeLockError::Io(error),
101    }
102}
103
104fn command_lifetime_lock_path(journal_path: &Path, operation_sequence: usize) -> PathBuf {
105    let mut path = journal_path.as_os_str().to_os_string();
106    path.push(format!(".command-{operation_sequence}.lock"));
107    PathBuf::from(path)
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use crate::test_support::temp_path;
114
115    #[cfg(unix)]
116    use rustix::io::{FdFlags, fcntl_getfd, fcntl_setfd};
117    #[cfg(unix)]
118    use std::{
119        os::fd::BorrowedFd,
120        os::unix::process::CommandExt,
121        process::Command,
122        thread,
123        time::{Duration, Instant},
124    };
125
126    const CHILD_JOURNAL_ENV: &str = "CANIC_TEST_COMMAND_LOCK_CHILD_PATH";
127    const CHILD_READY_ENV: &str = "CANIC_TEST_COMMAND_LOCK_CHILD_READY";
128
129    #[cfg(unix)]
130    #[test]
131    fn command_lock_stays_close_on_exec_in_the_owner() {
132        let journal_path = temp_path("canic-command-lifetime-lock");
133        let lock = CommandLifetimeLock::acquire(&journal_path, 3).expect("acquire command lock");
134
135        assert!(
136            fcntl_getfd(&lock.file)
137                .expect("read command lock descriptor flags")
138                .contains(FdFlags::CLOEXEC)
139        );
140        lock.finish().expect("finish quiescent command");
141        fs::remove_file(command_lifetime_lock_path(&journal_path, 3))
142            .expect("remove command lock path");
143    }
144
145    #[cfg(unix)]
146    #[test]
147    fn owner_death_keeps_lock_until_direct_child_and_descendant_exit() {
148        let journal_path = temp_path("canic-command-lifetime-owner-death");
149        let ready_path = temp_path("canic-command-lifetime-ready");
150        let mut owner = Command::new(std::env::current_exe().expect("resolve test executable"))
151            .args([
152                "--exact",
153                "persistence::command_lifetime_lock::tests::command_lock_child_owner",
154                "--nocapture",
155            ])
156            .env(CHILD_JOURNAL_ENV, &journal_path)
157            .env(CHILD_READY_ENV, &ready_path)
158            .spawn()
159            .expect("spawn command lock owner");
160
161        for _ in 0..500 {
162            if ready_path.is_file() {
163                break;
164            }
165            assert!(
166                owner.try_wait().expect("inspect owner").is_none(),
167                "command lock owner exited before readiness"
168            );
169            thread::sleep(Duration::from_millis(10));
170        }
171        assert!(
172            ready_path.is_file(),
173            "command lock owner did not become ready"
174        );
175        std::assert_matches!(
176            CommandLifetimeLock::acquire(&journal_path, 7),
177            Err(CommandLifetimeLockError::InFlight { .. })
178        );
179
180        owner.kill().expect("kill owner without unwinding");
181        owner.wait().expect("reap owner");
182        std::assert_matches!(
183            CommandLifetimeLock::acquire(&journal_path, 7),
184            Err(CommandLifetimeLockError::InFlight { .. })
185        );
186
187        let deadline = Instant::now() + Duration::from_secs(5);
188        loop {
189            match CommandLifetimeLock::acquire(&journal_path, 7) {
190                Ok(lock) => {
191                    lock.finish().expect("finish after descendant exit");
192                    break;
193                }
194                Err(CommandLifetimeLockError::InFlight { .. }) if Instant::now() < deadline => {
195                    thread::sleep(Duration::from_millis(10));
196                }
197                Err(error) => panic!("command tree did not become quiescent: {error:?}"),
198            }
199        }
200
201        fs::remove_file(command_lifetime_lock_path(&journal_path, 7))
202            .expect("remove command lock path");
203        fs::remove_file(ready_path).expect("remove ready marker");
204    }
205
206    #[cfg(unix)]
207    #[test]
208    #[expect(
209        clippy::zombie_processes,
210        reason = "the owner is intentionally killed before it can reap the command tree"
211    )]
212    fn command_lock_child_owner() {
213        let Some(journal_path) = std::env::var_os(CHILD_JOURNAL_ENV) else {
214            return;
215        };
216        let ready_path = std::env::var_os(CHILD_READY_ENV).expect("child ready path");
217        let lock =
218            CommandLifetimeLock::acquire(Path::new(&journal_path), 7).expect("acquire child lock");
219        let mut command = Command::new("sh");
220        command.args(["-c", "sleep 2 & wait"]).process_group(0);
221        inherit_command_lock(&mut command, lock.handle());
222        let _command_tree = command.spawn().expect("spawn direct child and descendant");
223        fs::write(ready_path, b"ready\n").expect("signal child readiness");
224        loop {
225            thread::sleep(Duration::from_secs(1));
226        }
227    }
228
229    #[cfg(unix)]
230    fn inherit_command_lock(command: &mut Command, handle: CommandLifetimeHandle) {
231        let raw_fd = handle.raw_fd();
232        // SAFETY: the child setup performs only fcntl on a descriptor kept open
233        // by `lock` until this test process is killed.
234        unsafe {
235            command.pre_exec(move || {
236                // SAFETY: `lock` keeps the raw descriptor valid through spawn.
237                let fd = BorrowedFd::borrow_raw(raw_fd);
238                let mut flags = fcntl_getfd(fd).map_err(errno_to_io)?;
239                flags.remove(FdFlags::CLOEXEC);
240                fcntl_setfd(fd, flags).map_err(errno_to_io)
241            });
242        }
243    }
244
245    #[cfg(unix)]
246    fn errno_to_io(error: rustix::io::Errno) -> io::Error {
247        io::Error::from_raw_os_error(error.raw_os_error())
248    }
249}