openlogi_device/host_lock.rs
1//! Advisory locks shared by every OpenLogi process on the host.
2//!
3//! Every process that opens a HID node receives every input report on it —
4//! macOS, Windows and Linux all fan reports out to every open handle — so two
5//! OpenLogi processes talking to one device at the same time, the agent and a
6//! CLI or GUI run, take each other's replies. Two things therefore need
7//! arbitration across processes, not just across the channels of one: HID++
8//! software ids, leased per channel by the HID transport, and receiver
9//! register access, serialised per node by [`lock_receiver_registers`]
10//! because HID++ 1.0 register replies carry no software id at all. Both are
11//! exclusive OS file locks in one directory. The OS releases a lock with its
12//! holder, so a crashed process cannot strand one.
13
14use std::{
15 fs::{self, File, OpenOptions, TryLockError},
16 io,
17 path::PathBuf,
18 time::{Duration, Instant},
19};
20
21use tracing::debug;
22
23use crate::backend::NodeId;
24
25/// How often [`lock_within`] re-tries a held lock.
26const POLL_PERIOD: Duration = Duration::from_millis(20);
27
28/// Directory of the lock files.
29///
30/// The temp directory rather than a profile directory on purpose: a
31/// dev-profile agent and a release CLI open the same HID node, so they must
32/// meet at one path. It is per user on macOS and Windows and per host on
33/// Linux; a file another user created is opened read-only, which still
34/// carries the lock.
35#[must_use]
36pub fn lock_dir() -> PathBuf {
37 std::env::temp_dir().join("openlogi-locks")
38}
39
40/// An exclusive host-wide lock. Dropping it releases the lock.
41#[derive(Debug)]
42pub struct HostLock {
43 /// Held only to be dropped: closing the file releases the lock.
44 _file: File,
45}
46
47/// Try to take the exclusive lock `name` without waiting.
48///
49/// `Ok(None)` when another holder has it — another process, or another taker
50/// in this one: the lock belongs to the open file description, so two takers
51/// in one process exclude each other as well. `Err` when the lock directory
52/// is unusable; callers fall back to whatever they did before locks existed.
53pub fn try_lock(name: &str) -> io::Result<Option<HostLock>> {
54 let dir = lock_dir();
55 fs::create_dir_all(&dir)?;
56 let path = dir.join(name);
57 let file = match OpenOptions::new()
58 .read(true)
59 .write(true)
60 .create(true)
61 .truncate(false)
62 .open(&path)
63 {
64 Ok(file) => file,
65 Err(error) if error.kind() == io::ErrorKind::PermissionDenied => File::open(&path)?,
66 Err(error) => return Err(error),
67 };
68 match file.try_lock() {
69 Ok(()) => Ok(Some(HostLock { _file: file })),
70 Err(TryLockError::WouldBlock) => Ok(None),
71 Err(TryLockError::Error(error)) => Err(error),
72 }
73}
74
75/// Take the exclusive lock `name`, re-trying for at most `budget`.
76///
77/// `Ok(None)` when the lock stayed held past the budget: the holder is still
78/// at whatever the lock guards, and the caller must not join it — defer or
79/// fail, never proceed unlocked. Bounded on purpose all the same, so a holder
80/// that is itself stuck cannot hang the caller. `Err` when the lock directory
81/// is unusable, which is the one case where there is nothing to wait for:
82/// callers fall back to whatever they did before locks existed.
83pub async fn lock_within(name: &str, budget: Duration) -> io::Result<Option<HostLock>> {
84 let deadline = Instant::now() + budget;
85 loop {
86 match try_lock(name) {
87 Ok(Some(lock)) => return Ok(Some(lock)),
88 Ok(None) if Instant::now() < deadline => tokio::time::sleep(POLL_PERIOD).await,
89 Ok(None) => {
90 debug!(name, ?budget, "host lock still held past the budget");
91 return Ok(None);
92 }
93 Err(error) => {
94 debug!(name, %error, "host lock unusable");
95 return Err(error);
96 }
97 }
98 }
99}
100
101/// How long a caller waits for another OpenLogi process to finish a
102/// receiver's register phase before giving up on it this time.
103///
104/// A phase is a probe's register reads — on a Unifying receiver held through
105/// its slot walk, since a codename read comes after it — or a route open's
106/// unique-id read: seconds at most, in the low single digits, with the 1.5 s
107/// arrival drains as the long poles. A holder still going past this is
108/// either a pairing session, which holds the phase for its whole run, or a
109/// probe failing one request timeout at a time; a waiter defers to it rather
110/// than joining it unlocked either way.
111pub const RECEIVER_REGISTER_TIMEOUT: Duration = Duration::from_secs(8);
112
113/// Holds a receiver's register phase for this process — or nothing, when the
114/// host cannot arbitrate the phase at all. Dropping it releases the phase.
115#[derive(Debug)]
116pub struct ReceiverRegisterPhase {
117 _lock: Option<HostLock>,
118}
119
120/// Serialise a receiver's register phase across OpenLogi processes.
121///
122/// HID++ 1.0 register replies carry no software id, and an error reply
123/// carries no sub-register either, so a second process talking to the same
124/// receiver's registers at the same time takes this one's replies — most
125/// visibly a probe's empty-slot errors, which turn the one paired slot into
126/// "unreadable"; both probes then fail, and the agent retires a channel that
127/// was fine. Every caller that reads or writes a receiver's registers takes
128/// the phase for as long as it does so: the lock only works if conflicting
129/// callers participate. Feature walks are not register I/O — they address
130/// each device by index under this process's own software id — and run
131/// outside it.
132///
133/// `None` when another process still holds the phase after `timeout`: the
134/// caller must not proceed unlocked, which would restore the very race the
135/// phase exists for. What it does instead is its own — a probe settles as
136/// deferred, a route open passes the node by this time. The lock directory
137/// being unusable is different: nothing can arbitrate, so the phase runs as
138/// it did before locks existed.
139pub async fn lock_receiver_registers(
140 node: &NodeId,
141 timeout: Duration,
142) -> Option<ReceiverRegisterPhase> {
143 match lock_within(&node_lock_name(node), timeout).await {
144 Ok(Some(lock)) => Some(ReceiverRegisterPhase { _lock: Some(lock) }),
145 Ok(None) => {
146 debug!(
147 node = %node,
148 "another OpenLogi process still holds this receiver's register phase"
149 );
150 None
151 }
152 Err(_) => Some(ReceiverRegisterPhase { _lock: None }),
153 }
154}
155
156/// The lock name for one HID node: a stable hash of its backend identity, so
157/// every process that enumerates the node arrives at the same file.
158#[must_use]
159pub fn node_lock_name(node: &NodeId) -> String {
160 // FNV-1a: short, dependency-free, and stable across builds — which
161 // `DefaultHasher` does not promise, and two different OpenLogi binaries
162 // must agree on the name.
163 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
164 for byte in node.to_string().bytes() {
165 hash ^= u64::from(byte);
166 hash = hash.wrapping_mul(0x0100_0000_01b3);
167 }
168 format!("node-{hash:016x}")
169}
170
171#[cfg(test)]
172mod tests {
173 use std::time::Duration;
174
175 use super::{lock_within, node_lock_name, try_lock};
176 use crate::backend::NodeId;
177
178 fn name(tag: &str) -> String {
179 format!("test-{}-{tag}", std::process::id())
180 }
181
182 #[test]
183 fn a_held_lock_excludes_a_second_taker_until_dropped() {
184 let name = name("exclusive");
185 let first = try_lock(&name)
186 .unwrap()
187 .expect("the first taker gets the lock");
188 assert!(
189 try_lock(&name).unwrap().is_none(),
190 "a held lock must not be handed out twice"
191 );
192 drop(first);
193 assert!(
194 try_lock(&name).unwrap().is_some(),
195 "dropping the lock releases it"
196 );
197 }
198
199 #[tokio::test]
200 async fn lock_within_waits_for_a_release_inside_its_budget() {
201 let name = name("wait");
202 let held = try_lock(&name)
203 .unwrap()
204 .expect("the first taker gets the lock");
205 let release = tokio::spawn(async move {
206 tokio::time::sleep(Duration::from_millis(60)).await;
207 drop(held);
208 });
209 assert!(
210 lock_within(&name, Duration::from_secs(2))
211 .await
212 .unwrap()
213 .is_some(),
214 "a lock released inside the budget is taken"
215 );
216 release.await.unwrap();
217 }
218
219 #[tokio::test]
220 async fn lock_within_gives_up_after_its_budget() {
221 let name = name("give-up");
222 let _held = try_lock(&name)
223 .unwrap()
224 .expect("the first taker gets the lock");
225 assert!(
226 lock_within(&name, Duration::from_millis(50))
227 .await
228 .unwrap()
229 .is_none(),
230 "a lock held past the budget is not taken"
231 );
232 }
233
234 #[test]
235 fn node_lock_names_are_stable_and_distinct() {
236 let a = NodeId::from("RegistryEntryId(4295012345)".to_string());
237 let same = NodeId::from("RegistryEntryId(4295012345)".to_string());
238 let b = NodeId::from("RegistryEntryId(4295012346)".to_string());
239 assert_eq!(node_lock_name(&a), node_lock_name(&same));
240 assert_ne!(node_lock_name(&a), node_lock_name(&b));
241 }
242}