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 unlocks and closes only this claim's own
382/// handle.
383pub struct SpawnClaim {
384    _file: LockedFile,
385    path: PathBuf,
386}
387
388impl SpawnClaim {
389    pub fn path(suite: &Suite, service: &str) -> PathBuf {
390        suite.service_dir(service).join("spawn.claim")
391    }
392
393    pub fn acquire(suite: &Suite, service: &str) -> Result<Self, SpawnClaimError> {
394        make_private_dir(&suite.service_dir(service))
395            .map_err(|e| SpawnClaimError::Io(io::Error::other(e.to_string())))?;
396        let path = Self::path(suite, service);
397        match try_lock_exclusive(&path) {
398            Ok(Some(file)) => Ok(Self { _file: file, path }),
399            Ok(None) => Err(SpawnClaimError::AlreadyHeld(path)),
400            Err(e) => Err(SpawnClaimError::Io(e)),
401        }
402    }
403
404    /// Path of the claim file (for diagnostics).
405    pub fn claim_path(&self) -> &Path {
406        &self.path
407    }
408}
409
410/// An exclusive OS lock on a file, held until this value is dropped or the process dies.
411///
412/// On Unix the lock is a `flock`, which belongs to the open file description, not to the
413/// descriptor. A child that another thread of this process forks (or `posix_spawn`s) while the
414/// descriptor is open holds a copy of the description until its `exec` closes it, so closing our
415/// descriptor alone would leave a released lock reading as held for that long (rightkit#1323,
416/// #788). Drop therefore unlocks explicitly, which ends the lock for every copy of the
417/// description, as `rightkit-fs`'s `FileLock` does for a `flock`. The descriptor is
418/// close-on-exec, so no exec'd child ever holds it. Windows holds the lock through the
419/// no-share handle itself, which a spawned child does not inherit.
420#[derive(Debug)]
421pub(crate) struct LockedFile {
422    #[cfg_attr(not(unix), allow(dead_code))]
423    file: std::fs::File,
424}
425
426impl Drop for LockedFile {
427    fn drop(&mut self) {
428        #[cfg(unix)]
429        {
430            // Closing right after still ends the lock when this descriptor is the only copy, so
431            // a failure here has nothing left to report.
432            let _ = self.file.unlock();
433        }
434    }
435}
436
437/// Open `path` and take a non-blocking exclusive lock. `Ok(None)` = held elsewhere.
438#[cfg(unix)]
439pub(crate) fn try_lock_exclusive(path: &Path) -> io::Result<Option<LockedFile>> {
440    use std::fs::TryLockError;
441    use std::io::Write;
442    let mut f = std::fs::OpenOptions::new()
443        .read(true)
444        .write(true)
445        .create(true)
446        .truncate(false)
447        .open(path)?;
448    // std's `File::try_lock` is a non-blocking `flock(LOCK_EX | LOCK_NB)` on Unix.
449    match f.try_lock() {
450        Ok(()) => {}
451        Err(TryLockError::WouldBlock) => return Ok(None),
452        Err(TryLockError::Error(e)) => return Err(e),
453    }
454    // Informational only (we hold the lock, so the write is ours).
455    let _ = f.set_len(0);
456    let _ = f.write_all(std::process::id().to_string().as_bytes());
457    Ok(Some(LockedFile { file: f }))
458}
459
460/// Windows: open with share mode 0 (no sharing). A second open fails with
461/// ERROR_SHARING_VIOLATION (32) while the first handle lives; the kernel closes
462/// the handle on process death. Not compiled on this host; std-only API
463/// (`OpenOptionsExt::share_mode`), checked by reasoning against its docs.
464#[cfg(windows)]
465pub(crate) fn try_lock_exclusive(path: &Path) -> io::Result<Option<LockedFile>> {
466    use std::io::Write;
467    use std::os::windows::fs::OpenOptionsExt;
468    match std::fs::OpenOptions::new()
469        .write(true)
470        .create(true)
471        .truncate(false)
472        .share_mode(0)
473        .open(path)
474    {
475        Ok(mut f) => {
476            let _ = f.write_all(std::process::id().to_string().as_bytes());
477            Ok(Some(LockedFile { file: f }))
478        }
479        Err(e) if e.raw_os_error() == Some(32) => Ok(None),
480        Err(e) => Err(e),
481    }
482}
483
484#[derive(Debug, Clone, PartialEq, Eq)]
485pub enum AdoptOrSpawn {
486    Adopt(ServiceRecord),
487    Spawn,
488}
489
490/// Adopt only a live, reachable record whose PID generation still matches.
491///
492/// A `service.json` that cannot be read is `Spawn` here, as an absent one is; the enum cannot say
493/// "unknown". Use [`try_adopt_or_spawn`] where that must not start a second host.
494pub fn adopt_or_spawn(suite: &Suite, service: &str) -> AdoptOrSpawn {
495    try_adopt_or_spawn(suite, service).unwrap_or(AdoptOrSpawn::Spawn)
496}
497
498/// [`adopt_or_spawn`] with `Err` when `service.json` exists but could not be read: whether a
499/// live host exists is then unknown, so look again rather than spawn.
500pub fn try_adopt_or_spawn(suite: &Suite, service: &str) -> io::Result<AdoptOrSpawn> {
501    Ok(match try_read_record(suite, service)? {
502        Some(record) if record_process_matches(&record) && probe_endpoint(&record.endpoint) => {
503            AdoptOrSpawn::Adopt(record)
504        }
505        _ => AdoptOrSpawn::Spawn,
506    })
507}
508
509/// Wait for the winner of a competing claim to publish a matching live record.
510pub fn wait_for_claim_winner(
511    suite: &Suite,
512    service: &str,
513    timeout: Duration,
514) -> Option<ServiceRecord> {
515    let deadline = Instant::now() + timeout;
516    while Instant::now() < deadline {
517        // An unreadable record is "not yet": keep waiting, never stop early on it.
518        if let Ok(AdoptOrSpawn::Adopt(record)) = try_adopt_or_spawn(suite, service) {
519            return Some(record);
520        }
521        std::thread::sleep(
522            Duration::from_millis(25).min(deadline.saturating_duration_since(Instant::now())),
523        );
524    }
525    None
526}
527
528/// Check both liveness and process generation, protecting discovery from PID reuse.
529pub fn record_process_matches(record: &ServiceRecord) -> bool {
530    record_process_matches_inner(record)
531}
532
533fn record_process_matches_inner(record: &ServiceRecord) -> bool {
534    if !pid_alive(record.pid) {
535        return false;
536    }
537    // Legacy / wave-1 records carry no process birth time: PID liveness is all
538    // we can know. `started_ms` is publication time and never validates a PID.
539    if record.process_started_ms == 0 {
540        return true;
541    }
542    match process_start_ms(record.pid) {
543        Some(started) => started.abs_diff(record.process_started_ms as u128) <= 2_000,
544        // Cannot read the birth time right now: the pid is alive, keep the record.
545        None => true,
546    }
547}
548
549/// Start time (ms since epoch) of this process, as `process_start_ms` reports it.
550pub(crate) fn own_start_ms() -> Option<u64> {
551    process_start_ms(std::process::id()).map(|v| v as u64)
552}
553
554fn process_start_ms(pid: u32) -> Option<u128> {
555    #[cfg(target_os = "linux")]
556    {
557        let stat = std::fs::read_to_string(if pid == std::process::id() {
558            "/proc/self/stat".into()
559        } else {
560            format!("/proc/{pid}/stat")
561        })
562        .ok()?;
563        let ticks = stat
564            .rsplit(')')
565            .next()?
566            .split_whitespace()
567            .nth(19)?
568            .parse::<u128>()
569            .ok()?;
570        let boot = std::fs::read_to_string("/proc/stat")
571            .ok()?
572            .lines()
573            .find_map(|l| l.strip_prefix("btime "))?
574            .parse::<u128>()
575            .ok()?;
576        return Some(boot * 1000 + ticks * 1000 / 100);
577    }
578    #[cfg(target_os = "macos")]
579    {
580        let out = std::process::Command::new("ps")
581            .args(["-o", "lstart=", "-p", &pid.to_string()])
582            .output()
583            .ok()?;
584        let text = String::from_utf8(out.stdout).ok()?;
585        let value = text.trim();
586        let parsed = std::process::Command::new("date")
587            .args(["-j", "-f", "%a %b %e %H:%M:%S %Y", value, "+%s"])
588            .output()
589            .ok()?;
590        let seconds = String::from_utf8(parsed.stdout)
591            .ok()?
592            .trim()
593            .parse::<u128>()
594            .ok()?;
595        return Some(seconds * 1000);
596    }
597    #[cfg(windows)]
598    {
599        use std::ffi::c_void;
600        #[repr(C)]
601        struct FileTime {
602            low: u32,
603            high: u32,
604        }
605        #[link(name = "kernel32")]
606        unsafe extern "system" {
607            fn OpenProcess(access: u32, inherit: i32, pid: u32) -> *mut c_void;
608            fn GetProcessTimes(
609                handle: *mut c_void,
610                creation: *mut FileTime,
611                exit: *mut FileTime,
612                kernel: *mut FileTime,
613                user: *mut FileTime,
614            ) -> i32;
615            fn CloseHandle(handle: *mut c_void) -> i32;
616        }
617        const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
618        let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
619        if handle.is_null() {
620            return None;
621        }
622        let mut creation = FileTime { low: 0, high: 0 };
623        let mut exit = FileTime { low: 0, high: 0 };
624        let mut kernel = FileTime { low: 0, high: 0 };
625        let mut user = FileTime { low: 0, high: 0 };
626        let ok =
627            unsafe { GetProcessTimes(handle, &mut creation, &mut exit, &mut kernel, &mut user) }
628                != 0;
629        unsafe {
630            CloseHandle(handle);
631        }
632        if !ok {
633            return None;
634        }
635        let ticks = ((creation.high as u128) << 32) | creation.low as u128;
636        return Some(ticks / 10_000 - 11_644_473_600_000);
637    }
638    #[allow(unreachable_code)]
639    None
640}
641
642#[cfg(windows)]
643fn read_windows_uninstall_entries() -> Vec<UninstallEntry> {
644    let mut all = String::new();
645    for key in [
646        r"HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall",
647        r"HKLM\Software\Microsoft\Windows\CurrentVersion\Uninstall",
648        r"HKLM\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall",
649    ] {
650        let mut reg = std::process::Command::new("reg");
651        reg.args(["query", key, "/s"]);
652        #[cfg(windows)]
653        {
654            use std::os::windows::process::CommandExt;
655            reg.creation_flags(0x0800_0000); // CREATE_NO_WINDOW: never flash a console from a GUI host
656        }
657        if let Ok(output) = reg.output() {
658            all.push_str(&String::from_utf8_lossy(&output.stdout));
659            all.push('\n');
660        }
661    }
662    parse_reg_query(&all)
663}
664
665// ---------------------------------------------------------------------------
666// Host planning, takeover and detached start. Every service client plans through
667// these; product crates map their own role names onto ranks (`known_roles`) and
668// keep their own wire file names.
669// ---------------------------------------------------------------------------
670
671/// Rank of a live record or takeover request: its own `rank` when it writes one,
672/// else `known_roles` by its `role`. `None` when neither resolves; such a peer is
673/// never taken over from and never obliges a handoff.
674pub fn resolve_rank(role: &str, rank: u8, known_roles: &BTreeMap<String, u8>) -> Option<u8> {
675    if rank != RANK_UNKNOWN {
676        Some(rank)
677    } else {
678        known_roles.get(role).copied()
679    }
680}
681
682/// Outcome of [`plan_host`] for a process of a given rank.
683#[derive(Debug, Clone, PartialEq, Eq)]
684pub enum HostPlan {
685    /// A live host of equal or better rank serves the service.
686    UseRunning,
687    /// A live host of lower rank serves it and this process outranks it: request takeover.
688    TakeOver { from_role: String },
689    /// Nothing live and nothing to start: host in this process.
690    HostMyself,
691    /// Nothing live: start this installed engine, then re-discover.
692    StartInstalled(PathBuf),
693}
694
695/// Decide how a process of rank `my_rank` gets a host for `service`. A live record whose
696/// rank cannot be resolved through `known_roles` counts as absent. `installed_engine` is
697/// the executable the caller may start when nothing is live; it is used only when it exists.
698pub fn plan_host(
699    suite: &Suite,
700    service: &str,
701    my_rank: u8,
702    known_roles: &BTreeMap<String, u8>,
703    installed_engine: Option<&Path>,
704) -> HostPlan {
705    plan_from(
706        read_live_record(suite, service),
707        my_rank,
708        known_roles,
709        installed_engine,
710    )
711}
712
713/// [`plan_host`] with `Err` when `service.json` exists but could not be read. Whether a live host
714/// exists is then unknown, so no plan is made: `plan_host` would answer `HostMyself` or
715/// `StartInstalled`, which starts a second host beside a live one.
716pub fn try_plan_host(
717    suite: &Suite,
718    service: &str,
719    my_rank: u8,
720    known_roles: &BTreeMap<String, u8>,
721    installed_engine: Option<&Path>,
722) -> io::Result<HostPlan> {
723    Ok(plan_from(
724        try_read_live_record(suite, service)?,
725        my_rank,
726        known_roles,
727        installed_engine,
728    ))
729}
730
731fn plan_from(
732    live: Option<ServiceRecord>,
733    my_rank: u8,
734    known_roles: &BTreeMap<String, u8>,
735    installed_engine: Option<&Path>,
736) -> HostPlan {
737    if let Some(record) = live {
738        if let Some(their) = resolve_rank(&record.role, record.rank, known_roles) {
739            return if my_rank < their {
740                HostPlan::TakeOver {
741                    from_role: record.role,
742                }
743            } else {
744                HostPlan::UseRunning
745            };
746        }
747    }
748    match installed_engine {
749        Some(engine) if engine.exists() => HostPlan::StartInstalled(engine.to_path_buf()),
750        _ => HostPlan::HostMyself,
751    }
752}
753
754/// Takeover is requested only against a live, lower-ranked host held by another process.
755/// `incumbent` is the live record's `(resolved rank, pid)`.
756pub fn should_request_takeover(my_rank: u8, incumbent: Option<(u8, u32)>, own_pid: u32) -> bool {
757    incumbent.is_some_and(|(their, pid)| my_rank < their && pid != own_pid)
758}
759
760/// Does a pending takeover request `(resolved rank, pid)` oblige a host of rank `my_rank`
761/// to yield? Only a live, other-process, outranking requester does.
762pub fn takeover_obliges_yield(
763    my_rank: u8,
764    request: Option<(u8, u32)>,
765    own_pid: u32,
766    alive: &dyn Fn(u32) -> bool,
767) -> bool {
768    request.is_some_and(|(their, pid)| pid != own_pid && their < my_rank && alive(pid))
769}
770
771/// Publish `takeover.json` for this process as `role` (with `rank`), atomically.
772pub fn publish_takeover(suite: &Suite, service: &str, role: &str, rank: u8) -> io::Result<()> {
773    write_takeover(
774        suite,
775        service,
776        &TakeoverRequest {
777            role: role.into(),
778            rank,
779            pid: std::process::id(),
780            requested_at_ms: now_ms(),
781        },
782    )
783}
784
785fn now_ms() -> u64 {
786    SystemTime::now()
787        .duration_since(UNIX_EPOCH)
788        .map(|d| d.as_millis() as u64)
789        .unwrap_or(0)
790}
791
792/// Start `program` detached: no inherited stdio, its own process group on Unix, and on
793/// Windows no console window (`CREATE_NO_WINDOW | DETACHED_PROCESS`) so a GUI host never
794/// flashes one. The child is not waited for.
795pub fn spawn_detached(program: &Path, args: &[String], env: &[(String, String)]) -> io::Result<()> {
796    let mut cmd = Command::new(program);
797    cmd.args(args)
798        .envs(env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
799        .stdin(Stdio::null())
800        .stdout(Stdio::null())
801        .stderr(Stdio::null());
802    #[cfg(windows)]
803    {
804        use std::os::windows::process::CommandExt;
805        const CREATE_NO_WINDOW: u32 = 0x0800_0000;
806        const DETACHED_PROCESS: u32 = 0x0000_0008;
807        cmd.creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS);
808    }
809    #[cfg(unix)]
810    {
811        use std::os::unix::process::CommandExt;
812        cmd.process_group(0);
813    }
814    drop(cmd.spawn()?);
815    Ok(())
816}
817
818/// Poll `ready` every 100 ms until it holds (`true`) or `wait` elapses (`false`).
819/// Checks before the first sleep.
820pub fn poll_until(wait: Duration, mut ready: impl FnMut() -> bool) -> bool {
821    let deadline = Instant::now() + wait;
822    loop {
823        if ready() {
824            return true;
825        }
826        if Instant::now() >= deadline {
827            return false;
828        }
829        std::thread::sleep(Duration::from_millis(100));
830    }
831}