add PID lockfile crate

This commit is contained in:
Josh Junon 2024-03-18 18:30:04 +01:00
parent 786d63e601
commit 6968e99120
5 changed files with 378 additions and 3 deletions

78
Cargo.lock generated
View File

@ -1993,6 +1993,16 @@ dependencies = [
"thiserror",
]
[[package]]
name = "gitbutler-pidlock"
version = "0.0.0"
dependencies = [
"polonius-the-crab",
"sysinfo",
"tempfile",
"thiserror",
]
[[package]]
name = "glib"
version = "0.15.12"
@ -2250,6 +2260,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "higher-kinded-types"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "561985554c8b8d4808605c90a5f1979cc6c31a5d20b78465cd59501233c6678e"
dependencies = [
"never-say-never",
]
[[package]]
name = "hmac"
version = "0.12.1"
@ -3020,6 +3039,12 @@ dependencies = [
"jni-sys",
]
[[package]]
name = "never-say-never"
version = "6.6.666"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf5a574dadd7941adeaa71823ecba5e28331b8313fb2e1c6a5c7e5981ea53ad6"
[[package]]
name = "new_debug_unreachable"
version = "1.0.4"
@ -3100,6 +3125,15 @@ dependencies = [
"walkdir",
]
[[package]]
name = "ntapi"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4"
dependencies = [
"winapi",
]
[[package]]
name = "nu-ansi-term"
version = "0.46.0"
@ -3780,6 +3814,16 @@ dependencies = [
"windows-sys 0.48.0",
]
[[package]]
name = "polonius-the-crab"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96d9b0ba45e2c294fcd0795bc51e45c12746758e9954bb1190f97603ec9a3e91"
dependencies = [
"higher-kinded-types",
"never-say-never",
]
[[package]]
name = "portable-atomic"
version = "1.6.0"
@ -5225,6 +5269,21 @@ dependencies = [
"walkdir",
]
[[package]]
name = "sysinfo"
version = "0.30.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c385888ef380a852a16209afc8cfad22795dd8873d69c9a14d2e2088f118d18"
dependencies = [
"cfg-if",
"core-foundation-sys",
"libc",
"ntapi",
"once_cell",
"rayon",
"windows 0.52.0",
]
[[package]]
name = "system-configuration"
version = "0.5.1"
@ -6475,6 +6534,16 @@ dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be"
dependencies = [
"windows-core",
"windows-targets 0.52.0",
]
[[package]]
name = "windows-bindgen"
version = "0.39.0"
@ -6485,6 +6554,15 @@ dependencies = [
"windows-tokens",
]
[[package]]
name = "windows-core"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9"
dependencies = [
"windows-targets 0.52.0",
]
[[package]]
name = "windows-implement"
version = "0.39.0"

View File

@ -2,6 +2,7 @@
members = [
"gitbutler-app",
"gitbutler-changeset",
"gitbutler-pidlock",
]
resolver = "2"

View File

@ -45,10 +45,10 @@ refinery = { version = "0.8", features = [ "rusqlite" ] }
regex = "1.10"
reqwest = "0.11.24"
resolve-path = "0.1.0"
rusqlite.workspace = true
rusqlite = { workspace = true }
sentry = { version = "0.32", optional = true, features = ["backtrace", "contexts", "panic", "transport", "anyhow", "debug-images", "reqwest", "native-tls" ] }
sentry-tracing = "0.32.0"
serde.workspace = true
serde = { workspace = true }
serde_json = { version = "1.0", features = [ "std", "arbitrary_precision" ] }
sha1 = "0.10.6"
sha2 = "0.10.8"
@ -71,7 +71,7 @@ tracing-appender = "0.2.3"
tracing-subscriber = "0.3.17"
url = "2.5"
urlencoding = "2.1.3"
uuid.workspace = true
uuid = { workspace = true }
walkdir = "2.3.2"
zip = "0.6.5"

View File

@ -0,0 +1,12 @@
[package]
name = "gitbutler-pidlock"
version = "0.0.0"
edition = "2021"
[dependencies]
polonius-the-crab = "0.4.1"
sysinfo = "0.30.7"
thiserror.workspace = true
[dev-dependencies]
tempfile = "3.10.1"

View File

@ -0,0 +1,284 @@
//! # gitbutler-pidlock
//! Provides a PID-based lockfile for use where a task must ensure
//! it's the only operation working on a resource, typically a filesystem
//! resource.
//!
//! The PID file first checks if the PID file exists, pulls the PID out,
//! and checks if the process is still running. If all of those checks pass,
//! a lock blocks creation until any of those things is no longer true.
//!
//! It then (over)writes the PID file with its own PID, returns a lock,
//! and when the lock is dropped, it removes the PID file.
//!
//! Note that the above checks are not atomic; there *is* a small chance for
//! a race condition. Please keep this in mind.
#![deny(missing_docs)]
use std::{
fs,
path::{Path, PathBuf},
sync::Mutex,
time::Duration,
};
use ::polonius_the_crab::prelude::*;
use sysinfo::{Pid, ProcessRefreshKind, ProcessStatus};
/// The error type returned by the lockfile types.
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// The lockfile failed to get the current process's PID.
#[error("failed to get current process ID: {0}")]
GetPid(String),
/// The lockfile is being accessed by something else within
/// this process.
#[error("concurrent access to lockfile (same process)")]
ConcurrentAccess,
/// I/O error (generic, from [`std::io::Error`]).
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
/// Another process has locked the resource.
#[error("resource is locked by another process: {0}")]
ProcessLocked(u32),
}
/// Implements a PID lockfile to be used as a filesystem-based lock for a
/// resource, typically a filesystem resource.
///
/// See this crate's main documentation for information about the implementation.
pub struct PidLock {
filepath: PathBuf,
our_pid: Pid,
poll_rate: Duration,
access_mutex: Mutex<()>,
}
impl PidLock {
/// Creates a new `PidLock` instance given a filepath at which the PID file
/// should be stored.
#[cold]
pub fn new<P: AsRef<Path>>(filepath: P, poll_rate: Duration) -> Result<Self, Error> {
let our_pid = sysinfo::get_current_pid().map_err(|s| Error::GetPid(s.to_string()))?;
Ok(PidLock {
filepath: filepath.as_ref().to_owned(),
our_pid,
access_mutex: Mutex::new(()),
poll_rate,
})
}
/// Returns the path at which this PID lockfile is stored.
#[inline]
#[cold]
pub fn filepath(&self) -> &Path {
&self.filepath
}
/// Attempts to acquire the lock, blocking until it can be acquired.
pub fn try_lock(&mut self) -> Result<PidLockGuard, Error> {
{
let _access_lock = self
.access_mutex
.try_lock()
.map_err(|_| Error::ConcurrentAccess)?;
// Attempt to read the PID file.
let locked_pid = match fs::read_to_string(&self.filepath) {
Ok(s) => s.trim().parse::<Pid>().ok(),
Err(e) => {
if e.kind() == std::io::ErrorKind::NotFound {
None
} else {
return Err(e.into());
}
}
};
// If the PID file exists, check if the process is still running.
if let Some(locked_pid) = locked_pid {
let mut sys = sysinfo::System::new();
// We refresh with `::new()` here because we don't care about getting
// any of the information about the process other than its status.
sys.refresh_pids_specifics(&[locked_pid], ProcessRefreshKind::new());
if sys
.process(locked_pid)
.map(|p| p.status() != ProcessStatus::Zombie)
.unwrap_or(false)
{
return Err(Error::ProcessLocked(locked_pid.as_u32()));
}
}
}
// Otherwise, write our PID and return a lock.
fs::write(&self.filepath, self.our_pid.to_string())?;
Ok(PidLockGuard { lock: self })
}
/// Acquires the lock, blocking until it can be acquired.
pub fn lock<'a>(&'a mut self) -> Result<PidLockGuard<'a>, Error> {
let poll_rate = self.poll_rate;
let mut this = self;
loop {
polonius!(|this| -> Result<PidLockGuard<'polonius>, Error> {
match this.try_lock() {
Ok(guard) => polonius_return!(Ok(guard)),
Err(Error::ProcessLocked(_) | Error::ConcurrentAccess) => {
std::thread::sleep(poll_rate)
}
Err(e) => polonius_return!(Err(e)),
}
});
}
}
}
/// A guard returned by [`PidLock::try_lock`] and [`PidLock::lock`], which removes the PID file
/// when dropped.
///
/// **NOTE:** The removal is not guaranteed to work. If for some reason the removal fails, it will
/// be silently ignored.
pub struct PidLockGuard<'a> {
lock: &'a mut PidLock,
}
impl Drop for PidLockGuard<'_> {
fn drop(&mut self) {
let _ = fs::remove_file(&self.lock.filepath);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn test_ok_lock_drop() {
let td = tempfile::tempdir().unwrap();
let mut lock = PidLock::new(
td.path().join("test-ok-lock-drop.pid"),
Duration::from_millis(100),
)
.unwrap();
{
let _guard = lock.try_lock().unwrap();
}
{
let _guard = lock.try_lock().unwrap();
}
{
let _guard = lock.try_lock().unwrap();
}
}
#[test]
fn test_err_process_lock() {
let td = tempfile::tempdir().unwrap();
let path = td.path().join("test-err-process-lock.pid");
let mut lock = PidLock::new(&path, Duration::from_millis(100)).unwrap();
let _guard = lock.try_lock().unwrap();
let mut lock2 = PidLock::new(&path, Duration::from_millis(100)).unwrap();
let guard2 = lock2.try_lock();
match guard2 {
Ok(_) => panic!("expected process locked error, got lock"),
Err(Error::ProcessLocked(pid)) => {
assert_eq!(pid, sysinfo::get_current_pid().unwrap().as_u32())
}
Err(e) => panic!("expected process locked error, got {:?}", e),
}
}
#[test]
fn test_err_process_locked_different_pid() {
let td = tempfile::tempdir().unwrap();
let path = td.path().join("test-err-lock-process-locked.pid");
let mut lock = PidLock::new(&path, Duration::from_millis(50)).unwrap();
let _guard = lock.try_lock().unwrap();
// Write a fake PID to the file. In our case, we create a zombie process
// that is suspended upon boot (either forcefully, via a loop, or by the kernel).
let mut command = {
#[cfg(unix)]
unsafe {
// On Unix, we do a sleep loop before exec is invoked.
use std::{os::unix::process::CommandExt, process::Command};
Command::new("sh")
.arg("-c")
.arg(":")
.pre_exec(|| {
std::thread::sleep(Duration::from_millis(1000));
Ok(())
})
.spawn()
.unwrap()
}
#[cfg(windows)]
{
// On Windows, we start the program suspended and never resume it.
use std::{os::windows::process::CommandExt, process::Command};
Command::new("echo")
.creation_flags(0x00000004) // CREATE_SUSPENDED
.spawn()
.unwrap()
}
};
let fake_pid = command.id();
::std::fs::write(&path, fake_pid.to_string()).unwrap();
let mut lock2 = PidLock::new(&path, Duration::from_millis(1000)).unwrap();
let guard2 = lock2.try_lock();
command.kill().unwrap();
match guard2 {
Ok(_) => panic!("expected process locked error, got lock"),
Err(Error::ProcessLocked(pid)) => assert_eq!(pid, fake_pid),
Err(e) => panic!("expected process locked error, got {:?}", e),
}
}
#[test]
fn test_err_process_lock_wait() {
let joined = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let td = tempfile::tempdir().unwrap();
let path = td.path().join("test-err-process-lock-wait.pid");
let mut lock = PidLock::new(&path, Duration::from_millis(100)).unwrap();
let guard = lock.try_lock().unwrap();
let other_thread = std::thread::spawn({
let joined = joined.clone();
move || {
let mut lock2 = PidLock::new(&path, Duration::from_millis(10)).unwrap();
let guard2 = lock2.lock().unwrap();
joined.store(true, std::sync::atomic::Ordering::SeqCst);
drop(guard2);
}
});
// Sleep for a while and then drop the guard.
std::thread::sleep(Duration::from_millis(300));
assert!(!joined.load(std::sync::atomic::Ordering::SeqCst));
drop(guard);
other_thread.join().unwrap();
assert!(joined.load(std::sync::atomic::Ordering::SeqCst));
}
#[test]
fn test_ensure_remove_lock_file_on_guard_drop() {
let td = tempfile::tempdir().unwrap();
let path = td
.path()
.join("test-ensure-remove-lock-file-on-guard-drop.pid");
let mut lock = PidLock::new(&path, Duration::from_millis(100)).unwrap();
let guard = lock.try_lock().unwrap();
assert!(path.exists());
drop(guard);
assert!(!path.exists());
}
}