diff --git a/clients/cli/src/main.rs b/clients/cli/src/main.rs index bf993d0c..98d4a62c 100644 --- a/clients/cli/src/main.rs +++ b/clients/cli/src/main.rs @@ -363,7 +363,11 @@ from the config directory for a true factory reset." .unwrap_or(DISCOVER_DEFAULT_SECS) .min(DISCOVER_MAX_SECS); let found = pf_client_core::discovery::discover_for(Duration::from_secs_f64(secs)); - let known = KnownHosts::load(); + // `read`, not `load`: this verb only LOOKS at the records to annotate what it found, and + // never hands their ids back. `load` would mint ids for a pre-mint store and save them — + // a write from a read-only verb, and one that races the `hosts list` a caller is very + // likely running at the same moment (the Decky panel issues both together). + let known = KnownHosts::read(); let rows: Vec<( &pf_client_core::discovery::DiscoveredHost, Option<&KnownHost>, diff --git a/crates/pf-client-core/src/trust.rs b/crates/pf-client-core/src/trust.rs index 929e245c..9ab18cf2 100644 --- a/crates/pf-client-core/src/trust.rs +++ b/crates/pf-client-core/src/trust.rs @@ -232,17 +232,29 @@ impl KnownHosts { /// A read-only config dir just keeps re-minting in memory, which harms nothing: no lookup /// is keyed by the id yet (design §4.5). pub fn load() -> KnownHosts { - let mut k: KnownHosts = Self::path() - .and_then(|p| Ok(std::fs::read_to_string(p)?)) - .ok() - .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or_default(); + let mut k = Self::read(); if k.mint_missing_ids() { let _ = k.save(); } k } + /// The store exactly as it is on disk — no mint, and so no write. + /// + /// For a consumer that only needs to LOOK at the records (annotating a discovery result + /// against them, say) and never dials one by id. [`KnownHosts::load`]'s mint is a write, and + /// two processes started together against a pre-mint store will each mint a *different* id + /// for the same record and race to save it — after which whichever one already handed its + /// ids to a caller has handed out references that no longer resolve. A read that stays a + /// read cannot take part in that. + pub fn read() -> KnownHosts { + Self::path() + .and_then(|p| Ok(std::fs::read_to_string(p)?)) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() + } + /// Give every record still missing one a stable id; returns true if anything changed /// (i.e. whether this needs persisting). Idempotent — a store that has been through it /// once is left byte-identical.