Skip to main content

agentmux_srv/identity/
secret_store.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! srv's secret storage: account API keys, OAuth accounts, MuxBus
5//! credentials, browser-pane logins.
6//!
7//! Backs `SecretRef::Keychain { service, account }`, which names an entry in
8//! *this store*, wherever it keeps secrets; the DB holds just the pointer plus
9//! non-secret metadata + a masked tail. Reads return a `Zeroizing<String>` so
10//! the plaintext is wiped from memory on drop.
11//!
12//! **Two backends**, chosen once at startup:
13//! - **The OS keychain** (macOS Keychain / Windows Credential Manager / Linux
14//!   Secret Service via the `keyring` crate). The default, and what the
15//!   desktop app always uses.
16//! - **A file store** ([`use_file_store`]): one `0600` file per entry in a
17//!   `0700` directory, for machines with no keychain — a container, a
18//!   server, CI. Headless srv uses it unless told otherwise
19//!   (docs/specs/SPEC_SRV_HEADLESS_MODE_2026_09_26.md §3.6). Secrets are
20//!   protected by file permissions (and the volume's own encryption), not
21//!   encrypted by srv: a key kept beside them would add nothing.
22//!
23//! There is no silent downgrade from one to the other: failures surface as
24//! errors. See docs/specs/archive/SPEC_TRUST_CENTER_2026_06_15.md §7 and §12.2.
25//!
26//! The rest of this comment is about the keychain backend.
27//!
28//! **Reads are bounded by [`TIMEOUT`]; writes are not — see below.** Every
29//! operation here can require interactive OS consent ("App wants to access
30//! your confidential information...") the first time a given code
31//! signature touches a given entry, and that consent call has no
32//! cancellation mechanism: an unanswered prompt (headless process, dialog
33//! on another Space, no attached display session) blocks the underlying
34//! platform call indefinitely. Confirmed live — see
35//! `docs/retro/retro-macos-muxbus-keychain-prompt-storm-2026-08-19.md` §5.
36//!
37//! `get`/`get_optional` run the real platform call on a detached thread and
38//! give up waiting after `TIMEOUT`, so a stuck prompt bounds how long the
39//! CALLER waits (and any lock it's holding) instead of hanging it forever.
40//! This is safe specifically because a read has no side effect: if the
41//! timeout fires, the detached thread's eventual (possibly much later)
42//! result is just discarded — nothing acts on a stale read. At most one
43//! read per account is in flight ([`SharedReads`]): a retry while one is
44//! still waiting on a dialog joins it rather than queueing another dialog
45//! behind it.
46//!
47//! `put`/`delete` deliberately do NOT use this timeout (reagent + Codex,
48//! PR #2679 round 2): the same "detached thread keeps running after the
49//! caller gives up" behavior is unsafe for a MUTATION. A caller that treats
50//! a timed-out write as failure and proceeds — e.g. skipping a dependent DB
51//! write, or a rollback in `muxbus.rs` writing a different value to the
52//! same entry — can have the orphaned original write land afterward and
53//! silently clobber whatever the caller did next, with no ordering
54//! guarantee between them. There is no cancellation-equivalent compensation
55//! available (the platform call truly cannot be cancelled), so until one
56//! exists, an unbounded wait — the pre-existing, safe-if-slow behavior — is
57//! the correct tradeoff for anything that mutates state. A stuck consent
58//! prompt on a write still blocks its caller indefinitely; only reads are
59//! protected today.
60
61use std::collections::HashMap;
62use std::path::{Path, PathBuf};
63use std::sync::{Arc, Condvar, Mutex, MutexGuard, OnceLock, PoisonError};
64use std::time::Duration;
65
66use keyring::Entry;
67use zeroize::Zeroizing;
68
69/// Keychain service string — constant across all AgentMux secrets.
70pub const SERVICE: &str = "agentmux";
71
72/// How long a caller waits for a keychain operation before giving up. Long
73/// enough for a real user to notice and answer a genuine first-time consent
74/// prompt if they're looking at their screen; short enough that an
75/// unanswered/unanswerable one doesn't wedge whatever the caller is holding
76/// (e.g. `muxbus_save_lock`) indefinitely. See this module's doc comment.
77const TIMEOUT: Duration = Duration::from_secs(15);
78
79/// Why `run_with_timeout` gave up waiting — reagent P2: collapsing these
80/// into one outcome made a closure PANIC (sender dropped without sending,
81/// `mpsc::RecvTimeoutError::Disconnected`) misreport as "timed out after
82/// 15s" — actively misleading, since the operation actually failed
83/// immediately, not after waiting the full deadline.
84#[derive(Debug, PartialEq)]
85enum RunOutcome {
86    /// The deadline elapsed with no answer yet — the likely "stuck consent
87    /// prompt" case this module exists to bound.
88    TimedOut,
89    /// The worker thread's sender was dropped without sending — it panicked
90    /// before calling `f` to completion.
91    WorkerPanicked,
92}
93
94/// What a keychain read returns: `Ok(None)` when there's no entry.
95type ReadResult = Result<Option<Zeroizing<String>>, String>;
96
97/// Keychain reads, at most one in flight per account.
98///
99/// A read runs on a detached thread, and its caller gives up after a
100/// timeout; the platform call underneath can't be cancelled and stays
101/// queued behind an unanswered consent dialog. Without this, every retry
102/// queued another one: on 2026-10-08 an hour of once-a-minute retries
103/// behind one open dialog meant each Deny showed the next queued read's
104/// dialog, six in two seconds. Now a read of an account that already has
105/// one in flight waits on that one, under its own timeout, so an open
106/// dialog stays one dialog. Nothing is cached: a read that starts after the
107/// answer arrived reads again.
108struct SharedReads {
109    in_flight: Arc<Mutex<HashMap<String, Arc<PendingRead>>>>,
110}
111
112#[derive(Default)]
113struct PendingRead {
114    /// `None` until the read finishes; `Err(())` if it panicked.
115    result: Mutex<Option<Result<ReadResult, ()>>>,
116    done: Condvar,
117}
118
119impl SharedReads {
120    fn new() -> Self {
121        Self { in_flight: Arc::new(Mutex::new(HashMap::new())) }
122    }
123
124    /// Wait up to `timeout` for `account_id`'s read, starting it with `read`
125    /// unless one is already in flight. The `RunOutcome` is separate from
126    /// any error the read itself returns; callers fold it into their own
127    /// message (`run_outcome_message`).
128    fn read(
129        &self,
130        account_id: &str,
131        timeout: Duration,
132        read: impl FnOnce() -> ReadResult + Send + 'static,
133    ) -> Result<ReadResult, RunOutcome> {
134        let spawn = |work: Box<dyn FnOnce() + Send>| {
135            std::thread::Builder::new().name("keychain-read".into()).spawn(work).map(drop)
136        };
137        self.read_with(account_id, timeout, read, spawn)
138    }
139
140    /// [`read`](Self::read) with the thread start passed in, so a test can
141    /// make it fail.
142    fn read_with(
143        &self,
144        account_id: &str,
145        timeout: Duration,
146        read: impl FnOnce() -> ReadResult + Send + 'static,
147        spawn: impl FnOnce(Box<dyn FnOnce() + Send>) -> std::io::Result<()>,
148    ) -> Result<ReadResult, RunOutcome> {
149        let pending = {
150            let mut in_flight = lock(&self.in_flight);
151            match in_flight.get(account_id) {
152                Some(pending) => pending.clone(),
153                None => {
154                    let pending = Arc::new(PendingRead::default());
155                    in_flight.insert(account_id.to_string(), pending.clone());
156                    let (worker, map, id) = (pending.clone(), self.in_flight.clone(), account_id.to_string());
157                    let started = spawn(Box::new(move || {
158                        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(read)).map_err(|_| ());
159                        // Leave the map first, so a read that starts once the
160                        // answer is out reads again. Only if this is still the
161                        // account's entry: `forget` may have replaced it.
162                        let mut in_flight = lock(&map);
163                        if in_flight.get(&id).is_some_and(|p| Arc::ptr_eq(p, &worker)) {
164                            in_flight.remove(&id);
165                        }
166                        drop(in_flight);
167                        *lock(&worker.result) = Some(result);
168                        worker.done.notify_all();
169                    }));
170                    if let Err(e) = started {
171                        // Nothing will ever answer this entry: drop it, or every
172                        // later read of the account would wait on it and time out.
173                        in_flight.remove(account_id);
174                        return Ok(Err(format!("keychain read failed: could not start a thread: {e}")));
175                    }
176                    pending
177                }
178            }
179        };
180        let result = lock(&pending.result);
181        let (result, _) = pending
182            .done
183            .wait_timeout_while(result, timeout, |r| r.is_none())
184            .unwrap_or_else(PoisonError::into_inner);
185        match result.as_ref() {
186            None => Err(RunOutcome::TimedOut),
187            Some(Err(())) => Err(RunOutcome::WorkerPanicked),
188            Some(Ok(answer)) => Ok(answer.clone()),
189        }
190    }
191
192    /// Stop sharing `account_id`'s in-flight read: the next read starts its
193    /// own. Called after a write, so no read that starts after it can get
194    /// the value from before it.
195    fn forget(&self, account_id: &str) {
196        lock(&self.in_flight).remove(account_id);
197    }
198
199    /// Pass a write's result through, forgetting the account's in-flight
200    /// read only if the write succeeded. A failed write changed nothing, and
201    /// forgetting then would let the next retry start a second read beside
202    /// the one still blocked on a dialog.
203    fn after_write<T>(&self, account_id: &str, written: Result<T, String>) -> Result<T, String> {
204        if written.is_ok() {
205            self.forget(account_id);
206        }
207        written
208    }
209}
210
211fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
212    m.lock().unwrap_or_else(PoisonError::into_inner)
213}
214
215fn shared_reads() -> &'static SharedReads {
216    static READS: OnceLock<SharedReads> = OnceLock::new();
217    READS.get_or_init(SharedReads::new)
218}
219
220fn entry(account_id: &str) -> Result<Entry, String> {
221    Entry::new(SERVICE, &account_key(account_id))
222        .map_err(|e| format!("keychain entry init failed: {e}"))
223}
224
225/// Build the keychain account string for an identity-account id.
226pub fn account_key(account_id: &str) -> String {
227    format!("acct:{account_id}")
228}
229
230/// Set when [`use_file_store`] chose the file store; unset means the keychain.
231static FILE_STORE: OnceLock<PathBuf> = OnceLock::new();
232
233/// Keep every secret in files under `dir` instead of the OS keychain, for
234/// the rest of this process. Called once at startup, before anything reads
235/// or writes a secret. Creates `dir` (mode `0700`) if needed.
236pub fn use_file_store(dir: PathBuf) -> Result<(), String> {
237    file::prepare_dir(&dir)?;
238    FILE_STORE
239        .set(dir)
240        .map_err(|_| "the secret store was already chosen".to_string())
241}
242
243/// Which backend this process uses, for logs: `"keychain"` or `"file"`.
244pub fn backend_name() -> &'static str {
245    if FILE_STORE.get().is_some() {
246        "file"
247    } else {
248        "keychain"
249    }
250}
251
252/// Store (or overwrite) the secret for `account_id` in the OS keychain.
253///
254/// Deliberately unbounded — see this module's doc comment for why a
255/// timeout is unsafe for a mutation specifically (a timed-out-but-later-
256/// completing write can land after the caller has already acted on the
257/// assumption it failed).
258pub fn put(account_id: &str, secret: &str) -> Result<(), String> {
259    if let Some(dir) = FILE_STORE.get() {
260        return file::put(dir, &account_key(account_id), secret);
261    }
262    let written = entry(account_id)?
263        .set_password(secret)
264        .map_err(|e| format!("keychain write failed: {e}"));
265    shared_reads().after_write(account_id, written)
266}
267
268/// Read the secret for `account_id`. Returned wrapped in `Zeroizing` so it
269/// is wiped on drop. Resolved at agent spawn time when injecting env vars.
270pub fn get(account_id: &str) -> Result<Zeroizing<String>, String> {
271    if let Some(dir) = FILE_STORE.get() {
272        return file::get_optional(dir, &account_key(account_id))?
273            .ok_or_else(|| "secret store read failed: no entry for this account".to_string());
274    }
275    get_optional(account_id)?
276        .ok_or_else(|| format!("keychain read failed: {}", keyring::Error::NoEntry))
277}
278
279/// Read the secret for `account_id`, distinguishing "no entry stored yet"
280/// (`Ok(None)`) from a real storage failure (`Err`) — a locked keychain, no
281/// Secret Service daemon running, permission denied, etc. `get` collapses
282/// both into the same `Err` variant, which is correct for its own callers
283/// (a spawn-time credential resolve should fail either way), but is the
284/// wrong shape for a caller that needs to tell "genuinely never logged in"
285/// apart from "storage is transiently broken" — treating a transient
286/// failure as "no credential" can silently present as a full logout. Use
287/// this variant when that distinction matters.
288pub fn get_optional(account_id: &str) -> Result<Option<Zeroizing<String>>, String> {
289    if let Some(dir) = FILE_STORE.get() {
290        return file::get_optional(dir, &account_key(account_id));
291    }
292    let id = account_id.to_string();
293    match shared_reads().read(account_id, TIMEOUT, move || get_optional_now(&id)) {
294        Ok(result) => result,
295        Err(outcome) => Err(run_outcome_message("read", outcome)),
296    }
297}
298
299fn get_optional_now(account_id: &str) -> Result<Option<Zeroizing<String>>, String> {
300    match entry(account_id)?.get_password() {
301        Ok(pw) => Ok(Some(Zeroizing::new(pw))),
302        Err(keyring::Error::NoEntry) => Ok(None),
303        Err(e) => Err(format!("keychain read failed: {e}")),
304    }
305}
306
307/// Delete the secret for `account_id`. A missing entry is treated as
308/// success (idempotent delete).
309///
310/// Deliberately unbounded — see this module's doc comment for why a
311/// timeout is unsafe for a mutation specifically.
312pub fn delete(account_id: &str) -> Result<(), String> {
313    if let Some(dir) = FILE_STORE.get() {
314        return file::delete(dir, &account_key(account_id));
315    }
316    let deleted = match entry(account_id)?.delete_password() {
317        Ok(()) => Ok(()),
318        Err(keyring::Error::NoEntry) => Ok(()),
319        Err(e) => Err(format!("keychain delete failed: {e}")),
320    };
321    shared_reads().after_write(account_id, deleted)
322}
323
324fn run_outcome_message(op: &str, outcome: RunOutcome) -> String {
325    match outcome {
326        RunOutcome::TimedOut => format!(
327            "keychain {op} timed out after {TIMEOUT:?} — likely an unanswered OS access-consent \
328             prompt (see docs/retro/retro-macos-muxbus-keychain-prompt-storm-2026-08-19.md §5)"
329        ),
330        RunOutcome::WorkerPanicked => {
331            format!("keychain {op} failed: the background thread performing it panicked")
332        }
333    }
334}
335
336/// The file store. Each entry is one file named by the SHA-256 of its
337/// account key, so no key text (ids, hostnames) reaches a file name and no
338/// key can escape the directory. No timeouts: file operations don't wait
339/// on a user.
340mod file {
341    use super::*;
342    use sha2::{Digest, Sha256};
343    use std::io::Write;
344
345    pub(super) fn path_for(dir: &Path, key: &str) -> PathBuf {
346        let digest = Sha256::digest(key.as_bytes());
347        dir.join(digest.iter().map(|b| format!("{b:02x}")).collect::<String>())
348    }
349
350    /// Create `dir` if needed and make it owner-only (`0700`), also when it
351    /// already existed with wider permissions.
352    pub(super) fn prepare_dir(dir: &Path) -> Result<(), String> {
353        let mut builder = std::fs::DirBuilder::new();
354        builder.recursive(true);
355        #[cfg(unix)]
356        {
357            use std::os::unix::fs::DirBuilderExt;
358            builder.mode(0o700);
359        }
360        builder
361            .create(dir)
362            .map_err(|e| format!("secret store: cannot create {}: {e}", dir.display()))?;
363        #[cfg(unix)]
364        {
365            use std::os::unix::fs::PermissionsExt;
366            std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
367                .map_err(|e| format!("secret store: cannot restrict {}: {e}", dir.display()))?;
368        }
369        Ok(())
370    }
371
372    /// Write via a fresh `0600` temp file and a rename, so a reader never
373    /// sees a partial secret and a crash never leaves one behind.
374    pub(super) fn put(dir: &Path, key: &str, secret: &str) -> Result<(), String> {
375        let path = path_for(dir, key);
376        let tmp = dir.join(format!(".tmp-{}", uuid::Uuid::new_v4().simple()));
377        let mut opts = std::fs::OpenOptions::new();
378        opts.write(true).create_new(true);
379        #[cfg(unix)]
380        {
381            use std::os::unix::fs::OpenOptionsExt;
382            opts.mode(0o600);
383        }
384        let written = opts
385            .open(&tmp)
386            .and_then(|mut f| f.write_all(secret.as_bytes()).and_then(|()| f.sync_all()))
387            .and_then(|()| std::fs::rename(&tmp, &path));
388        written.map_err(|e| {
389            let _ = std::fs::remove_file(&tmp);
390            format!("secret store write failed: {e}")
391        })
392    }
393
394    pub(super) fn get_optional(dir: &Path, key: &str) -> Result<Option<Zeroizing<String>>, String> {
395        match std::fs::read(path_for(dir, key)) {
396            Ok(bytes) => String::from_utf8(bytes)
397                .map(|s| Some(Zeroizing::new(s)))
398                .map_err(|_| "secret store read failed: the entry is not valid UTF-8".to_string()),
399            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
400            Err(e) => Err(format!("secret store read failed: {e}")),
401        }
402    }
403
404    pub(super) fn delete(dir: &Path, key: &str) -> Result<(), String> {
405        match std::fs::remove_file(path_for(dir, key)) {
406            Ok(()) => Ok(()),
407            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
408            Err(e) => Err(format!("secret store delete failed: {e}")),
409        }
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416
417    #[test]
418    fn file_store_round_trips_overwrites_and_deletes() {
419        let tmp = tempfile::tempdir().unwrap();
420        let dir = tmp.path().join("secrets");
421        file::prepare_dir(&dir).unwrap();
422        let key = account_key("abc");
423        assert!(file::get_optional(&dir, &key).unwrap().is_none());
424        file::put(&dir, &key, "first").unwrap();
425        assert_eq!(file::get_optional(&dir, &key).unwrap().as_deref().map(String::as_str), Some("first"));
426        file::put(&dir, &key, "second").unwrap();
427        assert_eq!(file::get_optional(&dir, &key).unwrap().as_deref().map(String::as_str), Some("second"));
428        file::delete(&dir, &key).unwrap();
429        assert!(file::get_optional(&dir, &key).unwrap().is_none());
430        // Deleting what isn't there is fine, as with the keychain.
431        file::delete(&dir, &key).unwrap();
432        // Only the entries themselves are left: no temp files.
433        file::put(&dir, &key, "x").unwrap();
434        let names: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name()).collect();
435        assert_eq!(names.len(), 1, "{names:?}");
436    }
437
438    #[test]
439    fn file_store_entries_are_separate_and_named_by_hash() {
440        let tmp = tempfile::tempdir().unwrap();
441        let dir = tmp.path().to_path_buf();
442        // Keys with separators and traversal text stay inside the directory.
443        let (a, b) = ("muxbus:global", "browser-auth:id:../../etc/passwd");
444        file::put(&dir, a, "A").unwrap();
445        file::put(&dir, b, "B").unwrap();
446        assert_eq!(file::get_optional(&dir, a).unwrap().as_deref().map(String::as_str), Some("A"));
447        assert_eq!(file::get_optional(&dir, b).unwrap().as_deref().map(String::as_str), Some("B"));
448        for key in [a, b] {
449            let p = file::path_for(&dir, key);
450            assert_eq!(p.parent(), Some(dir.as_path()));
451            let name = p.file_name().unwrap().to_str().unwrap().to_string();
452            assert_eq!(name.len(), 64);
453            assert!(name.bytes().all(|c| c.is_ascii_hexdigit()));
454        }
455    }
456
457    #[cfg(unix)]
458    #[test]
459    fn file_store_is_owner_only() {
460        use std::os::unix::fs::PermissionsExt;
461        let tmp = tempfile::tempdir().unwrap();
462        let dir = tmp.path().join("secrets");
463        std::fs::create_dir(&dir).unwrap();
464        std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
465        // An existing, wider directory is tightened.
466        file::prepare_dir(&dir).unwrap();
467        let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
468        assert_eq!(mode(&dir), 0o700);
469        file::put(&dir, "k", "v").unwrap();
470        assert_eq!(mode(&file::path_for(&dir, "k")), 0o600);
471    }
472
473    #[test]
474    fn account_key_is_namespaced() {
475        assert_eq!(account_key("abc123"), "acct:abc123");
476    }
477
478    const SHORT: Duration = Duration::from_millis(50);
479    const LONG: Duration = Duration::from_secs(10);
480
481    /// A fake platform read: counts how often it's called, and each call
482    /// blocks until `open`, like a read held by an unanswered dialog.
483    struct Gate {
484        calls: std::sync::atomic::AtomicUsize,
485        open: Mutex<bool>,
486        opened: Condvar,
487    }
488
489    impl Gate {
490        fn new() -> Arc<Self> {
491            Arc::new(Self { calls: Default::default(), open: Mutex::new(false), opened: Condvar::new() })
492        }
493        fn read(self: &Arc<Self>, value: &'static str) -> impl FnOnce() -> ReadResult + Send + 'static {
494            let gate = self.clone();
495            move || {
496                gate.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
497                drop(gate.opened.wait_while(lock(&gate.open), |open| !*open).unwrap());
498                Ok(Some(Zeroizing::new(value.to_string())))
499            }
500        }
501        fn open(&self) {
502            *lock(&self.open) = true;
503            self.opened.notify_all();
504        }
505        fn calls(&self) -> usize {
506            self.calls.load(std::sync::atomic::Ordering::SeqCst)
507        }
508    }
509
510    fn secret(value: &str) -> ReadResult {
511        Ok(Some(Zeroizing::new(value.to_string())))
512    }
513
514    #[test]
515    fn a_read_that_finishes_in_time_returns_its_value() {
516        let reads = SharedReads::new();
517        assert_eq!(reads.read("a", LONG, || secret("v")), Ok(secret("v")));
518        assert_eq!(reads.read("b", LONG, || Ok(None)), Ok(Ok(None)));
519    }
520
521    #[test]
522    fn retries_behind_an_unanswered_read_share_it_instead_of_queueing_more() {
523        // 2026-10-08: once-a-minute retries behind one open dialog queued a
524        // read each, and every Deny showed the next one's dialog.
525        let reads = SharedReads::new();
526        let gate = Gate::new();
527        for _ in 0..5 {
528            assert_eq!(reads.read("muxbus", SHORT, gate.read("v")), Err(RunOutcome::TimedOut));
529        }
530        std::thread::sleep(SHORT);
531        assert_eq!(gate.calls(), 1, "each retry started its own platform read");
532        gate.open();
533    }
534
535    #[test]
536    fn callers_that_join_a_read_all_get_its_answer() {
537        let reads = Arc::new(SharedReads::new());
538        let gate = Gate::new();
539        let waiters: Vec<_> = (0..3)
540            .map(|_| {
541                let (reads, read) = (reads.clone(), gate.read("secret"));
542                std::thread::spawn(move || reads.read("acct", LONG, read))
543            })
544            .collect();
545        std::thread::sleep(Duration::from_millis(300)); // all three are waiting
546        gate.open();
547        for waiter in waiters {
548            assert_eq!(waiter.join().unwrap(), Ok(secret("secret")));
549        }
550        assert_eq!(gate.calls(), 1);
551    }
552
553    #[test]
554    fn a_read_after_the_answer_reads_again() {
555        // No caching: a later read must see a later value.
556        let reads = SharedReads::new();
557        let gate = Gate::new();
558        gate.open();
559        assert_eq!(reads.read("acct", LONG, gate.read("v")), Ok(secret("v")));
560        assert_eq!(reads.read("acct", LONG, gate.read("v")), Ok(secret("v")));
561        assert_eq!(gate.calls(), 2);
562    }
563
564    #[test]
565    fn accounts_are_read_separately() {
566        let reads = SharedReads::new();
567        let gate = Gate::new();
568        assert_eq!(reads.read("a", SHORT, gate.read("v")), Err(RunOutcome::TimedOut));
569        assert_eq!(reads.read("b", SHORT, gate.read("v")), Err(RunOutcome::TimedOut));
570        std::thread::sleep(SHORT);
571        assert_eq!(gate.calls(), 2);
572        gate.open();
573    }
574
575    #[test]
576    fn after_forget_the_next_read_starts_its_own_and_later_ones_share_that() {
577        let reads = SharedReads::new();
578        let (old, new) = (Gate::new(), Gate::new());
579        assert_eq!(reads.read("acct", SHORT, old.read("before")), Err(RunOutcome::TimedOut));
580        reads.forget("acct"); // a write happened
581        assert_eq!(reads.read("acct", SHORT, new.read("after")), Err(RunOutcome::TimedOut));
582        // The old read finishing must not drop the new one from the map.
583        old.open();
584        std::thread::sleep(SHORT);
585        assert_eq!(reads.read("acct", SHORT, new.read("after")), Err(RunOutcome::TimedOut));
586        std::thread::sleep(SHORT);
587        assert_eq!((old.calls(), new.calls()), (1, 1));
588        new.open();
589    }
590
591    #[test]
592    fn only_a_write_that_succeeded_stops_sharing_the_waiting_read() {
593        let reads = SharedReads::new();
594        let gate = Gate::new();
595        assert_eq!(reads.read("acct", SHORT, gate.read("v")), Err(RunOutcome::TimedOut));
596        // A failed write changed nothing: retries keep joining the waiting read.
597        let _ = reads.after_write("acct", Err::<(), _>("keychain write failed".to_string()));
598        assert_eq!(reads.read("acct", SHORT, gate.read("v")), Err(RunOutcome::TimedOut));
599        std::thread::sleep(SHORT);
600        assert_eq!(gate.calls(), 1, "a failed write let a retry start a second read");
601        // A write that succeeded did: the next read starts its own.
602        let _ = reads.after_write("acct", Ok(()));
603        assert_eq!(reads.read("acct", SHORT, gate.read("v")), Err(RunOutcome::TimedOut));
604        std::thread::sleep(SHORT);
605        assert_eq!(gate.calls(), 2);
606        gate.open();
607    }
608
609    #[test]
610    fn a_read_whose_thread_cannot_start_fails_and_does_not_block_the_next() {
611        let reads = SharedReads::new();
612        let refuse = |_: Box<dyn FnOnce() + Send>| Err(std::io::Error::other("no threads left"));
613        let failed = reads.read_with("acct", LONG, || Ok(None), refuse).unwrap();
614        assert!(failed.unwrap_err().contains("no threads left"));
615        // Not left waiting on a read that never started.
616        assert_eq!(reads.read("acct", LONG, || secret("v")), Ok(secret("v")));
617    }
618
619    #[test]
620    fn a_panicking_read_is_reported_as_such_and_the_next_read_tries_again() {
621        // A panic must not be reported as "timed out": it failed at once,
622        // not after the full deadline.
623        let reads = SharedReads::new();
624        assert_eq!(
625            reads.read("acct", LONG, || panic!("simulated worker panic")),
626            Err(RunOutcome::WorkerPanicked)
627        );
628        assert_eq!(reads.read("acct", LONG, || Ok(None)), Ok(Ok(None)));
629    }
630
631    #[test]
632    fn run_outcome_message_distinguishes_timeout_from_panic() {
633        assert!(run_outcome_message("read", RunOutcome::TimedOut).contains("timed out"));
634        assert!(run_outcome_message("read", RunOutcome::WorkerPanicked).contains("panicked"));
635    }
636}