canic_backup/persistence/command_lifetime_lock/
mod.rs1use 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 unsafe {
235 command.pre_exec(move || {
236 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}