Skip to main content

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}