Skip to main content

sioul_sync/
lease.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! One computer at a time for a part of Sioul's work (docs/database.md,
5//! "Leases"): reminding medicines, numbering invoices. Two computers doing
6//! either at once is a harm: a dose reminded again after it was taken, two
7//! invoices with one number.
8//!
9//! Through the sharing folder: each computer writes its own claim on a part,
10//! sealed like the records (`leases/<part>/<computer>.lease`), one writer per
11//! file so the sync never makes conflicted copies; renewed each minute while
12//! Sioul runs, alive five minutes after its last renewal, taken out when Sioul
13//! quits, or is put away on a phone: then it says so (`close`) with how far
14//! it wrote its records, so the others know it marks nothing until it is
15//! back (docs/health.md, "Knowing"). Every computer reads the same claims the same way:
16//! - a part that follows you (medicines) is kept by the live claim used most
17//!   recently: the computer you are at;
18//! - a part that stays put (invoices) is kept by the live claim taken last,
19//!   on purpose ("make invoices here"), else the oldest.
20//!
21//! A computer acts on a part only once it has kept it for the settling time:
22//! the others' claims have had time to come through the sync. A sync that
23//! stopped can still hide a claim; what must never happen twice also wants
24//! the others heard from lately (`Keeper::others_heard`).
25
26use serde::{Deserialize, Serialize};
27use std::collections::BTreeMap;
28use std::path::{Path, PathBuf};
29use std::sync::Mutex;
30
31/// How long a claim lives after its last renewal.
32pub const ALIVE: i64 = 5 * 60;
33/// How long a computer keeps a part before acting on it.
34pub const SETTLING: i64 = 90;
35/// How long a claim on a part that stays put (invoices) still holds it once
36/// lapsed, unless it says it closed: a computer whose sync stopped, or whose
37/// claims a phone's sync app brings half an hour late, may still be numbering
38/// (review of 5 October 2026, F7); one gone without closing (a crash, a
39/// computer given away) holds it a day, then no more.
40pub const UNCLOSED: i64 = 24 * 3600;
41
42/// Who keeps a part, as the claims say.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum Rule {
45    /// The computer used most recently: medicines' reminders.
46    FollowsYou,
47    /// The one that took it last, on purpose: invoices' numbers.
48    StaysPut,
49}
50
51/// A computer's claim on a part, in seconds.
52#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
53pub struct Claim {
54    pub computer: String,
55    /// Its name, to say where: the host's.
56    pub name: String,
57    /// Since when it claims, without a break.
58    pub since: i64,
59    pub renewed: i64,
60    pub until: i64,
61    /// You were last at it then.
62    pub active: i64,
63    /// Taken on purpose then; 0 never.
64    pub taken: i64,
65    /// How far it had written its records when it renewed: its round and the
66    /// number of the last (`share::written`). Read that far, everything it
67    /// said until then is known; none from a Sioul that does not say.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub wrote: Option<(u32, u64)>,
70    /// It closed then (quit, or put away): it marks nothing until it says otherwise.
71    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
72    pub closed: bool,
73    /// The file's size changed at each writing, so that every sync app brings
74    /// it (`share::pad_for`): read by nobody.
75    #[serde(default, skip_serializing_if = "String::is_empty")]
76    pub pad: String,
77}
78
79/// Who keeps a part now.
80#[derive(Debug, Clone, Default, PartialEq, Eq)]
81pub struct Keeper {
82    pub computer: String,
83    pub name: String,
84    /// This computer keeps it.
85    pub mine: bool,
86    /// And has kept it long enough to act.
87    pub settled: bool,
88    /// The last renewal heard from another live computer; 0 when none is
89    /// alive. For a part that stays put: the renewal of the one heard from
90    /// least lately among those still holding it, lapsed but not closed too
91    /// (`UNCLOSED`).
92    pub others_heard: i64,
93    /// How many other computers claim it now (for a part that stays put,
94    /// those lapsed but not closed too).
95    pub others: usize,
96}
97
98impl Keeper {
99    /// The only computer: no sharing, or nobody else claims.
100    pub fn alone(computer: &str) -> Keeper {
101        Keeper { computer: computer.to_string(), name: host_name(), mine: true, settled: true, others_heard: 0, others: 0 }
102    }
103}
104
105/// This computer's name, as people call it: its host name, asked once. On a
106/// Mac no file holds it and a program tells it: started at each claim (three
107/// a minute), it also held for a moment a copy of every file Sioul had open,
108/// the sharing's lock among them (`share::exchange_lock`).
109pub fn host_name() -> String {
110    static NAME: std::sync::OnceLock<String> = std::sync::OnceLock::new();
111    NAME.get_or_init(|| {
112        let named = std::env::var("COMPUTERNAME").ok().or_else(|| std::fs::read_to_string("/etc/hostname").ok()).or_else(|| crate::command("hostname").output().ok().map(|o| String::from_utf8_lossy(&o.stdout).to_string()));
113        named.map(|n| n.trim().to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| "?".into())
114    })
115    .clone()
116}
117
118fn folder_of(folder: &Path, part: &str) -> PathBuf {
119    folder.join("leases").join(part)
120}
121
122fn bound(part: &str, computer: &str) -> String {
123    format!("lease{}{part}{}{computer}", '\u{1f}', '\u{1f}')
124}
125
126/// Every claim on `part` that reads, live or not.
127pub fn claims(folder: &Path, key: &[u8; 32], part: &str) -> Vec<Claim> {
128    read(folder, key, part)
129}
130
131/// The claims on `part`: of one fetched from the server too
132/// (`remote::overlay`), the later renewed of the two copies; the folder's on a tie.
133fn read(folder: &Path, key: &[u8; 32], part: &str) -> Vec<Claim> {
134    let mut by: BTreeMap<String, Claim> = BTreeMap::new();
135    for dir in std::iter::once(folder.to_path_buf()).chain(crate::remote::overlay(folder)) {
136        for claim in read_in(&dir, key, part) {
137            match by.get(&claim.computer) {
138                Some(kept) if (kept.renewed, kept.until) >= (claim.renewed, claim.until) => {}
139                _ => {
140                    by.insert(claim.computer.clone(), claim);
141                }
142            }
143        }
144    }
145    by.into_values().collect()
146}
147
148fn read_in(folder: &Path, key: &[u8; 32], part: &str) -> Vec<Claim> {
149    let Ok(entries) = std::fs::read_dir(folder_of(folder, part)) else { return Vec::new() };
150    entries
151        .filter_map(Result::ok)
152        .map(|e| e.path())
153        .filter(|p| p.extension().is_some_and(|x| x == "lease"))
154        .filter_map(|path| {
155            let computer = path.file_stem()?.to_string_lossy().to_string();
156            // A claim is a few hundred bytes: a larger file is no claim, never read whole.
157            let text = crate::share::read_small(&path)?;
158            let plain = crate::share::open(key, &bound(part, &computer), text.trim())?;
159            let claim: Claim = serde_json::from_slice(&plain).ok()?;
160            // A claim names the computer whose file it is, or it is no claim.
161            (claim.computer == computer).then_some(claim)
162        })
163        .collect()
164}
165
166/// Whether a claim holds its part now: alive; for a part that stays put,
167/// lapsed but not closed, within `UNCLOSED` of its last renewal too.
168fn holds(claim: &Claim, rule: Rule, now: i64) -> bool {
169    claim.until > now || (rule == Rule::StaysPut && !claim.closed && now - claim.renewed <= UNCLOSED)
170}
171
172/// The keeper among the claims that hold the part (`holds`), by the part's
173/// rule; ties go to the smaller id.
174fn choose<'a>(claims: &'a [Claim], rule: Rule, now: i64) -> Option<&'a Claim> {
175    let live = claims.iter().filter(|c| holds(c, rule, now));
176    match rule {
177        Rule::FollowsYou => live.max_by(|a, b| a.active.cmp(&b.active).then_with(|| b.computer.cmp(&a.computer))),
178        // The last taken on purpose; none taken: the oldest claim.
179        Rule::StaysPut => live.max_by(|a, b| a.taken.cmp(&b.taken).then_with(|| b.since.cmp(&a.since)).then_with(|| b.computer.cmp(&a.computer))),
180    }
181}
182
183/// Since when this computer keeps each part, as seen here: (part → (keeper, since)).
184static KEPT: Mutex<BTreeMap<String, (String, i64)>> = Mutex::new(BTreeMap::new());
185
186/// This computer's claim on `part`, renewed (made the first time): `active`
187/// is when you were last at this computer, `take` takes the part on purpose,
188/// `wrote` how far it wrote its records. Returns who keeps it now.
189#[allow(clippy::too_many_arguments)]
190pub fn renew(folder: &Path, key: &[u8; 32], part: &str, computer: &str, now: i64, active: i64, take: bool, rule: Rule, wrote: Option<(u32, u64)>) -> Result<Keeper, String> {
191    let mut claims = read(folder, key, part);
192    let old = claims.iter().find(|c| c.computer == computer).cloned();
193    // A claim that lapsed, or closed, starts again: its age counts from now.
194    let since = old.as_ref().filter(|c| c.until > now && !c.closed).map_or(now, |c| c.since);
195    let taken = if take { now } else { old.as_ref().map_or(0, |c| c.taken) };
196    let claim = Claim { computer: computer.to_string(), name: host_name(), since, renewed: now, until: now + ALIVE, active: active.max(old.as_ref().map_or(0, |c| c.active)), taken, wrote, closed: false, pad: String::new() };
197    write(folder, key, part, computer, &claim)?;
198    claims.retain(|c| c.computer != computer);
199    claims.push(claim);
200    Ok(keeper(&claims, part, computer, rule, now))
201}
202
203/// A claim sealed into its file, its size changed from the one there (`share::pad_for`).
204fn write(folder: &Path, key: &[u8; 32], part: &str, computer: &str, claim: &Claim) -> Result<(), String> {
205    let path = folder_of(folder, part).join(format!("{computer}.lease"));
206    let sealed = |pad: usize| serde_json::to_vec(&Claim { pad: ".".repeat(pad), ..claim.clone() }).map(|plain| crate::share::seal(key, &bound(part, computer), &plain)).map_err(|e| e.to_string());
207    let pad = crate::share::pad_for(&path, |pad| sealed(pad).map_or(0, |s| s.len() as u64));
208    crate::share::write_atomically(&path, sealed(pad)?.as_bytes())
209}
210
211/// Who keeps `part`, as read now, without claiming it.
212pub fn look(folder: &Path, key: &[u8; 32], part: &str, computer: &str, now: i64, rule: Rule) -> Keeper {
213    keeper(&read(folder, key, part), part, computer, rule, now)
214}
215
216fn keeper(claims: &[Claim], part: &str, computer: &str, rule: Rule, now: i64) -> Keeper {
217    let others: Vec<&Claim> = claims.iter().filter(|c| c.computer != computer && holds(c, rule, now)).collect();
218    let heard = others.iter().map(|c| c.renewed);
219    let others_heard = match rule {
220        Rule::FollowsYou => heard.max(),
221        // Invoices: each other computer holding them heard from lately, the least lately one counts.
222        Rule::StaysPut => heard.min(),
223    }
224    .unwrap_or(0);
225let Some(chosen) = choose(claims, rule, now) else { return Keeper::alone(computer) };
226    let mine = chosen.computer == computer;
227    // Settled: this computer has kept it, as seen here, for the settling time.
228    let settled = match KEPT.lock() {
229        Ok(mut kept) => {
230            let entry = kept.entry(part.to_string()).or_insert_with(|| (chosen.computer.clone(), now));
231            if entry.0 != chosen.computer {
232                *entry = (chosen.computer.clone(), now);
233            }
234            mine && (others.is_empty() || now - entry.1 >= SETTLING)
235        }
236        Err(_) => false,
237    };
238    Keeper { computer: chosen.computer.clone(), name: chosen.name.clone(), mine, settled, others_heard, others: others.len() }
239}
240
241/// This computer's claim on `part` closed: Sioul quits, or is put away on a
242/// phone. It keeps nothing, and says how far it wrote its records, so the
243/// others know everything it marked before it closed.
244pub fn close(folder: &Path, key: &[u8; 32], part: &str, computer: &str, now: i64, wrote: Option<(u32, u64)>) -> Result<(), String> {
245    let old = read(folder, key, part).into_iter().find(|c| c.computer == computer);
246    let claim = Claim { computer: computer.to_string(), name: host_name(), since: now, renewed: now, until: now, active: old.as_ref().map_or(0, |c| c.active), taken: old.as_ref().map_or(0, |c| c.taken), wrote, closed: true, pad: String::new() };
247    write(folder, key, part, computer, &claim)?;
248    if let Ok(mut kept) = KEPT.lock() {
249        kept.remove(part);
250    }
251    Ok(())
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    fn folder(name: &str) -> PathBuf {
259        let dir = std::env::temp_dir().join(format!("sioul-lease-{name}-{}", std::process::id()));
260        let _ = std::fs::remove_dir_all(&dir);
261        dir
262    }
263
264    #[test]
265    fn medicines_follow_you() {
266        let dir = folder("follow");
267        let key = [7u8; 32];
268        // The desktop alone: it keeps them at once, nobody else to wait for.
269        let alone = renew(&dir, &key, "health", "desktop", 1000, 1000, false, Rule::FollowsYou, None).unwrap();
270        assert!(alone.mine && alone.settled);
271        // The laptop opens and is used: it keeps them, but acts only once settled.
272        let laptop = renew(&dir, &key, "health", "laptop", 1060, 1060, false, Rule::FollowsYou, None).unwrap();
273        assert!(laptop.mine && !laptop.settled, "the desktop may not have seen it yet");
274        let desktop = renew(&dir, &key, "health", "desktop", 1070, 1000, false, Rule::FollowsYou, None).unwrap();
275        assert!(!desktop.mine, "the desktop steps back");
276        assert_eq!(desktop.others, 1);
277        let later = renew(&dir, &key, "health", "laptop", 1060 + SETTLING, 1060 + SETTLING, false, Rule::FollowsYou, None).unwrap();
278        assert!(later.mine && later.settled);
279        // The laptop closes: it says so, and how far it wrote; the desktop keeps them again.
280        close(&dir, &key, "health", "laptop", 1190, Some((1, 12))).unwrap();
281        let closed = claims(&dir, &key, "health").into_iter().find(|c| c.computer == "laptop").unwrap();
282        assert!(closed.closed && closed.wrote == Some((1, 12)) && closed.until <= 1190);
283        let back = renew(&dir, &key, "health", "desktop", 1200, 1200, false, Rule::FollowsYou, None).unwrap();
284        assert!(back.mine);
285        let _ = std::fs::remove_dir_all(&dir);
286    }
287
288    #[test]
289    fn invoices_stay_put() {
290        let dir = folder("stay");
291        let key = [9u8; 32];
292        let first = renew(&dir, &key, "invoices", "desktop", 1000, 1000, false, Rule::StaysPut, None).unwrap();
293        assert!(first.mine);
294        // Used later, the laptop does not take them: the oldest keeps them.
295        let laptop = renew(&dir, &key, "invoices", "laptop", 1100, 1100, false, Rule::StaysPut, None).unwrap();
296        assert!(!laptop.mine && laptop.name == host_name());
297        // Taken on purpose: the laptop keeps them, once settled.
298        let taken = renew(&dir, &key, "invoices", "laptop", 1200, 1200, true, Rule::StaysPut, None).unwrap();
299        assert!(taken.mine && !taken.settled);
300        assert!(!look(&dir, &key, "invoices", "desktop", 1210, Rule::StaysPut).mine);
301        // A claim not renewed, not closed, still holds them a day (until 10 October 2026 it died
302        // with its five minutes, and another computer numbered too: the review's F7).
303        assert!(!look(&dir, &key, "invoices", "desktop", 1200 + ALIVE + 1, Rule::StaysPut).mine, "a lapsed claim, not closed, still holds them");
304        assert!(look(&dir, &key, "invoices", "desktop", 1200 + UNCLOSED + 1, Rule::StaysPut).mine, "a day without news: it keeps nothing");
305        // Another key reads nothing: a stranger's claims do not count.
306        assert!(look(&dir, &[1u8; 32], "invoices", "desktop", 1210, Rule::StaysPut).mine);
307        let _ = std::fs::remove_dir_all(&dir);
308    }
309
310    /// The review of 5 October 2026, F7: the desktop numbers invoices; on the
311    /// phone its claim lapsed (a sync app half an hour late, a sync stopped),
312    /// not closed. The phone never numbers meanwhile: the desktop keeps them;
313    /// taken on purpose, the phone still waits until the desktop is heard
314    /// from, closes, or a day went by without its news.
315    #[test]
316    fn invoices_stay_with_a_computer_not_heard_from_until_it_closes() {
317        let dir = folder("unclosed");
318        let key = [5u8; 32];
319        renew(&dir, &key, "invoices", "desktop", 1000, 1000, false, Rule::StaysPut, None).unwrap();
320        // Ten minutes on, its claim not renewed here: the phone does not keep them.
321        let phone = renew(&dir, &key, "invoices", "phone", 1600, 1600, false, Rule::StaysPut, None).unwrap();
322        assert!(!phone.mine, "a computer not heard from is not a computer gone: {phone:?}");
323        // Taken here on purpose: kept here, but the desktop, not heard from for ten minutes, holds the numbers back.
324        let taken = renew(&dir, &key, "invoices", "phone", 1700, 1700, true, Rule::StaysPut, None).unwrap();
325        assert!(taken.mine && taken.others == 1 && taken.others_heard == 1000, "{taken:?}");
326        // The desktop closes: the phone alone.
327        close(&dir, &key, "invoices", "desktop", 1800, None).unwrap();
328        let alone = renew(&dir, &key, "invoices", "phone", 1900, 1900, false, Rule::StaysPut, None).unwrap();
329        assert!(alone.mine && alone.others == 0, "{alone:?}");
330        // A computer gone without closing holds them a day, then no more.
331        renew(&dir, &key, "invoices", "laptop", 2000, 2000, true, Rule::StaysPut, None).unwrap();
332        assert!(!look(&dir, &key, "invoices", "phone", 2000 + UNCLOSED, Rule::StaysPut).mine);
333        assert!(look(&dir, &key, "invoices", "phone", 2001 + UNCLOSED, Rule::StaysPut).mine);
334        // A part that follows you (medicines; its own name here, as `KEPT` is the process's): a lapsed claim keeps nothing, as before.
335        renew(&dir, &key, "follows", "desktop", 1000, 1000, false, Rule::FollowsYou, None).unwrap();
336        assert!(renew(&dir, &key, "follows", "phone", 1600, 1600, false, Rule::FollowsYou, None).unwrap().mine);
337        let _ = std::fs::remove_dir_all(&dir);
338    }
339}