Skip to main content

rightkit_daemon/
discovery.rs

1//! Installed app discovery and single-winner spawn coordination.
2//!
3//! Discovery is deliberately installation-bound: it scans known application
4//! bundles (macOS) or uninstall registry entries (Windows), never `PATH` or a
5//! checkout. Spawn claims use `create_new`; discovery is daemon-owned and is
6//! never removed by a losing launcher.
7
8use crate::record::{
9    read_live_record, try_read_live_record, try_read_record, write_takeover, ServiceRecord,
10    TakeoverRequest, RANK_UNKNOWN,
11};
12use crate::suite::{make_private_dir, Suite};
13use crate::transport::{pid_alive, probe_endpoint};
14use serde::Serialize;
15use std::collections::BTreeMap;
16use std::io;
17use std::path::{Path, PathBuf};
18use std::process::{Command, Stdio};
19use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
20
21/// Installed Right Suite applications and their engine executable.
22#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
23pub struct InstalledApp {
24    pub app: String,
25    pub app_path: String,
26    pub engine_path: String,
27    pub version: Option<String>,
28    pub source: String,
29    /// Whether the engine executable was found. Always [`Completeness::Complete`] unless
30    /// [`DiscoverOptions::include_incomplete`] was set.
31    pub completeness: Completeness,
32}
33
34/// Whether an installed app is usable as a service host.
35#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq, Default)]
36#[serde(rename_all = "snake_case")]
37pub enum Completeness {
38    /// The app bundle and its engine executable are both present.
39    #[default]
40    Complete,
41    /// The app is installed but its engine executable is missing (a broken or partial
42    /// install). `engine_path` names where the engine was expected.
43    EngineMissing,
44}
45
46/// Options for discovery. `DiscoverOptions::default()` reproduces the original behaviour.
47#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
48pub struct DiscoverOptions {
49    /// Also return an app whose bundle is installed but whose engine is missing, marked
50    /// [`Completeness::EngineMissing`]. A complete install in any root always wins over an
51    /// incomplete one. Default `false`. Honoured on macOS ([`discover_macos_with`]) and on
52    /// Windows ([`discover_windows_with`], where an entry whose install directory exists
53    /// but whose engine file is missing is reported).
54    pub include_incomplete: bool,
55}
56
57/// The original Right Suite table. Deprecated: the crate hard-coded one product
58/// suite's names. Products pass their own `(app name, engine executable)` registry to
59/// [`discover_apps_in`]. Kept with identical contents so existing callers behave the same
60/// in this release.
61#[deprecated(
62    since = "0.1.2",
63    note = "pass your own (app, engine) registry to discover_apps_in"
64)]
65pub const KNOWN_APPS: &[(&str, &str)] = SUITE_APPS;
66
67/// Built-in table behind the deprecated [`KNOWN_APPS`] and [`discover_apps`].
68const SUITE_APPS: &[(&str, &str)] = &[
69    ("HeardRight", "heardright-engine"),
70    ("ScrapeRight", "scraperight-engine"),
71    ("GenRight", "genright-engine"),
72    ("CutRight", "cutright-engine"),
73];
74
75/// Discover installed apps from the caller's `(app bundle or display name, engine executable)`
76/// registry. The first complete match per app wins.
77pub fn discover_apps_in(registry: &[(&str, &str)]) -> Vec<InstalledApp> {
78    discover_apps_in_with(registry, &DiscoverOptions::default())
79}
80
81/// [`discover_apps_in`] with explicit [`DiscoverOptions`].
82pub fn discover_apps_in_with(
83    registry: &[(&str, &str)],
84    options: &DiscoverOptions,
85) -> Vec<InstalledApp> {
86    #[cfg(target_os = "macos")]
87    {
88        discover_macos_with(&default_macos_roots(), registry, options)
89    }
90    #[cfg(windows)]
91    {
92        discover_windows_with(
93            &read_windows_uninstall_entries(),
94            registry,
95            |p| Path::new(p).is_file(),
96            |p| Path::new(p).is_dir(),
97            options,
98        )
99    }
100    #[cfg(not(any(target_os = "macos", windows)))]
101    {
102        let _ = (registry, options);
103        Vec::new()
104    }
105}
106
107#[cfg(target_os = "macos")]
108fn default_macos_roots() -> Vec<PathBuf> {
109    let mut roots = vec![PathBuf::from("/Applications")];
110    // The user's own Applications folder comes from the account database (`getpwuid_r`), never
111    // from `HOME`: whoever launches the process controls `HOME`, and this root decides which
112    // installed apps are trusted as siblings.
113    if let Ok(home) = crate::suite::passwd_home_dir() {
114        roots.push(home.join("Applications"));
115    }
116    roots
117}
118
119/// Discover the built-in Right Suite apps. Deprecated: use [`discover_apps_in`].
120#[deprecated(since = "0.1.2", note = "use discover_apps_in with your own registry")]
121pub fn discover_apps() -> Vec<InstalledApp> {
122    #[allow(deprecated)]
123    discover_apps_in(KNOWN_APPS)
124}
125
126/// Scan roots in order; first complete bundle wins per app.
127pub fn discover_macos(roots: &[PathBuf], apps: &[(&str, &str)]) -> Vec<InstalledApp> {
128    discover_macos_with(roots, apps, &DiscoverOptions::default())
129}
130
131/// [`discover_macos`] with explicit [`DiscoverOptions`]. With `include_incomplete`, an app
132/// that has a bundle directory but no engine in any root is returned once, from the first
133/// root that has its bundle, as [`Completeness::EngineMissing`].
134pub fn discover_macos_with(
135    roots: &[PathBuf],
136    apps: &[(&str, &str)],
137    options: &DiscoverOptions,
138) -> Vec<InstalledApp> {
139    let mut found = Vec::new();
140    for (name, engine) in apps {
141        let mut incomplete: Option<InstalledApp> = None;
142        let mut complete: Option<InstalledApp> = None;
143        for root in roots {
144            let bundle = root.join(format!("{name}.app"));
145            let binary = bundle.join("Contents/MacOS").join(engine);
146            let present = binary.is_file();
147            if !present && (!options.include_incomplete || incomplete.is_some() || !bundle.is_dir())
148            {
149                continue;
150            }
151            let app = InstalledApp {
152                app: (*name).to_string(),
153                app_path: bundle.to_string_lossy().into_owned(),
154                engine_path: binary.to_string_lossy().into_owned(),
155                version: read_plist_version(&bundle.join("Contents/Info.plist")),
156                source: "macos-bundle".into(),
157                completeness: if present {
158                    Completeness::Complete
159                } else {
160                    Completeness::EngineMissing
161                },
162            };
163            if present {
164                complete = Some(app);
165                break;
166            }
167            incomplete = Some(app);
168        }
169        found.extend(complete.or(incomplete));
170    }
171    found
172}
173
174fn read_plist_version(path: &Path) -> Option<String> {
175    parse_plist_string(
176        &std::fs::read_to_string(path).ok()?,
177        "CFBundleShortVersionString",
178    )
179}
180
181/// Parse a plain XML plist without requiring a plist dependency. Binary plist
182/// files return `None`, while the app remains discoverable.
183pub fn parse_plist_string(xml: &str, key: &str) -> Option<String> {
184    let marker = format!("<key>{key}</key>");
185    let after = &xml[xml.find(&marker)? + marker.len()..];
186    let string_start = after.find("<string>")? + "<string>".len();
187    if after[..string_start].contains("<key>") {
188        return None;
189    }
190    let end = after[string_start..].find("</string>")? + string_start;
191    let value = after[string_start..end].trim();
192    (!value.is_empty()).then(|| value.to_string())
193}
194
195/// One uninstall registry row, independent of the Windows registry API so it
196/// can be tested from captured `reg query` output.
197#[derive(Debug, Clone, Default, PartialEq, Eq)]
198pub struct UninstallEntry {
199    pub display_name: String,
200    pub install_location: Option<String>,
201    pub display_icon: Option<String>,
202    pub uninstall_string: Option<String>,
203    pub display_version: Option<String>,
204}
205
206/// Parse `reg query ... /s` output. Unknown values and malformed rows are
207/// ignored; keys are grouped by their registry-key heading.
208pub fn parse_reg_query(text: &str) -> Vec<UninstallEntry> {
209    let mut out = Vec::new();
210    let mut current = UninstallEntry::default();
211    let mut active = false;
212    for raw in text.lines() {
213        let line = raw.trim_end();
214        let trimmed = line.trim();
215        if trimmed.starts_with("HKEY_") {
216            if active && !current.display_name.is_empty() {
217                out.push(current);
218            }
219            current = UninstallEntry::default();
220            active = true;
221            continue;
222        }
223        let mut fields = trimmed.split_whitespace();
224        let Some(name) = fields.next() else { continue };
225        let Some(_kind) = fields.next() else { continue };
226        let value = fields.collect::<Vec<_>>().join(" ");
227        if value.is_empty() {
228            continue;
229        }
230        match name {
231            "DisplayName" => current.display_name = value,
232            "InstallLocation" => current.install_location = Some(value),
233            "DisplayIcon" => current.display_icon = Some(value),
234            "UninstallString" => current.uninstall_string = Some(value),
235            "DisplayVersion" => current.display_version = Some(value),
236            _ => {}
237        }
238    }
239    if active && !current.display_name.is_empty() {
240        out.push(current);
241    }
242    out
243}
244
245fn strip_quotes(value: &str) -> &str {
246    value.trim().trim_matches('"').trim()
247}
248
249fn parent(path: &str) -> Option<String> {
250    let i = path.rfind(['\\', '/'])?;
251    (!path[..i].is_empty()).then(|| path[..i].to_string())
252}
253
254fn executable_from_value(value: &str) -> Option<String> {
255    let value = value.trim();
256    let path = if let Some(rest) = value.strip_prefix('"') {
257        rest.split('"').next()?.to_string()
258    } else {
259        let lower = value.to_ascii_lowercase();
260        let end = lower.find(".exe").map(|i| i + 4).unwrap_or(value.len());
261        value[..end].to_string()
262    };
263    (!path.is_empty()).then_some(path)
264}
265
266pub fn resolve_install_dir(entry: &UninstallEntry) -> Option<String> {
267    if let Some(loc) = entry.install_location.as_deref() {
268        let loc = strip_quotes(loc).trim_end_matches(['\\', '/']);
269        if !loc.is_empty() {
270            return Some(loc.to_string());
271        }
272    }
273    entry
274        .display_icon
275        .as_deref()
276        .and_then(executable_from_value)
277        .and_then(|p| parent(&p))
278        .or_else(|| {
279            entry
280                .uninstall_string
281                .as_deref()
282                .and_then(executable_from_value)
283                .and_then(|p| parent(&p))
284        })
285}
286
287/// Discover complete Windows installs only. Equivalent to [`discover_windows_with`] with
288/// [`DiscoverOptions::default`].
289pub fn discover_windows(
290    entries: &[UninstallEntry],
291    apps: &[(&str, &str)],
292    exists: impl Fn(&str) -> bool,
293) -> Vec<InstalledApp> {
294    discover_windows_with(
295        entries,
296        apps,
297        exists,
298        |_| false,
299        &DiscoverOptions::default(),
300    )
301}
302
303/// Windows discovery with explicit options. `exists` checks the engine file and `dir_exists`
304/// checks the install directory. With `include_incomplete`, an entry whose directory exists but
305/// whose engine is missing is returned as [`Completeness::EngineMissing`]. A complete install
306/// from any entry of the same app always wins.
307pub fn discover_windows_with(
308    entries: &[UninstallEntry],
309    apps: &[(&str, &str)],
310    exists: impl Fn(&str) -> bool,
311    dir_exists: impl Fn(&str) -> bool,
312    options: &DiscoverOptions,
313) -> Vec<InstalledApp> {
314    let mut found = Vec::new();
315    for (name, engine) in apps {
316        let mut incomplete: Option<InstalledApp> = None;
317        let mut complete: Option<InstalledApp> = None;
318        for entry in entries.iter().filter(|entry| entry.display_name == *name) {
319            let Some(dir) = resolve_install_dir(entry) else {
320                continue;
321            };
322            let engine_path = format!(r"{dir}\{engine}.exe");
323            let present = exists(&engine_path);
324            if !present
325                && (!options.include_incomplete || incomplete.is_some() || !dir_exists(&dir))
326            {
327                continue;
328            }
329            let app = InstalledApp {
330                app: (*name).to_string(),
331                app_path: dir,
332                engine_path,
333                version: entry
334                    .display_version
335                    .clone()
336                    .filter(|v| !v.trim().is_empty()),
337                source: "windows-uninstall".into(),
338                completeness: if present {
339                    Completeness::Complete
340                } else {
341                    Completeness::EngineMissing
342                },
343            };
344            if present {
345                complete = Some(app);
346                break;
347            }
348            incomplete = Some(app);
349        }
350        if let Some(app) = complete.or(incomplete) {
351            found.push(app);
352        }
353    }
354    found
355}
356
357#[derive(Debug)]
358pub enum SpawnClaimError {
359    AlreadyHeld(PathBuf),
360    Io(io::Error),
361}
362impl std::fmt::Display for SpawnClaimError {
363    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
364        match self {
365            Self::AlreadyHeld(path) => write!(f, "spawn claim is held: {}", path.display()),
366            Self::Io(e) => write!(f, "spawn claim: {e}"),
367        }
368    }
369}
370impl std::error::Error for SpawnClaimError {}
371impl From<io::Error> for SpawnClaimError {
372    fn from(e: io::Error) -> Self {
373        Self::Io(e)
374    }
375}
376
377/// The claim around one process spawn attempt, backed by an OS exclusive lock
378/// on `spawn.claim`. The lock lives as long as the open file: the kernel
379/// releases it when the owner dies (any signal), and there is no time-based
380/// eviction of a live owner. The file is never unlinked, so every contender
381/// always locks the same inode; Drop closes only this claim's own handle.
382pub struct SpawnClaim {
383    _file: std::fs::File,
384    path: PathBuf,
385}
386
387impl SpawnClaim {
388    pub fn path(suite: &Suite, service: &str) -> PathBuf {
389        suite.service_dir(service).join("spawn.claim")
390    }
391
392    pub fn acquire(suite: &Suite, service: &str) -> Result<Self, SpawnClaimError> {
393        make_private_dir(&suite.service_dir(service))
394            .map_err(|e| SpawnClaimError::Io(io::Error::other(e.to_string())))?;
395        let path = Self::path(suite, service);
396        match try_lock_exclusive(&path) {
397            Ok(Some(file)) => Ok(Self { _file: file, path }),
398            Ok(None) => Err(SpawnClaimError::AlreadyHeld(path)),
399            Err(e) => Err(SpawnClaimError::Io(e)),
400        }
401    }
402
403    /// Path of the claim file (for diagnostics).
404    pub fn claim_path(&self) -> &Path {
405        &self.path
406    }
407}
408
409/// Open `path` and take a non-blocking exclusive lock. `Ok(None)` = held elsewhere.
410#[cfg(unix)]
411pub(crate) fn try_lock_exclusive(path: &Path) -> io::Result<Option<std::fs::File>> {
412    use std::fs::TryLockError;
413    use std::io::Write;
414    let mut f = std::fs::OpenOptions::new()
415        .read(true)
416        .write(true)
417        .create(true)
418        .truncate(false)
419        .open(path)?;
420    // std's `File::try_lock` is a non-blocking `flock(LOCK_EX | LOCK_NB)` on Unix.
421    match f.try_lock() {
422        Ok(()) => {}
423        Err(TryLockError::WouldBlock) => return Ok(None),
424        Err(TryLockError::Error(e)) => return Err(e),
425    }
426    // Informational only (we hold the lock, so the write is ours).
427    let _ = f.set_len(0);
428    let _ = f.write_all(std::process::id().to_string().as_bytes());
429    Ok(Some(f))
430}
431
432/// Windows: open with share mode 0 (no sharing). A second open fails with
433/// ERROR_SHARING_VIOLATION (32) while the first handle lives; the kernel closes
434/// the handle on process death. Not compiled on this host; std-only API
435/// (`OpenOptionsExt::share_mode`), checked by reasoning against its docs.
436#[cfg(windows)]
437pub(crate) fn try_lock_exclusive(path: &Path) -> io::Result<Option<std::fs::File>> {
438    use std::io::Write;
439    use std::os::windows::fs::OpenOptionsExt;
440    match std::fs::OpenOptions::new()
441        .write(true)
442        .create(true)
443        .truncate(false)
444        .share_mode(0)
445        .open(path)
446    {
447        Ok(mut f) => {
448            let _ = f.write_all(std::process::id().to_string().as_bytes());
449            Ok(Some(f))
450        }
451        Err(e) if e.raw_os_error() == Some(32) => Ok(None),
452        Err(e) => Err(e),
453    }
454}
455
456#[derive(Debug, Clone, PartialEq, Eq)]
457pub enum AdoptOrSpawn {
458    Adopt(ServiceRecord),
459    Spawn,
460}
461
462/// Adopt only a live, reachable record whose PID generation still matches.
463///
464/// A `service.json` that cannot be read is `Spawn` here, as an absent one is; the enum cannot say
465/// "unknown". Use [`try_adopt_or_spawn`] where that must not start a second host.
466pub fn adopt_or_spawn(suite: &Suite, service: &str) -> AdoptOrSpawn {
467    try_adopt_or_spawn(suite, service).unwrap_or(AdoptOrSpawn::Spawn)
468}
469
470/// [`adopt_or_spawn`] with `Err` when `service.json` exists but could not be read: whether a
471/// live host exists is then unknown, so look again rather than spawn.
472pub fn try_adopt_or_spawn(suite: &Suite, service: &str) -> io::Result<AdoptOrSpawn> {
473    Ok(match try_read_record(suite, service)? {
474        Some(record) if record_process_matches(&record) && probe_endpoint(&record.endpoint) => {
475            AdoptOrSpawn::Adopt(record)
476        }
477        _ => AdoptOrSpawn::Spawn,
478    })
479}
480
481/// Wait for the winner of a competing claim to publish a matching live record.
482pub fn wait_for_claim_winner(
483    suite: &Suite,
484    service: &str,
485    timeout: Duration,
486) -> Option<ServiceRecord> {
487    let deadline = Instant::now() + timeout;
488    while Instant::now() < deadline {
489        // An unreadable record is "not yet": keep waiting, never stop early on it.
490        if let Ok(AdoptOrSpawn::Adopt(record)) = try_adopt_or_spawn(suite, service) {
491            return Some(record);
492        }
493        std::thread::sleep(
494            Duration::from_millis(25).min(deadline.saturating_duration_since(Instant::now())),
495        );
496    }
497    None
498}
499
500/// Check both liveness and process generation, protecting discovery from PID reuse.
501pub fn record_process_matches(record: &ServiceRecord) -> bool {
502    record_process_matches_inner(record)
503}
504
505fn record_process_matches_inner(record: &ServiceRecord) -> bool {
506    if !pid_alive(record.pid) {
507        return false;
508    }
509    // Legacy / wave-1 records carry no process birth time: PID liveness is all
510    // we can know. `started_ms` is publication time and never validates a PID.
511    if record.process_started_ms == 0 {
512        return true;
513    }
514    match process_start_ms(record.pid) {
515        Some(started) => started.abs_diff(record.process_started_ms as u128) <= 2_000,
516        // Cannot read the birth time right now: the pid is alive, keep the record.
517        None => true,
518    }
519}
520
521/// Start time (ms since epoch) of this process, as `process_start_ms` reports it.
522pub(crate) fn own_start_ms() -> Option<u64> {
523    process_start_ms(std::process::id()).map(|v| v as u64)
524}
525
526fn process_start_ms(pid: u32) -> Option<u128> {
527    #[cfg(target_os = "linux")]
528    {
529        let stat = std::fs::read_to_string(if pid == std::process::id() {
530            "/proc/self/stat".into()
531        } else {
532            format!("/proc/{pid}/stat")
533        })
534        .ok()?;
535        let ticks = stat
536            .rsplit(')')
537            .next()?
538            .split_whitespace()
539            .nth(19)?
540            .parse::<u128>()
541            .ok()?;
542        let boot = std::fs::read_to_string("/proc/stat")
543            .ok()?
544            .lines()
545            .find_map(|l| l.strip_prefix("btime "))?
546            .parse::<u128>()
547            .ok()?;
548        return Some(boot * 1000 + ticks * 1000 / 100);
549    }
550    #[cfg(target_os = "macos")]
551    {
552        let out = std::process::Command::new("ps")
553            .args(["-o", "lstart=", "-p", &pid.to_string()])
554            .output()
555            .ok()?;
556        let text = String::from_utf8(out.stdout).ok()?;
557        let value = text.trim();
558        let parsed = std::process::Command::new("date")
559            .args(["-j", "-f", "%a %b %e %H:%M:%S %Y", value, "+%s"])
560            .output()
561            .ok()?;
562        let seconds = String::from_utf8(parsed.stdout)
563            .ok()?
564            .trim()
565            .parse::<u128>()
566            .ok()?;
567        return Some(seconds * 1000);
568    }
569    #[cfg(windows)]
570    {
571        use std::ffi::c_void;
572        #[repr(C)]
573        struct FileTime {
574            low: u32,
575            high: u32,
576        }
577        #[link(name = "kernel32")]
578        unsafe extern "system" {
579            fn OpenProcess(access: u32, inherit: i32, pid: u32) -> *mut c_void;
580            fn GetProcessTimes(
581                handle: *mut c_void,
582                creation: *mut FileTime,
583                exit: *mut FileTime,
584                kernel: *mut FileTime,
585                user: *mut FileTime,
586            ) -> i32;
587            fn CloseHandle(handle: *mut c_void) -> i32;
588        }
589        const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
590        let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
591        if handle.is_null() {
592            return None;
593        }
594        let mut creation = FileTime { low: 0, high: 0 };
595        let mut exit = FileTime { low: 0, high: 0 };
596        let mut kernel = FileTime { low: 0, high: 0 };
597        let mut user = FileTime { low: 0, high: 0 };
598        let ok =
599            unsafe { GetProcessTimes(handle, &mut creation, &mut exit, &mut kernel, &mut user) }
600                != 0;
601        unsafe {
602            CloseHandle(handle);
603        }
604        if !ok {
605            return None;
606        }
607        let ticks = ((creation.high as u128) << 32) | creation.low as u128;
608        return Some(ticks / 10_000 - 11_644_473_600_000);
609    }
610    #[allow(unreachable_code)]
611    None
612}
613
614#[cfg(windows)]
615fn read_windows_uninstall_entries() -> Vec<UninstallEntry> {
616    let mut all = String::new();
617    for key in [
618        r"HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall",
619        r"HKLM\Software\Microsoft\Windows\CurrentVersion\Uninstall",
620        r"HKLM\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall",
621    ] {
622        let mut reg = std::process::Command::new("reg");
623        reg.args(["query", key, "/s"]);
624        #[cfg(windows)]
625        {
626            use std::os::windows::process::CommandExt;
627            reg.creation_flags(0x0800_0000); // CREATE_NO_WINDOW: never flash a console from a GUI host
628        }
629        if let Ok(output) = reg.output() {
630            all.push_str(&String::from_utf8_lossy(&output.stdout));
631            all.push('\n');
632        }
633    }
634    parse_reg_query(&all)
635}
636
637// ---------------------------------------------------------------------------
638// Host planning, takeover and detached start. Every service client plans through
639// these; product crates map their own role names onto ranks (`known_roles`) and
640// keep their own wire file names.
641// ---------------------------------------------------------------------------
642
643/// Rank of a live record or takeover request: its own `rank` when it writes one,
644/// else `known_roles` by its `role`. `None` when neither resolves; such a peer is
645/// never taken over from and never obliges a handoff.
646pub fn resolve_rank(role: &str, rank: u8, known_roles: &BTreeMap<String, u8>) -> Option<u8> {
647    if rank != RANK_UNKNOWN {
648        Some(rank)
649    } else {
650        known_roles.get(role).copied()
651    }
652}
653
654/// Outcome of [`plan_host`] for a process of a given rank.
655#[derive(Debug, Clone, PartialEq, Eq)]
656pub enum HostPlan {
657    /// A live host of equal or better rank serves the service.
658    UseRunning,
659    /// A live host of lower rank serves it and this process outranks it: request takeover.
660    TakeOver { from_role: String },
661    /// Nothing live and nothing to start: host in this process.
662    HostMyself,
663    /// Nothing live: start this installed engine, then re-discover.
664    StartInstalled(PathBuf),
665}
666
667/// Decide how a process of rank `my_rank` gets a host for `service`. A live record whose
668/// rank cannot be resolved through `known_roles` counts as absent. `installed_engine` is
669/// the executable the caller may start when nothing is live; it is used only when it exists.
670pub fn plan_host(
671    suite: &Suite,
672    service: &str,
673    my_rank: u8,
674    known_roles: &BTreeMap<String, u8>,
675    installed_engine: Option<&Path>,
676) -> HostPlan {
677    plan_from(
678        read_live_record(suite, service),
679        my_rank,
680        known_roles,
681        installed_engine,
682    )
683}
684
685/// [`plan_host`] with `Err` when `service.json` exists but could not be read. Whether a live host
686/// exists is then unknown, so no plan is made: `plan_host` would answer `HostMyself` or
687/// `StartInstalled`, which starts a second host beside a live one.
688pub fn try_plan_host(
689    suite: &Suite,
690    service: &str,
691    my_rank: u8,
692    known_roles: &BTreeMap<String, u8>,
693    installed_engine: Option<&Path>,
694) -> io::Result<HostPlan> {
695    Ok(plan_from(
696        try_read_live_record(suite, service)?,
697        my_rank,
698        known_roles,
699        installed_engine,
700    ))
701}
702
703fn plan_from(
704    live: Option<ServiceRecord>,
705    my_rank: u8,
706    known_roles: &BTreeMap<String, u8>,
707    installed_engine: Option<&Path>,
708) -> HostPlan {
709    if let Some(record) = live {
710        if let Some(their) = resolve_rank(&record.role, record.rank, known_roles) {
711            return if my_rank < their {
712                HostPlan::TakeOver {
713                    from_role: record.role,
714                }
715            } else {
716                HostPlan::UseRunning
717            };
718        }
719    }
720    match installed_engine {
721        Some(engine) if engine.exists() => HostPlan::StartInstalled(engine.to_path_buf()),
722        _ => HostPlan::HostMyself,
723    }
724}
725
726/// Takeover is requested only against a live, lower-ranked host held by another process.
727/// `incumbent` is the live record's `(resolved rank, pid)`.
728pub fn should_request_takeover(my_rank: u8, incumbent: Option<(u8, u32)>, own_pid: u32) -> bool {
729    incumbent.is_some_and(|(their, pid)| my_rank < their && pid != own_pid)
730}
731
732/// Does a pending takeover request `(resolved rank, pid)` oblige a host of rank `my_rank`
733/// to yield? Only a live, other-process, outranking requester does.
734pub fn takeover_obliges_yield(
735    my_rank: u8,
736    request: Option<(u8, u32)>,
737    own_pid: u32,
738    alive: &dyn Fn(u32) -> bool,
739) -> bool {
740    request.is_some_and(|(their, pid)| pid != own_pid && their < my_rank && alive(pid))
741}
742
743/// Publish `takeover.json` for this process as `role` (with `rank`), atomically.
744pub fn publish_takeover(suite: &Suite, service: &str, role: &str, rank: u8) -> io::Result<()> {
745    write_takeover(
746        suite,
747        service,
748        &TakeoverRequest {
749            role: role.into(),
750            rank,
751            pid: std::process::id(),
752            requested_at_ms: now_ms(),
753        },
754    )
755}
756
757fn now_ms() -> u64 {
758    SystemTime::now()
759        .duration_since(UNIX_EPOCH)
760        .map(|d| d.as_millis() as u64)
761        .unwrap_or(0)
762}
763
764/// Start `program` detached: no inherited stdio, its own process group on Unix, and on
765/// Windows no console window (`CREATE_NO_WINDOW | DETACHED_PROCESS`) so a GUI host never
766/// flashes one. The child is not waited for.
767pub fn spawn_detached(program: &Path, args: &[String], env: &[(String, String)]) -> io::Result<()> {
768    let mut cmd = Command::new(program);
769    cmd.args(args)
770        .envs(env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
771        .stdin(Stdio::null())
772        .stdout(Stdio::null())
773        .stderr(Stdio::null());
774    #[cfg(windows)]
775    {
776        use std::os::windows::process::CommandExt;
777        const CREATE_NO_WINDOW: u32 = 0x0800_0000;
778        const DETACHED_PROCESS: u32 = 0x0000_0008;
779        cmd.creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS);
780    }
781    #[cfg(unix)]
782    {
783        use std::os::unix::process::CommandExt;
784        cmd.process_group(0);
785    }
786    drop(cmd.spawn()?);
787    Ok(())
788}
789
790/// Poll `ready` every 100 ms until it holds (`true`) or `wait` elapses (`false`).
791/// Checks before the first sleep.
792pub fn poll_until(wait: Duration, mut ready: impl FnMut() -> bool) -> bool {
793    let deadline = Instant::now() + wait;
794    loop {
795        if ready() {
796            return true;
797        }
798        if Instant::now() >= deadline {
799            return false;
800        }
801        std::thread::sleep(Duration::from_millis(100));
802    }
803}
804
805#[cfg(test)]
806mod tests {
807    /// flock (Unix `File::try_lock`) belongs to the open file description, which a child forked by another test in
808    /// this binary shares until it execs (child-process spawns with pre_exec take the fork path on macOS). A lock
809    /// released by drop can therefore read as held for that instant: re-acquire with a short bound. Not a release bug:
810    /// the guard's drop closes our descriptor; the lock ends when the last shared copy closes (#788).
811    fn eventually<T, E>(mut f: impl FnMut() -> Result<T, E>) -> Result<T, E> {
812        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
813        loop {
814            match f() {
815                Ok(v) => return Ok(v),
816                Err(e) if std::time::Instant::now() >= deadline => return Err(e),
817                Err(_) => std::thread::sleep(std::time::Duration::from_millis(20)),
818            }
819        }
820    }
821
822    use super::*;
823    use std::fs;
824
825    #[test]
826    fn takeover_predicates_compare_resolved_ranks_and_skip_self() {
827        let always = |_: u32| true;
828        // Lower rank wins; equal rank never takes over.
829        assert!(should_request_takeover(0, Some((1, 99)), 7));
830        assert!(!should_request_takeover(1, Some((1, 99)), 7));
831        // Own pid is never a takeover target.
832        assert!(!should_request_takeover(0, Some((1, 7)), 7));
833        assert!(!should_request_takeover(0, None, 7));
834        assert!(takeover_obliges_yield(1, Some((0, 99)), 7, &always));
835        assert!(!takeover_obliges_yield(0, Some((1, 99)), 7, &always));
836        assert!(!takeover_obliges_yield(1, Some((0, 7)), 7, &always));
837        assert!(!takeover_obliges_yield(1, Some((0, 99)), 7, &|_| false));
838    }
839
840    #[test]
841    fn unresolvable_ranks_never_win_and_explicit_rank_beats_role_table() {
842        let roles = BTreeMap::from([("shell".to_string(), 0u8)]);
843        assert_eq!(resolve_rank("shell", RANK_UNKNOWN, &roles), Some(0));
844        assert_eq!(resolve_rank("unknown", RANK_UNKNOWN, &roles), None);
845        assert_eq!(resolve_rank("shell", 3, &roles), Some(3));
846    }
847
848    #[test]
849    fn spawn_poll_and_takeover_publication_round_trip() {
850        let d = tempfile::tempdir().unwrap();
851        let s = Suite::at(d.path()).unwrap();
852        publish_takeover(&s, "demo", "shell", 0).unwrap();
853        let t = crate::record::read_takeover(&s, "demo").unwrap();
854        assert_eq!(
855            (t.role.as_str(), t.rank, t.pid),
856            ("shell", 0, std::process::id())
857        );
858        assert!(poll_until(Duration::from_millis(50), || true));
859        assert!(!poll_until(Duration::from_millis(50), || false));
860    }
861
862    #[test]
863    fn mac_discovery_uses_known_bundle_and_first_root() {
864        let d = tempfile::tempdir().unwrap();
865        let bundle = d.path().join("DemoApp.app/Contents/MacOS");
866        fs::create_dir_all(&bundle).unwrap();
867        fs::write(bundle.join("demo-engine"), b"engine").unwrap();
868        fs::write(
869            d.path().join("DemoApp.app/Contents/Info.plist"),
870            b"<key>CFBundleShortVersionString</key><string>1.2</string>",
871        )
872        .unwrap();
873        let apps = discover_macos(&[d.path().to_path_buf()], &[("DemoApp", "demo-engine")]);
874        assert_eq!(apps[0].version.as_deref(), Some("1.2"));
875    }
876
877    #[test]
878    fn mac_discovery_reports_incomplete_installs_only_on_request() {
879        let d = tempfile::tempdir().unwrap();
880        // Broken install in the first root (bundle, no engine); nothing in the second.
881        let first = d.path().join("first");
882        let second = d.path().join("second");
883        fs::create_dir_all(first.join("DemoApp.app/Contents/MacOS")).unwrap();
884        fs::create_dir_all(&second).unwrap();
885        let roots = [first.clone(), second.clone()];
886        let reg = [("DemoApp", "demo-engine"), ("Absent", "absent-engine")];
887
888        // Default behaviour is unchanged: incomplete installs are not reported.
889        assert!(discover_macos(&roots, &reg).is_empty());
890        assert!(discover_macos_with(&roots, &reg, &DiscoverOptions::default()).is_empty());
891
892        let opts = DiscoverOptions {
893            include_incomplete: true,
894        };
895        let apps = discover_macos_with(&roots, &reg, &opts);
896        assert_eq!(
897            apps.len(),
898            1,
899            "an app with no bundle at all is never reported"
900        );
901        assert_eq!(apps[0].app, "DemoApp");
902        assert_eq!(apps[0].completeness, Completeness::EngineMissing);
903        assert!(std::path::Path::new(&apps[0].engine_path).ends_with(
904            std::path::Path::new("Contents")
905                .join("MacOS")
906                .join("demo-engine")
907        ));
908        assert!(apps[0].app_path.starts_with(first.to_str().unwrap()));
909
910        // A complete install in a later root beats the incomplete one in an earlier root.
911        let good = second.join("DemoApp.app/Contents/MacOS");
912        fs::create_dir_all(&good).unwrap();
913        fs::write(good.join("demo-engine"), b"engine").unwrap();
914        let apps = discover_macos_with(&roots, &reg, &opts);
915        assert_eq!(apps.len(), 1);
916        assert_eq!(apps[0].completeness, Completeness::Complete);
917        assert!(apps[0].app_path.starts_with(second.to_str().unwrap()));
918        assert_eq!(discover_macos(&roots, &reg).len(), 1);
919    }
920
921    #[test]
922    fn windows_discovery_reports_incomplete_installs_only_on_request() {
923        let entry = |location: &str| UninstallEntry {
924            display_name: "DemoApp".into(),
925            install_location: Some(location.into()),
926            display_icon: None,
927            uninstall_string: None,
928            display_version: Some("3.0".into()),
929        };
930        let reg = [("DemoApp", "demo-engine")];
931        let broken = r"C:\Apps\Broken";
932        let entries = [entry(broken), entry(r"C:\Apps\Gone")];
933        // `Broken` has its directory but no engine; `Gone` has neither.
934        let engine = |p: &str| p.starts_with("C:\\Apps\\Good\\");
935        let dirs = |p: &str| p == broken;
936
937        assert!(discover_windows(&entries, &reg, engine).is_empty());
938        let opts = DiscoverOptions {
939            include_incomplete: true,
940        };
941        let apps = discover_windows_with(&entries, &reg, engine, dirs, &opts);
942        assert_eq!(apps.len(), 1);
943        assert_eq!(apps[0].app_path, broken);
944        assert_eq!(apps[0].completeness, Completeness::EngineMissing);
945        assert_eq!(apps[0].version.as_deref(), Some("3.0"));
946
947        // A complete install in a later entry beats the incomplete one.
948        let entries = [entry(broken), entry(r"C:\Apps\Good")];
949        let apps = discover_windows_with(&entries, &reg, engine, dirs, &opts);
950        assert_eq!(apps.len(), 1);
951        assert_eq!(apps[0].completeness, Completeness::Complete);
952        assert_eq!(apps[0].app_path, r"C:\Apps\Good");
953    }
954
955    #[test]
956    fn registry_parser_and_windows_projection_are_deterministic() {
957        let text = "HKEY_CURRENT_USER\\x\n    DisplayName    REG_SZ    DemoApp\n    InstallLocation    REG_SZ    C:\\Apps\\DemoApp\n    DisplayVersion    REG_SZ    2.0\n";
958        let rows = parse_reg_query(text);
959        assert_eq!(rows.len(), 1);
960        let apps = discover_windows(&rows, &[("DemoApp", "demo-engine")], |p| {
961            p.ends_with("demo-engine.exe")
962        });
963        assert_eq!(apps[0].version.as_deref(), Some("2.0"));
964    }
965
966    #[test]
967    fn spawn_claim_is_single_winner_and_released() {
968        let d = tempfile::tempdir().unwrap();
969        let s = Suite::at(d.path()).unwrap();
970        let c = SpawnClaim::acquire(&s, "demo").unwrap();
971        assert!(matches!(
972            SpawnClaim::acquire(&s, "demo"),
973            Err(SpawnClaimError::AlreadyHeld(_))
974        ));
975        drop(c);
976        assert!(eventually(|| SpawnClaim::acquire(&s, "demo")).is_ok());
977    }
978}