Skip to content

Commit fda9b6e

Browse files
committed
watt: implement single-instance lock mechanism for watt daemon
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Ideaf221f690c7bc694ca1f171385bfde6a6a6964
1 parent a8e3438 commit fda9b6e

File tree

5 files changed

+214
-2
lines changed

5 files changed

+214
-2
lines changed

Cargo.lock

Lines changed: 14 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,11 @@ clap = { features = [ "derive", "env" ], version = "4.5.50" }
1818
clap-verbosity-flag = "3.0.4"
1919
clap_complete = "4.5.59"
2020
clap_complete_nushell = "4.5.9"
21-
ctrlc = "3.5.0"
21+
ctrlc = "3.5.1"
2222
derive_more = { features = [ "full" ], version = "2.0.1" }
2323
env_logger = "0.11.8"
2424
log = "0.4.28"
25+
nix = { features = [ "fs" ], version = "0.31.1" }
2526
num_cpus = "1.17.0"
2627
serde = { features = [ "derive" ], version = "1.0.228" }
2728
thiserror = "2.0.17"

watt/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ ctrlc.workspace = true
2121
derive_more.workspace = true
2222
env_logger.workspace = true
2323
log.workspace = true
24+
nix.workspace = true
2425
num_cpus.workspace = true
2526
serde.workspace = true
2627
thiserror.workspace = true

watt/lib.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ pub mod fs;
1111

1212
pub mod config;
1313

14+
pub mod lock;
15+
1416
#[derive(clap::Parser, Debug)]
1517
#[command(version, about)]
1618
pub struct Cli {
@@ -20,6 +22,11 @@ pub struct Cli {
2022
/// The daemon config path.
2123
#[arg(long, env = "WATT_CONFIG")]
2224
config: Option<PathBuf>,
25+
26+
/// Force running even if another instance is already running. Potentially
27+
/// destructive.
28+
#[arg(long)]
29+
force: bool,
2330
}
2431

2532
pub fn main() -> anyhow::Result<()> {
@@ -38,5 +45,15 @@ pub fn main() -> anyhow::Result<()> {
3845

3946
log::info!("starting watt daemon");
4047

48+
let lock_path = std::env::var("XDG_RUNTIME_DIR")
49+
.map(|dir| std::path::PathBuf::from(dir).join("watt.pid"))
50+
.unwrap_or_else(|_| {
51+
log::warn!("XDG_RUNTIME_DIR not set, using /tmp/watt.pid");
52+
std::path::PathBuf::from("/tmp/watt.pid")
53+
});
54+
55+
let _lock = lock::LockFile::acquire(&lock_path, cli.force)
56+
.map_err(|e| anyhow::anyhow!("{e}"))?;
57+
4158
system::run_daemon(config)
4259
}

watt/lock.rs

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
use std::{
2+
fs::{
3+
File,
4+
OpenOptions,
5+
},
6+
io::Write,
7+
path::{
8+
Path,
9+
PathBuf,
10+
},
11+
process,
12+
};
13+
14+
#[cfg(unix)] use nix::fcntl::{
15+
Flock,
16+
FlockArg,
17+
};
18+
19+
#[cfg(not(unix))]
20+
compile_error!("watt is only supported on Unix-like systems");
21+
22+
pub struct LockFile {
23+
lock: Flock<File>,
24+
path: PathBuf,
25+
}
26+
27+
#[derive(Debug)]
28+
pub struct LockFileError {
29+
pub path: PathBuf,
30+
pid: u32,
31+
}
32+
33+
impl std::fmt::Display for LockFileError {
34+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35+
if self.pid == 0 {
36+
write!(f, "failed to acquire lock at {}", self.path.display())
37+
} else {
38+
write!(f, "another watt daemon is running (PID: {})", self.pid)
39+
}
40+
}
41+
}
42+
43+
impl std::error::Error for LockFileError {}
44+
45+
impl std::ops::Deref for LockFile {
46+
type Target = File;
47+
48+
fn deref(&self) -> &Self::Target {
49+
&self.lock
50+
}
51+
}
52+
53+
impl std::ops::DerefMut for LockFile {
54+
fn deref_mut(&mut self) -> &mut Self::Target {
55+
&mut self.lock
56+
}
57+
}
58+
59+
impl LockFile {
60+
pub fn path(&self) -> &Path {
61+
&self.path
62+
}
63+
64+
pub fn acquire(
65+
lock_path: &Path,
66+
force: bool,
67+
) -> Result<Option<Self>, LockFileError> {
68+
let pid = process::id();
69+
70+
#[allow(clippy::suspicious_open_options)]
71+
let file = match OpenOptions::new()
72+
.create(true)
73+
.read(true)
74+
.write(true)
75+
.open(lock_path)
76+
{
77+
Ok(file) => file,
78+
Err(error) => {
79+
log::error!(
80+
"failed to open lock file at {}: {}",
81+
lock_path.display(),
82+
error
83+
);
84+
return Err(LockFileError {
85+
path: lock_path.to_owned(),
86+
pid: 0,
87+
});
88+
},
89+
};
90+
91+
let mut lock = match Flock::lock(file, FlockArg::LockExclusiveNonblock) {
92+
Ok(lock) => lock,
93+
Err((_, nix::errno::Errno::EWOULDBLOCK)) => {
94+
let existing_pid = Self::read_pid(lock_path);
95+
96+
if let Some(existing_pid) = existing_pid {
97+
if force {
98+
log::warn!(
99+
"another watt instance is running (PID: {existing_pid}), \
100+
starting anyway",
101+
);
102+
return Ok(None);
103+
}
104+
105+
return Err(LockFileError {
106+
path: lock_path.to_owned(),
107+
pid: existing_pid,
108+
});
109+
}
110+
111+
if force {
112+
log::warn!(
113+
"could not determine PID of existing watt instance, starting \
114+
anyway",
115+
);
116+
return Ok(None);
117+
}
118+
119+
return Err(LockFileError {
120+
path: lock_path.to_owned(),
121+
pid: 0,
122+
});
123+
},
124+
125+
Err((_, error)) => {
126+
log::error!("failed to acquire lock: {}", error);
127+
return Err(LockFileError {
128+
path: lock_path.to_owned(),
129+
pid: 0,
130+
});
131+
},
132+
};
133+
134+
if let Err(e) = lock.set_len(0) {
135+
log::error!("failed to truncate lock file: {}", e);
136+
return Err(LockFileError {
137+
path: lock_path.to_owned(),
138+
pid: 0,
139+
});
140+
}
141+
142+
if let Err(e) = lock.write_all(format!("{pid}\n").as_bytes()) {
143+
log::error!("failed to write PID to lock file: {}", e);
144+
return Err(LockFileError {
145+
path: lock_path.to_owned(),
146+
pid: 0,
147+
});
148+
}
149+
150+
if let Err(e) = lock.sync_all() {
151+
log::error!("failed to sync lock file: {}", e);
152+
return Err(LockFileError {
153+
path: lock_path.to_owned(),
154+
pid: 0,
155+
});
156+
}
157+
158+
Ok(Some(LockFile {
159+
lock,
160+
path: lock_path.to_owned(),
161+
}))
162+
}
163+
164+
fn read_pid(lock_path: &Path) -> Option<u32> {
165+
match std::fs::read_to_string(lock_path) {
166+
Ok(content) => content.trim().parse().ok(),
167+
Err(_) => None,
168+
}
169+
}
170+
171+
pub fn release(&mut self) {
172+
let _ = std::fs::remove_file(&self.path);
173+
}
174+
}
175+
176+
impl Drop for LockFile {
177+
fn drop(&mut self) {
178+
self.release();
179+
}
180+
}

0 commit comments

Comments
 (0)