返回 CodeWhale
test_support.rs
根目录 / crates / tui / src / test_support.rs
1 //! Shared test-only helpers.
2
3 use std::ffi::{OsStr, OsString};
4 use std::path::{Path, PathBuf};
5 use std::sync::{Mutex, OnceLock};
6 use std::time::{SystemTime, UNIX_EPOCH};
7
8 pub(crate) use crate::shell_dispatcher::test_env_lock::{
9 EnvScopeMembership, EnvScopeTicket, TestEnvLock, current_env_scope_generation,
10 current_thread_holds_test_env_lock, env_scope_ticket, join_env_scope, lock_test_env,
11 with_test_env_lock,
12 };
13
14 /// Process-wide state root for unit tests that do not intentionally provide an
15 /// explicit config/settings path.
16 ///
17 /// The production fallback is the user's real home. That is useful at runtime
18 /// and unsafe in a parallel test binary: an unguarded save can otherwise read
19 /// or overwrite the developer's config. Tests that exercise path precedence
20 /// still hold [`lock_test_env`] and provide explicit temporary environment
21 /// values; every other test is confined here — enforced by
22 /// [`guarded_environment_provides_state_paths`], not assumed.
23 pub(crate) fn isolated_test_state_root() -> &'static Path {
24 static ROOT: OnceLock<PathBuf> = OnceLock::new();
25 ROOT.get_or_init(|| {
26 let nonce = SystemTime::now()
27 .duration_since(UNIX_EPOCH)
28 .unwrap_or_default()
29 .as_nanos();
30 let root = std::env::temp_dir().join(format!(
31 "codewhale-tui-test-state-{}-{nonce}",
32 std::process::id()
33 ));
34 std::fs::create_dir_all(&root).unwrap_or_else(|error| {
35 panic!(
36 "failed to create isolated unit-test state root {}: {error}",
37 root.display()
38 )
39 });
40 // Match resolvers that canonicalize their root (macOS aliases /var to
41 // /private/var). Every fence must name the same physical test store.
42 root.canonicalize().expect("canonical test state root")
43 })
44 }
45
46 /// Where the calling test's state should live when it has not sealed the
47 /// environment itself.
48 ///
49 /// Two different callers land here. A test that never took [`lock_test_env`]
50 /// gets the shared root, exactly as before — those tests already coexist there
51 /// under [`with_test_state_io_lock`]. A test that *holds* the lock but sealed
52 /// nothing gets a private directory instead: before #5359 it resolved the
53 /// developer's real home, so it has never shared the process root, and several
54 /// such tests run full settings transactions. Adding that traffic to the shared
55 /// root pushed the transaction lock past its deadline and hung unrelated
56 /// `config_command_*` tests. Keep them isolated from the developer *and* from
57 /// each other.
58 pub(crate) fn unsealed_test_state_root() -> PathBuf {
59 let shared = isolated_test_state_root();
60 if !current_thread_holds_test_env_lock() {
61 return shared.to_path_buf();
62 }
63 // libtest runs each test in a fresh thread. Keep one root for that thread:
64 // a settings save resolves its path more than once, while different tests
65 // must not inherit each other's files.
66 HOLDER_ROOT.with(|cached| {
67 cached
68 .get_or_init(|| {
69 let root = shared.join(format!("env-holder-{:?}", std::thread::current().id()));
70 std::fs::create_dir_all(&root).unwrap_or_else(|error| {
71 panic!(
72 "failed to create per-holder test state root {}: {error}",
73 root.display()
74 )
75 });
76 root
77 })
78 .clone()
79 })
80 }
81
82 thread_local! {
83 static HOLDER_ROOT: OnceLock<PathBuf> = const { OnceLock::new() };
84 }
85
86 /// Where a state resolver must put `name` instead of the user's home: the
87 /// isolated test root when no test has sealed the home (see [`home_is_sealed`]),
88 /// `None` when one has and the resolver should follow the environment as
89 /// production does.
90 ///
91 /// Every resolver that reaches `~/.codewhale` and is reachable from a test
92 /// needs this fence, because incidental callers (an `App` constructor, a
93 /// command dispatch) never think about the home at all. Call it first, under
94 /// `#[cfg(test)]`, so the production path stays byte-for-byte unchanged.
95 ///
96 /// The directory is under the shared isolated root, not the per-holder one
97 /// [`unsealed_test_state_root`] hands to lock holders: these resolvers are
98 /// reached from worker threads as well as the test thread, and a test must see
99 /// one directory from both. The directory exists when this returns, so callers
100 /// need no filesystem call of their own on the fenced branch.
101 pub(crate) fn unsealed_state_dir(name: impl AsRef<Path>) -> Option<PathBuf> {
102 if home_is_sealed() {
103 return None;
104 }
105 let dir = isolated_test_state_root().join(name);
106 std::fs::create_dir_all(&dir).unwrap_or_else(|error| {
107 panic!(
108 "failed to create the unsealed test state dir {}: {error}",
109 dir.display()
110 )
111 });
112 Some(dir)
113 }
114
115 /// A fixture workspace an OS sandbox can still see (#6305).
116 ///
117 /// The Linux bwrap wrapper mounts a fresh `--tmpfs /tmp` before it binds the
118 /// policy's writable roots (`sandbox/bwrap.rs`), and an enforced read-only
119 /// command has no writable roots at all — nothing re-exposes the host `/tmp`.
120 /// A fixture rooted there is shadowed inside the sandbox, so the trailing
121 /// `--chdir <workspace>` lands on a path that no longer exists and bwrap exits
122 /// with `Can't chdir to /tmp/.tmpXXXXXX`. The tmpfs is deliberate isolation and
123 /// a security boundary, so the fixture moves instead of the mount.
124 ///
125 /// Known limitations: this relocates only the directory the sandboxed command
126 /// chdirs into. It does not make anything else under the host `/tmp` reachable
127 /// from inside the sandbox, and it says nothing about `/dev` or `/proc`, which
128 /// bwrap also replaces. Use it for the sandbox probes; plain `tempfile::tempdir`
129 /// stays correct everywhere else.
130 // Every caller is `#[cfg(unix)]` (the bwrap probes); on Windows these would
131 // be dead code and CI builds tests with `-Dwarnings`.
132 #[cfg(unix)]
133 pub(crate) fn sandbox_visible_tempdir() -> tempfile::TempDir {
134 let root = sandbox_visible_fixture_root();
135 tempfile::tempdir_in(root).unwrap_or_else(|error| {
136 panic!(
137 "failed to create a sandbox-visible fixture workspace in {}: {error}",
138 root.display()
139 )
140 })
141 }
142
143 /// `OUT_DIR` is this crate's own build directory, so it follows the Cargo
144 /// target directory rather than `TMPDIR` — the one path every test binary
145 /// already owns and that is outside `/tmp` in every normal layout. A target
146 /// directory deliberately placed under `/tmp` would silently reintroduce
147 /// #6305, so say so instead of handing back a shadowed path.
148 #[cfg(unix)]
149 fn sandbox_visible_fixture_root() -> &'static Path {
150 static ROOT: OnceLock<PathBuf> = OnceLock::new();
151 ROOT.get_or_init(|| {
152 let root = Path::new(env!("OUT_DIR")).join("sandbox-fixtures");
153 assert!(
154 !root.starts_with("/tmp"),
155 "sandbox fixtures need a root outside /tmp, which bwrap replaces with a fresh \
156 tmpfs: {} is under it. Point CARGO_TARGET_DIR somewhere else.",
157 root.display()
158 );
159 std::fs::create_dir_all(&root).unwrap_or_else(|error| {
160 panic!(
161 "failed to create the sandbox fixture root {}: {error}",
162 root.display()
163 )
164 });
165 root
166 })
167 }
168
169 /// Build a syntactically valid, non-secret JWT fixture without embedding a
170 /// high-entropy token-shaped literal in Git history.
171 pub(crate) fn future_test_jwt(label: &str) -> String {
172 use base64::Engine as _;
173
174 let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(br#"{"exp":9999999999}"#);
175 format!("test.{payload}.{label}")
176 }
177
178 fn state_io_lock() -> &'static Mutex<()> {
179 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
180 LOCK.get_or_init(|| Mutex::new(()))
181 }
182
183 /// Serialize read/merge/write operations against the process-wide isolated
184 /// test state root.
185 ///
186 /// Path isolation protects the developer's files, but parallel tests still
187 /// share the same temporary files. Settings persistence is a multi-step
188 /// operation, so it needs this second barrier around the complete I/O
189 /// transaction rather than only around path resolution.
190 pub(crate) fn with_test_state_io_lock<T>(operation: impl FnOnce() -> T) -> T {
191 let _guard = match state_io_lock().lock() {
192 Ok(guard) => guard,
193 Err(poisoned) => poisoned.into_inner(),
194 };
195 operation()
196 }
197
198 /// Build a test phase's future inside this call and box it (#6362).
199 ///
200 /// In debug builds every inline `async {}` value gets a stack slot in the
201 /// enclosing poll frame the size of that future's whole state machine, and
202 /// the slots are never reused, so a body that awaits four phases inline
203 /// carries all four state machines on its own frame at once (measured at
204 /// 806 KiB for the runtime-store binding test). Constructing the phase here
205 /// leaves the caller holding a pointer, and the phase's own temporaries die
206 /// with its poll frame.
207 pub(crate) fn boxed_phase<'a, T, M, F>(
208 make: M,
209 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = T> + 'a>>
210 where
211 M: FnOnce() -> F,
212 F: std::future::Future<Output = T> + 'a,
213 {
214 Box::pin(make())
215 }
216
217 /// Drive a test future on a thread with libtest's default 2 MiB stack,
218 /// whatever `RUST_MIN_STACK` says (#6362).
219 ///
220 /// CI exports a 16 MiB `RUST_MIN_STACK` for every test thread, so a test
221 /// that only fits because of that export never learns it overflowed the
222 /// stack a contributor's plain `cargo test` gives it. The future is built on
223 /// the spawned thread (so it need not be `Send`) and pinned before
224 /// `block_on`, exactly as `#[tokio::test]` drives a current-thread runtime;
225 /// a panic inside propagates to the caller unchanged. An overflow still
226 /// aborts the process with "has overflowed its stack": that is the reported
227 /// symptom, not something this helper can turn into a panic.
228 pub(crate) fn block_on_default_test_stack<M, F, T>(make: M) -> T
229 where
230 M: FnOnce() -> F + Send + 'static,
231 F: std::future::Future<Output = T>,
232 T: Send + 'static,
233 {
234 const DEFAULT_TEST_THREAD_STACK: usize = 2 * 1024 * 1024;
235 std::thread::Builder::new()
236 .name("default-test-stack".into())
237 .stack_size(DEFAULT_TEST_THREAD_STACK)
238 .spawn(move || {
239 let runtime = tokio::runtime::Builder::new_current_thread()
240 .enable_all()
241 .build()
242 .expect("current-thread test runtime");
243 let future = make();
244 tokio::pin!(future);
245 runtime.block_on(future)
246 })
247 .expect("spawn the default-stack test thread")
248 .join()
249 .unwrap_or_else(|panic| std::panic::resume_unwind(panic))
250 }
251
252 /// Restore one environment variable when dropped.
253 ///
254 /// Callers that mutate process-global environment variables must hold
255 /// [`lock_test_env`] until after this guard is dropped.
256 ///
257 /// Every live guard is also recorded in [`guarded_env_keys`], so path
258 /// resolution can distinguish a test that deliberately redirected `HOME`
259 /// from one that merely holds the lock to serialize unrelated env access —
260 /// see [`guarded_environment_provides_state_paths`].
261 pub(crate) struct EnvVarGuard {
262 key: &'static str,
263 previous: Option<OsString>,
264 }
265
266 fn guarded_env_keys() -> &'static Mutex<std::collections::HashMap<&'static str, usize>> {
267 static KEYS: OnceLock<Mutex<std::collections::HashMap<&'static str, usize>>> = OnceLock::new();
268 KEYS.get_or_init(|| Mutex::new(std::collections::HashMap::new()))
269 }
270
271 fn register_guarded_env_key(key: &'static str) {
272 let mut keys = match guarded_env_keys().lock() {
273 Ok(keys) => keys,
274 Err(poisoned) => poisoned.into_inner(),
275 };
276 *keys.entry(key).or_insert(0) += 1;
277 }
278
279 fn unregister_guarded_env_key(key: &'static str) {
280 let mut keys = match guarded_env_keys().lock() {
281 Ok(keys) => keys,
282 Err(poisoned) => poisoned.into_inner(),
283 };
284 if let Some(count) = keys.get_mut(key) {
285 *count -= 1;
286 if *count == 0 {
287 keys.remove(key);
288 }
289 }
290 }
291
292 /// Whether some live [`EnvVarGuard`] currently covers `key`.
293 pub(crate) fn env_var_currently_guarded(key: &str) -> bool {
294 match guarded_env_keys().lock() {
295 Ok(keys) => keys.contains_key(key),
296 Err(poisoned) => poisoned.into_inner().contains_key(key),
297 }
298 }
299
300 /// Whether the calling test actually provided the state-path environment it
301 /// is about to resolve.
302 ///
303 /// Holding [`lock_test_env`] alone is not that: many tests hold the lock only
304 /// to serialize access to unrelated variables (`TERM_PROGRAM`, API keys) and
305 /// have provided no temporary paths at all. Trusting the lock routed those
306 /// tests to the developer's real `~/.codewhale` state, which is exactly the
307 /// leak the isolated root exists to prevent (#5359). A test earns environment
308 /// resolution by holding the lock *and* either setting one of the explicit
309 /// override variables or redirecting `HOME`/`USERPROFILE` through
310 /// [`EnvVarGuard`].
311 pub(crate) fn guarded_environment_provides_state_paths() -> bool {
312 if !current_thread_holds_test_env_lock() {
313 return false;
314 }
315 let guarded_path_is_present = |var: &str| {
316 env_var_currently_guarded(var)
317 && std::env::var_os(var)
318 .is_some_and(|value| value.to_str().is_none_or(|text| !text.trim().is_empty()))
319 };
320 [
321 "CODEWHALE_HOME",
322 "CODEWHALE_CONFIG_PATH",
323 "DEEPSEEK_CONFIG_PATH",
324 "HOME",
325 "USERPROFILE",
326 ]
327 .iter()
328 .any(|var| guarded_path_is_present(var))
329 }
330
331 /// Whether the calling test's live seal pins the user's *home* — the root that
332 /// user state (sessions, snapshots, plugin bundles, logs) resolves under — as
333 /// opposed to merely redirecting a config file.
334 ///
335 /// The owner and workers enrolled through [`join_env_scope`] follow the seal.
336 /// Unrelated parallel tests and workers without that scope use the isolated
337 /// root: following another test's environment would race its restoration to
338 /// the developer's real profile. Pass [`env_scope_ticket`] to workers that need
339 /// their owner's home.
340 ///
341 /// Stricter than [`guarded_environment_provides_state_paths`], which also
342 /// accepts a guarded config-path override: such a test still resolves
343 /// `~/.codewhale` for everything else. `CODEWHALE_HOME` outranks `HOME` when it
344 /// is set, so a guarded `HOME` seals nothing while an unguarded
345 /// `CODEWHALE_HOME` is in the ambient environment.
346 pub(crate) fn home_is_sealed() -> bool {
347 if !current_thread_holds_test_env_lock() {
348 return false;
349 }
350 let present = |var: &str| {
351 std::env::var_os(var)
352 .is_some_and(|value| value.to_str().is_none_or(|text| !text.trim().is_empty()))
353 };
354 if present("CODEWHALE_HOME") {
355 return env_var_currently_guarded("CODEWHALE_HOME");
356 }
357 ["HOME", "USERPROFILE"]
358 .iter()
359 .any(|var| env_var_currently_guarded(var) && present(var))
360 }
361
362 impl EnvVarGuard {
363 pub(crate) fn set(key: &'static str, value: impl AsRef<OsStr>) -> Self {
364 debug_assert!(
365 current_thread_holds_test_env_lock(),
366 "EnvVarGuard::set({key}) requires lock_test_env()"
367 );
368 let previous = std::env::var_os(key);
369 // SAFETY: callers hold the process-wide test env mutex.
370 unsafe { std::env::set_var(key, value) };
371 register_guarded_env_key(key);
372 Self { key, previous }
373 }
374
375 pub(crate) fn remove(key: &'static str) -> Self {
376 debug_assert!(
377 current_thread_holds_test_env_lock(),
378 "EnvVarGuard::remove({key}) requires lock_test_env()"
379 );
380 let previous = std::env::var_os(key);
381 // SAFETY: callers hold the process-wide test env mutex.
382 unsafe { std::env::remove_var(key) };
383 register_guarded_env_key(key);
384 Self { key, previous }
385 }
386 }
387
388 impl Drop for EnvVarGuard {
389 fn drop(&mut self) {
390 // Withdraw the claim *before* restoring the value: a reader on another
391 // thread that still saw the key as guarded would otherwise follow the
392 // restored — ambient, possibly real — path for the instant in between.
393 unregister_guarded_env_key(self.key);
394 // SAFETY: callers hold the process-wide test env mutex until after this
395 // guard is dropped.
396 unsafe {
397 if let Some(value) = self.previous.take() {
398 std::env::set_var(self.key, value);
399 } else {
400 std::env::remove_var(self.key);
401 }
402 }
403 }
404 }
405
406 /// Seal the user's home onto a directory the test owns, for the life of one
407 /// test.
408 ///
409 /// This is the one way a test takes the user's state out of play. It pins
410 /// `HOME`, `USERPROFILE` and `CODEWHALE_HOME` through [`EnvVarGuard`] — so
411 /// [`guarded_environment_provides_state_paths`] recognises the seal — and
412 /// removes the aliases that would otherwise route around them
413 /// (`HOMEDRIVE`/`HOMEPATH`, `CODEWHALE_CONFIG_PATH`, `DEEPSEEK_CONFIG_PATH`,
414 /// `DEEPSEEK_HOME`). Pinning `HOME` alone is not a seal: an ambient
415 /// `CODEWHALE_HOME` takes precedence over it, so a test that "scoped" only
416 /// `HOME` still wrote the developer's real profile.
417 ///
418 /// It holds the process-wide environment lock, which is not reentrant: keep
419 /// one seal per test, and build any fixture that takes the lock itself
420 /// (settings, diagnostics harnesses) after it. Anything that reads or writes
421 /// the user's state — instructions, skills, sessions, snapshots, settings,
422 /// credentials — then lands in the seal.
423 pub(crate) struct SealedHome {
424 // Drop order matters: restore the environment, then delete the directory,
425 // then release the lock.
426 _vars: Vec<EnvVarGuard>,
427 _dir: Option<tempfile::TempDir>,
428 home: PathBuf,
429 codewhale_home: PathBuf,
430 _lock: TestEnvLock,
431 }
432
433 impl SealedHome {
434 /// Seal onto fresh, empty directories: `<tmp>/home` and
435 /// `<tmp>/codewhale-home`.
436 pub(crate) fn new() -> Self {
437 let lock = lock_test_env();
438 let dir = tempfile::TempDir::new().expect("sealed home tempdir");
439 let home = dir.path().join("home");
440 let codewhale_home = dir.path().join("codewhale-home");
441 std::fs::create_dir_all(&home).expect("sealed home dir");
442 std::fs::create_dir_all(&codewhale_home).expect("sealed codewhale home dir");
443 Self::pin(lock, Some(dir), home, codewhale_home)
444 }
445
446 /// Seal onto a home the test already owns: `HOME` is `home` and the
447 /// Codewhale home is `home/.codewhale`, the layout an unset
448 /// `CODEWHALE_HOME` resolves to. For tests whose fixtures are built under
449 /// one tempdir that also plays the home.
450 pub(crate) fn at(home: &Path) -> Self {
451 let lock = lock_test_env();
452 let codewhale_home = home.join(".codewhale");
453 Self::pin(lock, None, home.to_path_buf(), codewhale_home)
454 }
455
456 fn pin(
457 lock: TestEnvLock,
458 dir: Option<tempfile::TempDir>,
459 home: PathBuf,
460 codewhale_home: PathBuf,
461 ) -> Self {
462 // `CODEWHALE_HOME` first: it is dropped first, so the seal never
463 // outlives the override that outranks `HOME`.
464 let vars = vec![
465 EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home),
466 EnvVarGuard::set("HOME", &home),
467 EnvVarGuard::set("USERPROFILE", &home),
468 EnvVarGuard::remove("HOMEDRIVE"),
469 EnvVarGuard::remove("HOMEPATH"),
470 EnvVarGuard::remove("CODEWHALE_CONFIG_PATH"),
471 EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH"),
472 EnvVarGuard::remove("DEEPSEEK_HOME"),
473 ];
474 Self {
475 _vars: vars,
476 _dir: dir,
477 home,
478 codewhale_home,
479 _lock: lock,
480 }
481 }
482
483 /// The sealed `HOME` / `USERPROFILE`.
484 pub(crate) fn home(&self) -> &Path {
485 &self.home
486 }
487
488 /// The sealed `CODEWHALE_HOME`.
489 pub(crate) fn codewhale_home(&self) -> &Path {
490 &self.codewhale_home
491 }
492 }
493
494 /// Find the byte position of the first divergence between two strings,
495 /// returning a windowed view (`±32 bytes` around the divergence) so failures
496 /// in cache-prefix-stability tests show *which* bytes drifted, not just that
497 /// they did. Returns `None` when the strings are byte-identical.
498 pub(crate) fn first_divergence(a: &str, b: &str) -> Option<(usize, String, String)> {
499 let a_bytes = a.as_bytes();
500 let b_bytes = b.as_bytes();
501 let max = a_bytes.len().min(b_bytes.len());
502 for i in 0..max {
503 if a_bytes[i] != b_bytes[i] {
504 let lo = i.saturating_sub(32);
505 let a_hi = (i + 32).min(a_bytes.len());
506 let b_hi = (i + 32).min(b_bytes.len());
507 let a_ctx = String::from_utf8_lossy(&a_bytes[lo..a_hi]).into_owned();
508 let b_ctx = String::from_utf8_lossy(&b_bytes[lo..b_hi]).into_owned();
509 return Some((i, a_ctx, b_ctx));
510 }
511 }
512 if a_bytes.len() != b_bytes.len() {
513 return Some((
514 max,
515 format!("(len={})", a_bytes.len()),
516 format!("(len={})", b_bytes.len()),
517 ));
518 }
519 None
520 }
521
522 /// Assert two strings are byte-identical, panicking with a windowed diff
523 /// around the first divergence when they aren't. Used by the prefix-cache
524 /// stability harness (#263, #280) to pin construction surfaces that land in
525 /// DeepSeek's KV cache prefix.
526 #[track_caller]
527 pub(crate) fn assert_byte_identical(label: &str, a: &str, b: &str) {
528 if let Some((pos, a_ctx, b_ctx)) = first_divergence(a, b) {
529 panic!(
530 "{label}: prompt construction is non-deterministic — first diff at byte {pos}\n\
531 ── side A (±32B) ──\n{a_ctx:?}\n── side B (±32B) ──\n{b_ctx:?}",
532 );
533 }
534 }
535
536 // ── Shared App/TuiOptions fixtures (#3923) ──────────────────────────────
537 //
538 // Before this module owned them, `create_test_app` was copy-pasted across 28
539 // test modules, each spelling out the full `TuiOptions` literal — 87 literals
540 // in all. The copies had drifted: different modules pinned different locales,
541 // currencies, and onboarding flags without anyone having chosen that, which is
542 // the non-hermeticity behind the intermittent `config_command_allow_shell_*`
543 // failures. Adding a `TuiOptions` field meant editing up to 87 sites.
544 //
545 // Express intentional differences by mutating the returned value at the call
546 // site, so the difference is visible as a deliberate line of test code rather
547 // than hidden inside another near-identical literal.
548
549 /// Default `TuiOptions` for tests, pinned to the deepseek-v4-pro fixture route.
550 /// Mark `workspace` trusted in the test's config, creating it first so the
551 /// trust key is the canonical path. Repository-supplied commands and skills
552 /// load only in a trusted workspace.
553 pub(crate) fn trust_workspace(workspace: &Path) {
554 std::fs::create_dir_all(workspace).expect("create test workspace");
555 crate::config::save_workspace_trust(workspace).expect("trust test workspace");
556 }
557
558 pub(crate) fn test_tui_options(workspace: impl AsRef<Path>) -> crate::tui::app::TuiOptions {
559 let workspace = workspace.as_ref().to_path_buf();
560 crate::tui::app::TuiOptions {
561 model: "deepseek-v4-pro".to_string(),
562 workspace,
563 config_path: None,
564 config_profile: None,
565 allow_shell: false,
566 screen_mode: crate::tui::app::ScreenMode::Fullscreen,
567 use_mouse_capture: false,
568 mouse_capture_preference: false,
569 use_bracketed_paste: true,
570 max_subagents: 1,
571 skills_dir: PathBuf::from("."),
572 memory_path: PathBuf::from("memory.md"),
573 notes_path: PathBuf::from("notes.txt"),
574 mcp_config_path: PathBuf::from("mcp.json"),
575 use_memory: false,
576 // Majority-of-fixtures defaults, measured across the 89 literals this
577 // helper replaced. Modules that need the other value say so explicitly.
578 start_in_agent_mode: false,
579 skip_onboarding: true,
580 yolo: false,
581 resume_session_id: None,
582 initial_input: None,
583 startup_notice: None,
584 }
585 }
586
587 /// Build an `App` whose observable state does not depend on the developer's
588 /// machine.
589 ///
590 /// `App::new` consults real persisted settings (provider/model maps,
591 /// auto-model, route limits, locale, currency), so an un-pinned fixture
592 /// computes against whatever the developer last configured. Every pin below
593 /// exists because some test was observed to depend on it. This fixture models
594 /// a session after the user has chosen a Startup action; direct `App::new`
595 /// tests remain the clean-launch authority.
596 pub(crate) fn test_app_with_options(options: crate::tui::app::TuiOptions) -> crate::tui::app::App {
597 let config = crate::config::Config::default();
598 let mut app = crate::tui::app::App::new(options, &config);
599
600 // Shared behavior tests operate on the live session surface. Do not make
601 // the production startup conditional for them: clean launches are covered
602 // by direct `App::new` tests that retain the Tideline Startup Hero.
603 app.launch.visible = false;
604
605 // Deterministic presentation regardless of host locale.
606 app.cost_currency = crate::pricing::CostCurrency::Usd;
607 app.ui_locale = codewhale_localization::Locale::En;
608 // Transcript tests must not depend on a concurrently swapped settings
609 // home. Tests for hidden reasoning opt out explicitly.
610 app.show_thinking = true;
611 // Pin the route identity: without this, a machine with customized
612 // settings computes context-window assertions against a different model
613 // than the requested deepseek-v4-pro.
614 app.set_provider_identity(crate::config::ProviderKind::Deepseek, "deepseek");
615 app.billing_presentation = crate::route_billing::BillingPresentation::Metered;
616 app.model = "deepseek-v4-pro".to_string();
617 app.auto_model = false;
618 app.last_effective_model = None;
619 app.active_route_limits = None;
620 app.active_context_window_override = None;
621 // Fixtures replace `app.workspace` freely. Do not retain `App::new`'s real
622 // process cwd as a second discovery root: parallel tests and a large
623 // developer checkout can otherwise consume the bounded mention index
624 // before the fixture workspace is scanned.
625 app.composer.mention_cwd = None;
626 // `App::new` derives onboarding state from the real `~/.codewhale`, and a
627 // pending step makes `ui::frame::render` take its onboarding early return
628 // before it assigns `last_prompt_area` or any other chrome geometry. CI
629 // has no such state, so a layout test written against that machine passes
630 // there and fails on any developer box mid-onboarding — for no product
631 // reason. Shared fixtures render the ordinary session surface; onboarding
632 // has its own tests that set this state deliberately.
633 app.onboarding = crate::tui::app::OnboardingState::None;
634 app
635 }
636
637 #[cfg(test)]
638 mod tests {
639 use super::*;
640 use std::sync::mpsc;
641 use std::time::Duration;
642
643 fn env_snapshot(keys: &[&str]) -> Vec<Option<OsString>> {
644 let _lock = lock_test_env();
645 keys.iter().map(std::env::var_os).collect()
646 }
647
648 #[test]
649 fn sealed_home_pins_every_alias_and_restores_the_environment() {
650 const KEYS: [&str; 8] = [
651 "HOME",
652 "USERPROFILE",
653 "HOMEDRIVE",
654 "HOMEPATH",
655 "CODEWHALE_HOME",
656 "CODEWHALE_CONFIG_PATH",
657 "DEEPSEEK_CONFIG_PATH",
658 "DEEPSEEK_HOME",
659 ];
660 let before = env_snapshot(&KEYS);
661 {
662 let seal = SealedHome::new();
663 let now = |key: &str| std::env::var_os(key);
664 assert_eq!(now("HOME").as_deref(), Some(seal.home().as_os_str()));
665 assert_eq!(now("USERPROFILE").as_deref(), Some(seal.home().as_os_str()));
666 assert_eq!(
667 now("CODEWHALE_HOME").as_deref(),
668 Some(seal.codewhale_home().as_os_str())
669 );
670 for alias in [
671 "HOMEDRIVE",
672 "HOMEPATH",
673 "CODEWHALE_CONFIG_PATH",
674 "DEEPSEEK_CONFIG_PATH",
675 "DEEPSEEK_HOME",
676 ] {
677 assert_eq!(now(alias), None, "{alias} would route around the seal");
678 }
679 assert!(guarded_environment_provides_state_paths());
680 assert!(home_is_sealed());
681 // A resolver that follows the environment lands inside the seal.
682 let sessions = crate::session_manager::default_sessions_dir().expect("sessions dir");
683 assert_eq!(sessions, seal.codewhale_home().join("sessions"));
684 }
685 {
686 let owned = tempfile::tempdir().expect("owned home");
687 let seal = SealedHome::at(owned.path());
688 assert_eq!(seal.home(), owned.path());
689 assert_eq!(seal.codewhale_home(), owned.path().join(".codewhale"));
690 }
691 assert_eq!(env_snapshot(&KEYS), before, "the seal must restore the env");
692 }
693
694 /// The fence on every resolver a test reaches without ever thinking about
695 /// the home: an unsealed test must land in the isolated root, whatever the
696 /// ambient `HOME` / `CODEWHALE_HOME` say. Add a resolver here when it grows
697 /// a `#[cfg(test)]` fence — and fence the next one before a test finds it.
698 #[test]
699 fn unsealed_state_resolvers_stay_inside_the_isolated_root() {
700 // Hold the barrier to keep the fixture's environment stable.
701 let _lock = lock_test_env();
702 assert!(!home_is_sealed());
703 let root = isolated_test_state_root();
704 let inside = |label: &str, path: &Path| {
705 assert!(
706 path.starts_with(root),
707 "{label} resolved outside the isolated test root: {}",
708 path.display()
709 );
710 };
711
712 inside(
713 "sessions",
714 &crate::session_manager::default_sessions_dir().expect("sessions dir"),
715 );
716 inside(
717 "snapshots",
718 &crate::snapshot::snapshot_dir_for(Path::new("/nonexistent/codewhale-fence-probe"))
719 .expect("snapshot dir"),
720 );
721 for bundle in crate::plugins::builtin::materialized_dirs() {
722 inside("built-in plugins", &bundle);
723 }
724 let audit = crate::audit::audit_log_path().expect("audit log path");
725 assert!(
726 audit.starts_with(std::env::temp_dir()),
727 "the audit log resolved outside the temp dir: {}",
728 audit.display()
729 );
730 }
731
732 #[test]
733 fn home_seal_is_available_only_to_its_owner_and_enrolled_workers() {
734 let seal = SealedHome::new();
735 let sessions = seal.codewhale_home().join("sessions");
736 let ticket = env_scope_ticket().expect("seal owns an environment scope");
737
738 std::thread::spawn(move || {
739 assert!(!home_is_sealed(), "a foreign worker cannot follow the seal");
740 assert!(
741 crate::session_manager::default_sessions_dir()
742 .expect("isolated sessions dir")
743 .starts_with(isolated_test_state_root())
744 );
745
746 let membership = join_env_scope(Some(ticket)).expect("enroll worker");
747 assert!(home_is_sealed());
748 assert_eq!(
749 crate::session_manager::default_sessions_dir().expect("sealed sessions dir"),
750 sessions
751 );
752 drop(membership);
753 assert!(!home_is_sealed(), "leaving the scope withdraws the seal");
754 })
755 .join()
756 .expect("home seal worker");
757 }
758
759 /// Tripwire for the leak this module exists to prevent: run a sample of
760 /// the tests that once wrote `~/.codewhale` in a child process whose
761 /// *ambient* `HOME` and `CODEWHALE_HOME` are a seeded, read-only sentinel,
762 /// then require both that they pass and that the sentinel is byte-for-byte
763 /// as it was. A test that writes the ambient home without sealing its own
764 /// fails here by name, instead of silently rewriting a developer's profile.
765 ///
766 /// The sample covers one test per resolver class that leaked
767 /// (`App::new`, command dispatch, sessions, snapshots, plugin bundles,
768 /// credentials, the `/import-claude` report). To sweep a whole family,
769 /// set `CODEWHALE_TEST_AMBIENT_HOME_FILTER` to a libtest filter such as
770 /// `commands::`; that takes minutes, which is why it is not the default.
771 #[cfg(unix)]
772 #[test]
773 fn ambient_home_survives_a_sample_of_state_touching_tests() {
774 use std::os::unix::fs::PermissionsExt;
775
776 const PROBE_ENV: &str = "CODEWHALE_TEST_AMBIENT_HOME_PROBE";
777 const FILTER_ENV: &str = "CODEWHALE_TEST_AMBIENT_HOME_FILTER";
778 const SAMPLE: &[&str] = &[
779 "commands::contract::tests::bundle_construction_performs_no_eager_work",
780 "commands::contract::tests::skill_group_snapshot_list_and_restore_roundtrip",
781 "commands::debug_diagnostics_host_tests::test_context_report_subcommands_return_source_map",
782 "commands::debug_mutation_host_tests::test_patch_undo_requests_session_resync_after_restore",
783 "commands::groups::core::core::tests::test_clear_resets_all_state",
784 "commands::groups::plugins::tests::kimi_managed_import_refuses_linked_children",
785 "commands::session_lifecycle_regression_tests::fork_saves_parent_and_switches_to_child_session",
786 "commands::session_lifecycle_regression_tests::test_save_creates_file_and_sets_session_id",
787 "commands::tests::every_registered_command_dispatches_to_a_handler",
788 "commands::tests::every_command_alias_dispatches_to_a_handler",
789 "commands::tests::feat020_plugin_dispatches_through_public_seam",
790 ];
791
792 // The child runs the sample; it must not recurse into this probe.
793 if std::env::var_os(PROBE_ENV).is_some() {
794 return;
795 }
796
797 fn walk(dir: &Path, visit: &mut dyn FnMut(&Path, &std::fs::Metadata)) {
798 let mut entries: Vec<_> = std::fs::read_dir(dir)
799 .expect("read sentinel dir")
800 .map(|entry| entry.expect("sentinel entry").path())
801 .collect();
802 entries.sort();
803 for path in entries {
804 let metadata = std::fs::symlink_metadata(&path).expect("sentinel metadata");
805 visit(&path, &metadata);
806 if metadata.is_dir() {
807 walk(&path, visit);
808 }
809 }
810 }
811 fn listing(root: &Path) -> Vec<String> {
812 let mut out = Vec::new();
813 walk(root, &mut |path, metadata| {
814 let mtime = metadata
815 .modified()
816 .ok()
817 .and_then(|time| time.duration_since(UNIX_EPOCH).ok())
818 .map_or(0, |elapsed| elapsed.as_nanos());
819 out.push(format!(
820 "{} dir={} len={} mtime={mtime} contents={:?}",
821 path.strip_prefix(root).unwrap_or(path).display(),
822 metadata.is_dir(),
823 metadata.len(),
824 metadata
825 .is_file()
826 .then(|| std::fs::read(path).expect("read sentinel file"))
827 ));
828 });
829 out
830 }
831 fn set_readonly(root: &Path, readonly: bool) {
832 let mode = |dir: bool| match (dir, readonly) {
833 (true, true) => 0o500,
834 (true, false) => 0o700,
835 (false, true) => 0o400,
836 (false, false) => 0o600,
837 };
838 let apply = |path: &Path, dir: bool| {
839 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode(dir)))
840 .expect("set sentinel permissions");
841 };
842 walk(root, &mut |path, metadata| {
843 if !metadata.file_type().is_symlink() {
844 apply(path, metadata.is_dir());
845 }
846 });
847 apply(root, true);
848 }
849
850 let sentinel = tempfile::tempdir().expect("sentinel tempdir");
851 let home = sentinel.path().join("home");
852 let codewhale_home = sentinel.path().join("codewhale-home");
853 // Content a leaking test would read, rewrite or migrate.
854 for (relative, body) in [
855 (
856 "home/.claude/CLAUDE.md",
857 "# someone's Claude instructions\n",
858 ),
859 ("home/.claude.json", "{\"mcpServers\":{}}\n"),
860 ("home/.codewhale/instructions.md", "# real instructions\n"),
861 ("home/.deepseek/config.toml", "model = \"keep-me\"\n"),
862 ("home/.deepseek/sessions/legacy.json", "{}\n"),
863 ("codewhale-home/instructions.md", "# real instructions\n"),
864 ("codewhale-home/sessions/keep.json", "{}\n"),
865 ] {
866 let path = sentinel.path().join(relative);
867 std::fs::create_dir_all(path.parent().expect("sentinel parent")).expect("seed dir");
868 std::fs::write(path, body).expect("seed file");
869 }
870 assert!(home.is_dir() && codewhale_home.is_dir());
871 let before = listing(sentinel.path());
872 set_readonly(sentinel.path(), true);
873
874 let filter = std::env::var(FILTER_ENV).ok().filter(|f| !f.is_empty());
875 let mut child = std::process::Command::new(std::env::current_exe().expect("test binary"));
876 child.arg("--test-threads=4");
877 match &filter {
878 Some(filter) => child.arg(filter),
879 None => child.arg("--exact").args(SAMPLE),
880 };
881 let output = child
882 .env(PROBE_ENV, "1")
883 .env("HOME", &home)
884 .env("USERPROFILE", &home)
885 .env("CODEWHALE_HOME", &codewhale_home)
886 .env_remove("HOMEDRIVE")
887 .env_remove("HOMEPATH")
888 .env_remove("CODEWHALE_CONFIG_PATH")
889 .env_remove("DEEPSEEK_CONFIG_PATH")
890 .env_remove("DEEPSEEK_HOME")
891 .output();
892
893 // Restore write access first so a failure still cleans up after itself.
894 set_readonly(sentinel.path(), false);
895 let output = output.expect("run the sample under a read-only ambient home");
896 let stdout = String::from_utf8_lossy(&output.stdout);
897 let stderr = String::from_utf8_lossy(&output.stderr);
898 let report = format!("stdout:\n{stdout}\nstderr:\n{stderr}");
899
900 assert!(
901 output.status.success(),
902 "a test failed under a read-only ambient home — it reaches the user's \
903 state without sealing it (use SealedHome, or fence the resolver):\n{report}"
904 );
905 if filter.is_none() {
906 assert!(
907 stdout.contains(&format!("running {} tests", SAMPLE.len())),
908 "the sample names drifted from real tests; update SAMPLE:\n{report}"
909 );
910 }
911 assert_eq!(
912 listing(sentinel.path()),
913 before,
914 "a test wrote the ambient home:\n{report}"
915 );
916 }
917
918 #[test]
919 fn ambient_codewhale_home_is_not_a_test_seal() {
920 let _lock = lock_test_env();
921 let _ambient = EnvVarGuard::set("CODEWHALE_HOME", "/tmp/ambient-codewhale-home");
922 unregister_guarded_env_key("CODEWHALE_HOME");
923
924 let sealed = guarded_environment_provides_state_paths();
925
926 register_guarded_env_key("CODEWHALE_HOME");
927 assert!(!sealed, "ambient developer state must remain confined");
928 }
929
930 #[test]
931 fn a_config_path_guard_or_a_home_shadowed_by_the_ambient_override_is_not_a_home_seal() {
932 let _lock = lock_test_env();
933 let dir = tempfile::tempdir().expect("tempdir");
934 {
935 // Redirecting the config file leaves `~/.codewhale` in play.
936 let _config = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", dir.path().join("config.toml"));
937 let _no_home_override = EnvVarGuard::remove("CODEWHALE_HOME");
938 assert!(guarded_environment_provides_state_paths());
939 assert!(!home_is_sealed());
940 }
941 {
942 // `CODEWHALE_HOME` outranks `HOME`: guarding only `HOME` while an
943 // ambient `CODEWHALE_HOME` is set seals nothing.
944 let _ambient = EnvVarGuard::set("CODEWHALE_HOME", dir.path().join("ambient"));
945 unregister_guarded_env_key("CODEWHALE_HOME");
946 let _home = EnvVarGuard::set("HOME", dir.path());
947 let sealed = home_is_sealed();
948 register_guarded_env_key("CODEWHALE_HOME");
949 assert!(!sealed, "an ambient CODEWHALE_HOME shadows a guarded HOME");
950 }
951 {
952 let _no_home_override = EnvVarGuard::remove("CODEWHALE_HOME");
953 let _home = EnvVarGuard::set("HOME", dir.path());
954 assert!(home_is_sealed());
955 }
956 }
957
958 #[test]
959 fn removing_overrides_does_not_seal_the_ambient_home() {
960 let _lock = lock_test_env();
961 let _codewhale_home = EnvVarGuard::remove("CODEWHALE_HOME");
962 let _codewhale_config = EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
963 let _deepseek_config = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
964
965 assert!(
966 !guarded_environment_provides_state_paths(),
967 "removing an override must not expose the developer's HOME"
968 );
969 }
970
971 #[test]
972 fn removing_home_variables_does_not_seal_a_missing_path() {
973 let _lock = lock_test_env();
974 let _home = EnvVarGuard::remove("HOME");
975 let _userprofile = EnvVarGuard::remove("USERPROFILE");
976
977 assert!(
978 !guarded_environment_provides_state_paths(),
979 "removing HOME variables must keep state in the isolated test root"
980 );
981 assert_eq!(
982 crate::config_persistence::config_toml_path(None)
983 .expect("resolve isolated config path"),
984 unsealed_test_state_root().join(codewhale_config::CONFIG_FILE_NAME)
985 );
986 }
987
988 #[test]
989 fn lock_without_sealed_paths_does_not_use_developer_config() {
990 let _lock = lock_test_env();
991 let path = crate::config_persistence::config_toml_path(None)
992 .expect("resolve isolated config path");
993 let root = isolated_test_state_root();
994 assert!(
995 path.starts_with(root),
996 "holding lock_test_env without an EnvVarGuard must not read ~/.codewhale ({})",
997 path.display()
998 );
999 assert_eq!(
1000 path,
1001 unsealed_test_state_root().join(codewhale_config::CONFIG_FILE_NAME)
1002 );
1003 }
1004
1005 #[test]
1006 fn unguarded_state_writes_use_isolated_test_root() {
1007 const PROBE_ENV: &str = "CODEWHALE_TEST_STATE_ISOLATION_PROBE";
1008 const RECEIPT_ENV: &str = "CODEWHALE_TEST_STATE_ISOLATION_RECEIPT";
1009
1010 if std::env::var_os(PROBE_ENV).is_some() {
1011 let config_path =
1012 crate::config_persistence::persist_root_bool_key(None, "allow_shell", true)
1013 .expect("write isolated config");
1014 let direct_config_path =
1015 crate::config::save_workspace_trust(Path::new("/tmp/codewhale-test-workspace"))
1016 .expect("write through direct default config path");
1017 crate::settings::Settings::default()
1018 .save()
1019 .expect("write isolated settings");
1020 let settings_path =
1021 crate::settings::Settings::path().expect("resolve isolated settings");
1022 let root = isolated_test_state_root();
1023 assert!(config_path.starts_with(root), "{}", config_path.display());
1024 assert!(
1025 settings_path.starts_with(root),
1026 "{}",
1027 settings_path.display()
1028 );
1029 assert!(
1030 direct_config_path.starts_with(root),
1031 "{}",
1032 direct_config_path.display()
1033 );
1034 let receipt = std::env::var_os(RECEIPT_ENV).expect("receipt path");
1035 std::fs::write(
1036 receipt,
1037 format!(
1038 "{}\n{}\n{}\n{}\n",
1039 root.display(),
1040 config_path.display(),
1041 settings_path.display(),
1042 direct_config_path.display()
1043 ),
1044 )
1045 .expect("write isolation receipt");
1046 return;
1047 }
1048
1049 let sentinel = tempfile::tempdir().expect("sentinel home");
1050 let user_state = sentinel.path().join(".codewhale");
1051 std::fs::create_dir_all(&user_state).expect("create sentinel state");
1052 let config_path = user_state.join("config.toml");
1053 let settings_path = user_state.join("settings.toml");
1054 let config_sentinel = b"# developer config sentinel\n";
1055 let settings_sentinel = b"# developer settings sentinel\n";
1056 std::fs::write(&config_path, config_sentinel).expect("seed config");
1057 std::fs::write(&settings_path, settings_sentinel).expect("seed settings");
1058 let receipt_path = sentinel.path().join("receipt.txt");
1059
1060 let output = std::process::Command::new(std::env::current_exe().expect("test binary"))
1061 .arg("--exact")
1062 .arg("test_support::tests::unguarded_state_writes_use_isolated_test_root")
1063 .arg("--test-threads=1")
1064 .env(PROBE_ENV, "1")
1065 .env(RECEIPT_ENV, &receipt_path)
1066 .env("HOME", sentinel.path())
1067 .env("USERPROFILE", sentinel.path())
1068 .env_remove("CODEWHALE_HOME")
1069 .env_remove("CODEWHALE_CONFIG_PATH")
1070 .env_remove("DEEPSEEK_CONFIG_PATH")
1071 .output()
1072 .expect("run isolated-state probe");
1073 assert!(
1074 output.status.success(),
1075 "probe failed\nstdout:\n{}\nstderr:\n{}",
1076 String::from_utf8_lossy(&output.stdout),
1077 String::from_utf8_lossy(&output.stderr)
1078 );
1079
1080 assert_eq!(
1081 std::fs::read(&config_path).expect("read config sentinel"),
1082 config_sentinel
1083 );
1084 assert_eq!(
1085 std::fs::read(&settings_path).expect("read settings sentinel"),
1086 settings_sentinel
1087 );
1088
1089 let receipt = std::fs::read_to_string(&receipt_path).expect("read isolation receipt");
1090 let mut paths = receipt.lines().map(PathBuf::from);
1091 let isolated_root = paths.next().expect("root receipt");
1092 let written_config = paths.next().expect("config receipt");
1093 let written_settings = paths.next().expect("settings receipt");
1094 let direct_config = paths.next().expect("direct config receipt");
1095 assert!(!isolated_root.starts_with(sentinel.path()));
1096 assert!(written_config.starts_with(&isolated_root));
1097 assert!(written_settings.starts_with(&isolated_root));
1098 assert!(direct_config.starts_with(&isolated_root));
1099 assert!(written_config.exists());
1100 assert!(written_settings.exists());
1101 }
1102
1103 #[test]
1104 fn config_path_read_waits_for_foreign_env_redirect_to_restore() {
1105 let (started_tx, started_rx) = mpsc::channel();
1106 let (tx, rx) = mpsc::channel();
1107 let redirected = std::env::temp_dir().join(format!(
1108 "codewhale-config-path-read-barrier-{}",
1109 std::process::id()
1110 ));
1111
1112 let reader = {
1113 let lock = lock_test_env();
1114 let redirect = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &redirected);
1115 let reader = std::thread::spawn(move || {
1116 started_tx.send(()).expect("signal config path read start");
1117 tx.send(crate::config_persistence::config_toml_path(None))
1118 .expect("send resolved config path");
1119 });
1120
1121 started_rx
1122 .recv_timeout(Duration::from_secs(2))
1123 .expect("reader reached config path resolution");
1124 assert!(
1125 rx.recv_timeout(Duration::from_millis(50)).is_err(),
1126 "a foreign reader observed the temporary config redirect"
1127 );
1128 drop(redirect);
1129 drop(lock);
1130 reader
1131 };
1132
1133 let resolved = rx
1134 .recv_timeout(Duration::from_secs(2))
1135 .expect("reader resumed after the redirect was restored")
1136 .expect("resolve config path");
1137 reader.join().expect("reader thread");
1138 assert_ne!(resolved, redirected);
1139 }
1140
1141 #[test]
1142 fn settings_save_waits_for_foreign_state_io_transaction() {
1143 let (holder_ready_tx, holder_ready_rx) = mpsc::channel();
1144 let (release_tx, release_rx) = mpsc::channel();
1145 let holder = std::thread::spawn(move || {
1146 with_test_state_io_lock(|| {
1147 holder_ready_tx.send(()).expect("signal state lock held");
1148 release_rx.recv().expect("release state lock");
1149 });
1150 });
1151 holder_ready_rx
1152 .recv_timeout(Duration::from_secs(2))
1153 .expect("holder acquired state I/O lock");
1154
1155 let (started_tx, started_rx) = mpsc::channel();
1156 let (saved_tx, saved_rx) = mpsc::channel();
1157 let writer = std::thread::spawn(move || {
1158 started_tx.send(()).expect("signal settings save start");
1159 saved_tx
1160 .send(crate::settings::Settings::default().save())
1161 .expect("send settings save result");
1162 });
1163 started_rx
1164 .recv_timeout(Duration::from_secs(2))
1165 .expect("writer reached settings save");
1166 assert!(
1167 saved_rx.recv_timeout(Duration::from_millis(50)).is_err(),
1168 "settings save did not wait for an in-flight state transaction"
1169 );
1170
1171 release_tx.send(()).expect("release holder");
1172 holder.join().expect("holder thread");
1173 saved_rx
1174 .recv_timeout(Duration::from_secs(2))
1175 .expect("settings save resumed")
1176 .expect("settings save succeeded");
1177 writer.join().expect("writer thread");
1178 }
1179 }
1180
1180 lines RUST