Skip to content

Commit f459c8d

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

File tree

5 files changed

+215
-3
lines changed

5 files changed

+215
-3
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: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
use std::path::PathBuf;
1+
use std::{
2+
env,
3+
path::PathBuf,
4+
};
25

36
use anyhow::Context as _;
47
use clap::Parser as _;
@@ -11,6 +14,8 @@ pub mod fs;
1114

1215
pub mod config;
1316

17+
pub mod lock;
18+
1419
#[derive(clap::Parser, Debug)]
1520
#[command(version, about)]
1621
pub struct Cli {
@@ -20,6 +25,11 @@ pub struct Cli {
2025
/// The daemon config path.
2126
#[arg(long, env = "WATT_CONFIG")]
2227
config: Option<PathBuf>,
28+
29+
/// Force running even if another instance is already running. Potentially
30+
/// destructive.
31+
#[arg(long)]
32+
force: bool,
2333
}
2434

2535
pub fn main() -> anyhow::Result<()> {
@@ -38,5 +48,13 @@ pub fn main() -> anyhow::Result<()> {
3848

3949
log::info!("starting watt daemon");
4050

51+
let lock_path = env::var("XDG_RUNTIME_DIR")
52+
.map(|dir| PathBuf::from(dir).join("watt.pid"))
53+
.unwrap_or_else(|_| PathBuf::from("/run/watt.pid"));
54+
55+
let _lock = lock::LockFile::acquire(&lock_path, cli.force).context(
56+
format!("failed to acquire pid lock at {}", lock_path.display()),
57+
)?;
58+
4159
system::run_daemon(config)
4260
}

watt/lock.rs

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

0 commit comments

Comments
 (0)