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    /// Since when this keeper has kept it, as seen by this process (Unix
97    /// seconds): alone, since this process began (`started`), never "always"
98    /// (the sixth review's R2). A dose's first reminder is this computer's
99    /// only if it kept the doses at the dose's time (docs/health.md, "What
100    /// became of a reminder").
101    pub since: i64,
102}
103
104/// When this process began, as the lease first asked (Unix seconds).
105pub fn started() -> i64 {
106    static STARTED: std::sync::OnceLock<i64> = std::sync::OnceLock::new();
107    *STARTED.get_or_init(|| std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64))
108}
109
110impl Keeper {
111    /// The only computer: no sharing, or nobody else claims.
112    pub fn alone(computer: &str) -> Keeper {
113        Keeper { computer: computer.to_string(), name: host_name(), mine: true, settled: true, others_heard: 0, others: 0, since: started() }
114    }
115}
116
117/// This computer's name, as people call it: its host name, asked once. On a
118/// Mac no file holds it and a program tells it: started at each claim (three
119/// a minute), it also held for a moment a copy of every file Sioul had open,
120/// the sharing's lock among them (`share::exchange_lock`).
121pub fn host_name() -> String {
122    static NAME: std::sync::OnceLock<String> = std::sync::OnceLock::new();
123    NAME.get_or_init(|| {
124        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()));
125        named.map(|n| n.trim().to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| "?".into())
126    })
127    .clone()
128}
129
130fn folder_of(folder: &Path, part: &str) -> PathBuf {
131    folder.join("leases").join(part)
132}
133
134fn bound(part: &str, computer: &str) -> String {
135    format!("lease{}{part}{}{computer}", '\u{1f}', '\u{1f}')
136}
137
138/// Every claim on `part` that reads, live or not.
139pub fn claims(folder: &Path, key: &[u8; 32], part: &str) -> Vec<Claim> {
140    read(folder, key, part)
141}
142
143/// The claims on `part`: of one fetched from the server too
144/// (`remote::overlay`), the later renewed of the two copies; the folder's on a tie.
145fn read(folder: &Path, key: &[u8; 32], part: &str) -> Vec<Claim> {
146    let mut by: BTreeMap<String, Claim> = BTreeMap::new();
147    for dir in std::iter::once(folder.to_path_buf()).chain(crate::remote::overlay(folder)) {
148        for claim in read_in(&dir, key, part) {
149            match by.get(&claim.computer) {
150                Some(kept) if (kept.renewed, kept.until) >= (claim.renewed, claim.until) => {}
151                _ => {
152                    by.insert(claim.computer.clone(), claim);
153                }
154            }
155        }
156    }
157    by.into_values().collect()
158}
159
160fn read_in(folder: &Path, key: &[u8; 32], part: &str) -> Vec<Claim> {
161    let Ok(entries) = std::fs::read_dir(folder_of(folder, part)) else { return Vec::new() };
162    entries
163        .filter_map(Result::ok)
164        .map(|e| e.path())
165        .filter(|p| p.extension().is_some_and(|x| x == "lease"))
166        .filter_map(|path| {
167            let computer = path.file_stem()?.to_string_lossy().to_string();
168            // A claim is a few hundred bytes: a larger file is no claim, never read whole.
169            let text = crate::share::read_small(&path)?;
170            let plain = crate::share::open(key, &bound(part, &computer), text.trim())?;
171            let claim: Claim = serde_json::from_slice(&plain).ok()?;
172            // A claim names the computer whose file it is, or it is no claim.
173            (claim.computer == computer).then_some(claim)
174        })
175        .collect()
176}
177
178/// Whether a claim holds its part now: alive; for a part that stays put,
179/// lapsed but not closed, within `UNCLOSED` of its last renewal too.
180fn holds(claim: &Claim, rule: Rule, now: i64) -> bool {
181    claim.until > now || (rule == Rule::StaysPut && !claim.closed && now - claim.renewed <= UNCLOSED)
182}
183
184/// The keeper among the claims that hold the part (`holds`), by the part's
185/// rule; ties go to the smaller id.
186fn choose<'a>(claims: &'a [Claim], rule: Rule, now: i64) -> Option<&'a Claim> {
187    let live = claims.iter().filter(|c| holds(c, rule, now));
188    match rule {
189        Rule::FollowsYou => live.max_by(|a, b| a.active.cmp(&b.active).then_with(|| b.computer.cmp(&a.computer))),
190        // The last taken on purpose; none taken: the oldest claim.
191        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))),
192    }
193}
194
195/// Since when this computer keeps each part, as seen here: (part → (keeper, since)).
196static KEPT: Mutex<BTreeMap<String, (String, i64)>> = Mutex::new(BTreeMap::new());
197
198/// This computer's claim on `part`, renewed (made the first time): `active`
199/// is when you were last at this computer, `take` takes the part on purpose,
200/// `wrote` how far it wrote its records. Returns who keeps it now.
201#[allow(clippy::too_many_arguments)]
202pub 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> {
203    let mut claims = read(folder, key, part);
204    let old = claims.iter().find(|c| c.computer == computer).cloned();
205    // A claim that lapsed, or closed, starts again: its age counts from now.
206    let since = old.as_ref().filter(|c| c.until > now && !c.closed).map_or(now, |c| c.since);
207    let taken = if take { now } else { old.as_ref().map_or(0, |c| c.taken) };
208    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() };
209    write(folder, key, part, computer, &claim)?;
210    claims.retain(|c| c.computer != computer);
211    claims.push(claim);
212    Ok(keeper(&claims, part, computer, rule, now))
213}
214
215/// A claim sealed into its file, its size changed from the one there (`share::pad_for`).
216fn write(folder: &Path, key: &[u8; 32], part: &str, computer: &str, claim: &Claim) -> Result<(), String> {
217    let path = folder_of(folder, part).join(format!("{computer}.lease"));
218    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());
219    let pad = crate::share::pad_for(&path, |pad| sealed(pad).map_or(0, |s| s.len() as u64));
220    crate::share::write_atomically(&path, sealed(pad)?.as_bytes())
221}
222
223/// Who keeps `part`, as read now, without claiming it.
224pub fn look(folder: &Path, key: &[u8; 32], part: &str, computer: &str, now: i64, rule: Rule) -> Keeper {
225    keeper(&read(folder, key, part), part, computer, rule, now)
226}
227
228fn keeper(claims: &[Claim], part: &str, computer: &str, rule: Rule, now: i64) -> Keeper {
229    let others: Vec<&Claim> = claims.iter().filter(|c| c.computer != computer && holds(c, rule, now)).collect();
230    let heard = others.iter().map(|c| c.renewed);
231    let others_heard = match rule {
232        Rule::FollowsYou => heard.max(),
233        // Invoices: each other computer holding them heard from lately, the least lately one counts.
234        Rule::StaysPut => heard.min(),
235    }
236    .unwrap_or(0);
237let Some(chosen) = choose(claims, rule, now) else { return Keeper::alone(computer) };
238    let mine = chosen.computer == computer;
239    // Settled: this computer has kept it, as seen here, for the settling time.
240    let (settled, since) = match KEPT.lock() {
241        Ok(mut kept) => {
242            let entry = kept.entry(part.to_string()).or_insert_with(|| (chosen.computer.clone(), now));
243            if entry.0 != chosen.computer {
244                *entry = (chosen.computer.clone(), now);
245            }
246            (mine && (others.is_empty() || now - entry.1 >= SETTLING), entry.1)
247        }
248        Err(_) => (false, now),
249    };
250    Keeper { computer: chosen.computer.clone(), name: chosen.name.clone(), mine, settled, others_heard, others: others.len(), since }
251}
252
253/// This computer's claim on `part` closed: Sioul quits, or is put away on a
254/// phone. It keeps nothing, and says how far it wrote its records, so the
255/// others know everything it marked before it closed.
256pub fn close(folder: &Path, key: &[u8; 32], part: &str, computer: &str, now: i64, wrote: Option<(u32, u64)>) -> Result<(), String> {
257    let old = read(folder, key, part).into_iter().find(|c| c.computer == computer);
258    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() };
259    write(folder, key, part, computer, &claim)?;
260    if let Ok(mut kept) = KEPT.lock() {
261        kept.remove(part);
262    }
263    Ok(())
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    fn folder(name: &str) -> PathBuf {
271        let dir = std::env::temp_dir().join(format!("sioul-lease-{name}-{}", std::process::id()));
272        let _ = std::fs::remove_dir_all(&dir);
273        dir
274    }
275
276    #[test]
277    fn medicines_follow_you() {
278        let dir = folder("follow");
279        let key = [7u8; 32];
280        // The desktop alone: it keeps them at once, nobody else to wait for.
281        let alone = renew(&dir, &key, "health", "desktop", 1000, 1000, false, Rule::FollowsYou, None).unwrap();
282        assert!(alone.mine && alone.settled);
283        // The laptop opens and is used: it keeps them, but acts only once settled.
284        let laptop = renew(&dir, &key, "health", "laptop", 1060, 1060, false, Rule::FollowsYou, None).unwrap();
285        assert!(laptop.mine && !laptop.settled, "the desktop may not have seen it yet");
286        let desktop = renew(&dir, &key, "health", "desktop", 1070, 1000, false, Rule::FollowsYou, None).unwrap();
287        assert!(!desktop.mine, "the desktop steps back");
288        assert_eq!(desktop.others, 1);
289        let later = renew(&dir, &key, "health", "laptop", 1060 + SETTLING, 1060 + SETTLING, false, Rule::FollowsYou, None).unwrap();
290        assert!(later.mine && later.settled);
291        // The laptop closes: it says so, and how far it wrote; the desktop keeps them again.
292        close(&dir, &key, "health", "laptop", 1190, Some((1, 12))).unwrap();
293        let closed = claims(&dir, &key, "health").into_iter().find(|c| c.computer == "laptop").unwrap();
294        assert!(closed.closed && closed.wrote == Some((1, 12)) && closed.until <= 1190);
295        let back = renew(&dir, &key, "health", "desktop", 1200, 1200, false, Rule::FollowsYou, None).unwrap();
296        assert!(back.mine);
297        let _ = std::fs::remove_dir_all(&dir);
298    }
299
300    #[test]
301    fn invoices_stay_put() {
302        let dir = folder("stay");
303        let key = [9u8; 32];
304        let first = renew(&dir, &key, "invoices", "desktop", 1000, 1000, false, Rule::StaysPut, None).unwrap();
305        assert!(first.mine);
306        // Used later, the laptop does not take them: the oldest keeps them.
307        let laptop = renew(&dir, &key, "invoices", "laptop", 1100, 1100, false, Rule::StaysPut, None).unwrap();
308        assert!(!laptop.mine && laptop.name == host_name());
309        // Taken on purpose: the laptop keeps them, once settled.
310        let taken = renew(&dir, &key, "invoices", "laptop", 1200, 1200, true, Rule::StaysPut, None).unwrap();
311        assert!(taken.mine && !taken.settled);
312        assert!(!look(&dir, &key, "invoices", "desktop", 1210, Rule::StaysPut).mine);
313        // A claim not renewed, not closed, still holds them a day (until 10 October 2026 it died
314        // with its five minutes, and another computer numbered too: the review's F7).
315        assert!(!look(&dir, &key, "invoices", "desktop", 1200 + ALIVE + 1, Rule::StaysPut).mine, "a lapsed claim, not closed, still holds them");
316        assert!(look(&dir, &key, "invoices", "desktop", 1200 + UNCLOSED + 1, Rule::StaysPut).mine, "a day without news: it keeps nothing");
317        // Another key reads nothing: a stranger's claims do not count.
318        assert!(look(&dir, &[1u8; 32], "invoices", "desktop", 1210, Rule::StaysPut).mine);
319        let _ = std::fs::remove_dir_all(&dir);
320    }
321
322    /// The review of 5 October 2026, F7: the desktop numbers invoices; on the
323    /// phone its claim lapsed (a sync app half an hour late, a sync stopped),
324    /// not closed. The phone never numbers meanwhile: the desktop keeps them;
325    /// taken on purpose, the phone still waits until the desktop is heard
326    /// from, closes, or a day went by without its news.
327    #[test]
328    fn invoices_stay_with_a_computer_not_heard_from_until_it_closes() {
329        let dir = folder("unclosed");
330        let key = [5u8; 32];
331        renew(&dir, &key, "invoices", "desktop", 1000, 1000, false, Rule::StaysPut, None).unwrap();
332        // Ten minutes on, its claim not renewed here: the phone does not keep them.
333        let phone = renew(&dir, &key, "invoices", "phone", 1600, 1600, false, Rule::StaysPut, None).unwrap();
334        assert!(!phone.mine, "a computer not heard from is not a computer gone: {phone:?}");
335        // Taken here on purpose: kept here, but the desktop, not heard from for ten minutes, holds the numbers back.
336        let taken = renew(&dir, &key, "invoices", "phone", 1700, 1700, true, Rule::StaysPut, None).unwrap();
337        assert!(taken.mine && taken.others == 1 && taken.others_heard == 1000, "{taken:?}");
338        // The desktop closes: the phone alone.
339        close(&dir, &key, "invoices", "desktop", 1800, None).unwrap();
340        let alone = renew(&dir, &key, "invoices", "phone", 1900, 1900, false, Rule::StaysPut, None).unwrap();
341        assert!(alone.mine && alone.others == 0, "{alone:?}");
342        // A computer gone without closing holds them a day, then no more.
343        renew(&dir, &key, "invoices", "laptop", 2000, 2000, true, Rule::StaysPut, None).unwrap();
344        assert!(!look(&dir, &key, "invoices", "phone", 2000 + UNCLOSED, Rule::StaysPut).mine);
345        assert!(look(&dir, &key, "invoices", "phone", 2001 + UNCLOSED, Rule::StaysPut).mine);
346        // A part that follows you (medicines; its own name here, as `KEPT` is the process's): a lapsed claim keeps nothing, as before.
347        renew(&dir, &key, "follows", "desktop", 1000, 1000, false, Rule::FollowsYou, None).unwrap();
348        assert!(renew(&dir, &key, "follows", "phone", 1600, 1600, false, Rule::FollowsYou, None).unwrap().mine);
349        let _ = std::fs::remove_dir_all(&dir);
350    }
351}