返回 CodeWhale
startup_defaults.rs
根目录 / crates / tui / src / tui / startup_defaults.rs
1 //! The single owner for startup defaults written back by interactive TUI
2 //! selectors.
3 //!
4 //! Before this module there were three unrelated writers for the same
5 //! `settings.toml` keys: the model picker's combined model+effort apply, the
6 //! effort-only picker apply, and — for `default_mode` — nothing at all, so
7 //! cycling into Operate reverted to Act on the next launch while the preset
8 //! and `/config` surfaces persisted correctly. Routing every interactive
9 //! selector through [`StartupDefaults`] keeps one load/normalize/save
10 //! transaction per user action and one place where the write can be audited.
11 //!
12 //! Every write goes through one per-`App` owner, [`StartupDefaultsWriter`],
13 //! which is what makes the two application shapes safe to mix:
14 //!
15 //! - [`StartupDefaultsWriter::apply_blocking`] is synchronous. The model picker
16 //! uses it because it must know whether the write landed before it records a
17 //! provider/model setup receipt.
18 //! - [`StartupDefaultsWriter::spawn`] is non-blocking. Mode cycling and
19 //! reasoning cycling are keystroke-rate actions, so they must never gate a
20 //! redraw on disk I/O. Failures are still truthful: they land in a
21 //! [`StartupDefaultFailures`] mailbox that the event loop drains into a
22 //! warning toast, rather than being swallowed.
23 //!
24 //! ## Why the ordering is not left to the scheduler
25 //!
26 //! Each write is a load / modify / save transaction against one
27 //! `settings.toml`. Handing each keystroke its own blocking task lets two of
28 //! those transactions interleave — both load the same bytes, and the one that
29 //! saves last wins regardless of which selection the user made last. That loses
30 //! the newer selection for the same field, and it can also resurrect a stale
31 //! value for a *different* field, because each save writes the whole file.
32 //!
33 //! So ordering comes from the enqueue, never from task scheduling:
34 //!
35 //! 1. Every producer is the TUI event loop thread, which processes user actions
36 //! one at a time. `spawn` pushes onto a FIFO queue *synchronously* on that
37 //! thread, so queue order is exactly user-action order.
38 //! 2. A single `write` mutex is held across a whole drain — pop, load, modify,
39 //! save, repeat — so no two transactions ever overlap, no matter how many
40 //! blocking tasks are in flight. Extra tasks simply find the queue empty.
41 //! 3. `apply_blocking` takes the same mutex and first drains everything queued
42 //! ahead of it, then applies its own update. It cannot be overtaken by a
43 //! later action, because a later action can only be enqueued by the event
44 //! loop thread that is currently blocked inside this call.
45 //!
46 //! Together: last user action wins for a field, and because each transaction
47 //! only sets the fields its own [`StartupDefaults`] carries, disjoint fields
48 //! never clobber each other.
49 //!
50 //! ## Why this writer is not the whole story
51 //!
52 //! The `write` mutex above only serializes transactions *this writer* owns, and
53 //! `settings.toml` has other writers in the same process — most sharply, the
54 //! Shift+Tab permission-posture write on the same event loop. Two writers that
55 //! each do their own load / modify / save can still lose a field to each other,
56 //! and locking `save` would not help because the stale read already happened.
57 //!
58 //! So the atomicity of a single load/modify/save belongs one level down, in
59 //! [`Settings::transact`], which holds a per-settings-path process mutex *and*
60 //! a cross-process advisory lock on an adjacent `settings.toml.lock` across the
61 //! whole cycle — the second one because a user can easily have two Codewhale
62 //! processes open on the same home directory. Every reachable settings writer
63 //! goes through it. What stays here is the part `transact` cannot provide:
64 //! **ordering**. A lock makes concurrent transactions safe but says nothing
65 //! about which one runs first, and for keystroke-rate actions "last user action
66 //! wins" is the behavior users can actually perceive.
67 //!
68 //! ## The no-deadlock contract
69 //!
70 //! `write` is held across disk I/O, so it is the *outermost* lock of every
71 //! transaction. `queue` is only ever taken for a single push or pop and never
72 //! across I/O, so it can never be the lock someone waits behind. That gives one
73 //! rule, and it is the rule this module has to keep true:
74 //!
75 //! > **No thread may block on `write` while holding a lock that a settings
76 //! > transaction needs.** Doing so parks the drainer (which holds `write` and
77 //! > wants that lock) against the waiter (which holds that lock and wants
78 //! > `write`).
79 //!
80 //! In production nothing violates it: a settings transaction takes only its own
81 //! two locks (the settings process mutex and the settings file lock), and
82 //! nothing that holds either ever asks for `write`. Under `cfg(test)` a third
83 //! lock joins the order — settings path
84 //! resolution goes through `test_support::with_test_env_lock`, and a
85 //! sealed-`HOME` test holds that lock for its entire body. So a test thread
86 //! calling [`StartupDefaultsWriter::flush`] or
87 //! [`StartupDefaultsWriter::apply_blocking`] would wait on a background drainer
88 //! that is itself parked on the test's own env lock. That inversion is the
89 //! deadlock this module was first shipped with.
90 //!
91 //! [`StartupDefaultsWriter::spawn`] closes it at the source, in two parts:
92 //!
93 //! 1. The background drain is enrolled in the *spawning test's* env scope
94 //! (`test_support::join_env_scope`), so the drain never blocks on a lock its
95 //! own test holds. The env barrier still applies to genuinely foreign
96 //! readers; it just stops treating this test's own writer thread as one of
97 //! them.
98 //! 2. Permission to write at all is keyed to a specific env-scope generation
99 //! (see `spawn_writes_permitted`), not to a process-global flag. A test
100 //! that is not inside an authorized scope enqueues nothing and spawns
101 //! nothing, so it can never become a thread that holds `write` while parked
102 //! on a *foreign* test's env lock. Outstanding-drain accounting is keyed the
103 //! same way, so a closing gate only ever waits for the drains it authorized.
104 //!
105 //! `lock_write` and `Settings::transact` additionally carry test-only deadlines,
106 //! so if the rule is ever broken again the offending test fails with a
107 //! diagnostic instead of hanging CI.
108 //!
109 //! What this module does *not* own: the effective per-turn policy. Session
110 //! restore and preset application call `App::set_mode` directly, which changes
111 //! the live session only. Only a user-facing selection writes a startup
112 //! default.
113
114 use std::collections::VecDeque;
115 use std::sync::{Arc, Mutex, MutexGuard};
116
117 use crate::settings::Settings;
118 use crate::tui::app::AppMode;
119
120 /// One user selection's worth of startup-default writes.
121 ///
122 /// Fields left `None` are untouched on disk, so a thinking change never
123 /// rewrites the persisted model and vice-versa.
124 #[derive(Debug, Clone, Default, PartialEq, Eq)]
125 pub struct StartupDefaults {
126 /// `settings.default_mode` — the mode a fresh session starts in.
127 mode: Option<&'static str>,
128 /// `settings.reasoning_effort` — normalized for the active route by the
129 /// caller, because only the caller knows the route.
130 reasoning_effort: Option<String>,
131 /// Global `settings.default_model`.
132 default_model: Option<String>,
133 }
134
135 impl StartupDefaults {
136 /// Persist `mode` as the startup default.
137 ///
138 /// `AppMode::as_setting` already collapses the legacy `Yolo` alias to
139 /// `agent`, which is the mode `App::set_mode` actually installs — so the
140 /// persisted value matches the live session rather than a label the user
141 /// never lands in.
142 #[must_use]
143 pub fn mode(mode: AppMode) -> Self {
144 Self {
145 mode: Some(mode.as_setting()),
146 ..Self::default()
147 }
148 }
149
150 /// Persist a route-normalized reasoning-effort setting.
151 #[must_use]
152 pub fn reasoning_effort(setting: impl Into<String>) -> Self {
153 Self {
154 reasoning_effort: Some(setting.into()),
155 ..Self::default()
156 }
157 }
158
159 #[cfg(test)]
160 #[must_use]
161 pub fn with_default_model(mut self, model: &str) -> Self {
162 self.default_model = Some(model.to_string());
163 self
164 }
165
166 #[cfg(test)]
167 #[must_use]
168 pub fn with_reasoning_effort(mut self, effort: &str) -> Self {
169 self.reasoning_effort = Some(effort.to_string());
170 self
171 }
172
173 #[must_use]
174 pub fn is_empty(&self) -> bool {
175 self.mode.is_none() && self.reasoning_effort.is_none() && self.default_model.is_none()
176 }
177
178 /// Which user-facing settings this update touches, as typed subjects.
179 ///
180 /// Deliberately *not* an English string. This module runs on a blocking
181 /// pool with no access to the user's locale, and a failure it prebuilt in
182 /// English would be untranslatable by the time `App` shows it. Callers get
183 /// the enum and translate at the locale boundary (see
184 /// `App::drain_startup_default_failures`).
185 #[must_use]
186 fn subjects(&self) -> Vec<StartupDefaultSubject> {
187 let mut subjects = Vec::new();
188 if self.mode.is_some() {
189 subjects.push(StartupDefaultSubject::Mode);
190 }
191 if self.reasoning_effort.is_some() {
192 subjects.push(StartupDefaultSubject::Thinking);
193 }
194 if self.default_model.is_some() {
195 subjects.push(StartupDefaultSubject::Model);
196 }
197 subjects
198 }
199
200 /// Load, update, and save `settings.toml` in one transaction.
201 ///
202 /// Private on purpose: callers go through [`StartupDefaultsWriter`], which
203 /// is what serializes these transactions against each other. Calling this
204 /// directly would reintroduce the interleaving described at the top of the
205 /// module.
206 ///
207 /// Every key goes through `Settings::set`, so the same normalization and
208 /// validation the `/config` surface uses applies here too.
209 fn apply(&self) -> anyhow::Result<()> {
210 if self.is_empty() {
211 return Ok(());
212 }
213 Settings::transact(|settings| {
214 if let Some(mode) = self.mode {
215 settings.set("default_mode", mode)?;
216 }
217 if let Some(model) = self.default_model.as_deref() {
218 settings.set("default_model", model)?;
219 }
220 if let Some(effort) = self.reasoning_effort.as_deref() {
221 settings.set("reasoning_effort", effort)?;
222 }
223 Ok(())
224 })
225 }
226
227 fn apply_reporting(&self, failures: &StartupDefaultFailures) {
228 if let Err(err) = self.apply() {
229 let subjects = self.subjects();
230 // The log line may carry the full chain — it goes to the user's own
231 // log file, not to the screen.
232 tracing::warn!(
233 target: "settings",
234 subjects = ?subjects,
235 error = ?err,
236 "startup default was not persisted"
237 );
238 failures.record(StartupDefaultFailure {
239 subjects,
240 detail: safe_error_detail(&err),
241 });
242 }
243 }
244 }
245
246 /// What a failed startup-default write was trying to save.
247 ///
248 /// `App` maps each variant to a `MessageId`, so the same failure reads in the
249 /// user's language rather than in whatever language the writer thread happened
250 /// to be compiled with.
251 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
252 pub enum StartupDefaultSubject {
253 /// `settings.default_mode`.
254 Mode,
255 /// `settings.reasoning_effort`.
256 Thinking,
257 /// `settings.default_model` / the provider-scoped model map.
258 Model,
259 }
260
261 /// One startup-default write that did not land.
262 ///
263 /// Typed subjects plus a **short, path-free** error detail. Nothing here is UI
264 /// copy: the sentence around it is assembled by `App` from a `MessageId`.
265 #[derive(Debug, Clone, PartialEq, Eq)]
266 pub struct StartupDefaultFailure {
267 /// Empty only for the unreachable "empty update failed" case.
268 pub subjects: Vec<StartupDefaultSubject>,
269 /// Cause, safe to render: the root cause's own message with any path-like
270 /// token replaced. See [`safe_error_detail`].
271 pub detail: String,
272 }
273
274 /// Reduce an error chain to one short line that is safe to put on screen.
275 ///
276 /// Two rules, both load-bearing:
277 ///
278 /// 1. Only the **root cause** is used. Our own `with_context` strings are the
279 /// ones that interpolate `settings.toml`'s absolute path; the underlying
280 /// `io::Error` ("Permission denied (os error 13)") carries the part the user
281 /// actually needs.
282 /// 2. Anything that still looks like a path is replaced. A home directory can
283 /// contain a real name, and a status toast is the one place in the TUI that
284 /// ends up in screenshots and bug reports. Truncation keeps a pathological
285 /// error from taking over the footer.
286 fn safe_error_detail(err: &anyhow::Error) -> String {
287 const MAX: usize = 160;
288 let raw = err.root_cause().to_string();
289 let scrubbed = raw
290 .split_whitespace()
291 .map(|token| {
292 let looks_like_path = token.contains('/')
293 || token.contains('\\')
294 || (token.len() > 2 && token.as_bytes()[1] == b':');
295 if looks_like_path { "<path>" } else { token }
296 })
297 .collect::<Vec<_>>()
298 .join(" ");
299 if scrubbed.chars().count() > MAX {
300 let truncated: String = scrubbed.chars().take(MAX).collect();
301 format!("{truncated}…")
302 } else {
303 scrubbed
304 }
305 }
306
307 /// The single serialized owner of startup-default writes for one `App`.
308 ///
309 /// Cheap to clone; every clone shares the same queue, write mutex, and failure
310 /// mailbox. See the module docs for why ordering comes from the enqueue rather
311 /// than from task scheduling.
312 #[derive(Debug, Clone, Default)]
313 pub struct StartupDefaultsWriter {
314 inner: Arc<WriterInner>,
315 }
316
317 #[derive(Debug, Default)]
318 struct WriterInner {
319 /// Pending updates in user-action order. Only ever popped while `write` is
320 /// held, so a drain is a strict prefix of this queue.
321 queue: Mutex<VecDeque<StartupDefaults>>,
322 /// Held across an entire load / modify / save, so transactions never
323 /// interleave.
324 write: Mutex<()>,
325 failures: StartupDefaultFailures,
326 }
327
328 impl StartupDefaultsWriter {
329 /// Queue `update` and persist it off the event loop.
330 ///
331 /// Returns immediately: the queue push is the only work done on the calling
332 /// thread, so keystroke-rate actions never wait on disk. When no tokio
333 /// runtime is running (unit tests, and the non-async construction paths
334 /// that mirror `file_picker`'s scan fallback) the drain happens inline so
335 /// behavior stays observable and deterministic.
336 pub fn spawn(&self, update: StartupDefaults) {
337 if update.is_empty() {
338 return;
339 }
340 // Checked *before* the enqueue: an unauthorized test must leave no trace
341 // at all, so a later authorized drain cannot inherit its work and so it
342 // never takes out a claim another test would have to wait on. Always
343 // true in production.
344 if !spawn_writes_permitted() {
345 return;
346 }
347 if tokio::runtime::Handle::try_current().is_err() {
348 // No runtime (unit tests, and the non-async construction paths that
349 // mirror `file_picker`'s scan fallback): drain inline so behavior
350 // stays observable and deterministic.
351 self.lock_queue().push_back(update);
352 self.drain_pending();
353 return;
354 }
355 // Captured on the *calling* thread, which under `cfg(test)` is the
356 // sealed-`HOME` test thread. It carries that test's env scope into the
357 // blocking pool and keeps that scope's write gate open until the drain is
358 // finished. See the module docs for why both matter.
359 #[cfg(test)]
360 let ticket = match TestDrainTicket::capture() {
361 Some(ticket) => ticket,
362 None => {
363 // Authorized to write, but not the env scope's *owner*, so this
364 // thread cannot hand the scope to a worker. Handing the write to
365 // an unenrolled background thread would park it on the sealing
366 // test's env lock; this thread is already inside the sealed
367 // environment, so write here instead of skipping.
368 self.lock_queue().push_back(update);
369 self.drain_pending();
370 return;
371 }
372 };
373 self.lock_queue().push_back(update);
374 let writer = self.clone();
375 crate::utils::spawn_blocking_supervised("startup-defaults-persist", move || {
376 #[cfg(test)]
377 let _scope = ticket.enter();
378 writer.drain_pending();
379 });
380 }
381
382 /// Persist `update` on the calling thread and report whether it landed.
383 ///
384 /// Used by the model and effort pickers, which record a setup receipt whose
385 /// honesty depends on knowing the write succeeded. Anything the user did
386 /// *before* this call is already queued and is applied first, under the
387 /// same lock, so this cannot silently overwrite a newer selection.
388 pub fn apply_blocking(&self, update: StartupDefaults) -> anyhow::Result<()> {
389 let _write = self.lock_write();
390 self.drain_locked();
391 if update.is_empty() {
392 return Ok(());
393 }
394 update.apply()
395 }
396
397 /// Block until the queue is empty and no transaction is in flight.
398 ///
399 /// Available in production, not only in tests: at shutdown the last thing
400 /// the user did may still be sitting in the queue, and the process is about
401 /// to exit. Taking the write lock and draining here is a join — a
402 /// background task that already holds the lock finishes first, and anything
403 /// still queued is applied on this thread.
404 ///
405 /// Never call this from a thread that holds a settings transaction; see the
406 /// no-deadlock contract in the module docs.
407 pub fn flush(&self) {
408 let _write = self.lock_write();
409 self.drain_locked();
410 }
411
412 /// Flush at process shutdown and hand back everything that failed.
413 ///
414 /// Returns the failures instead of toasting them because the caller is past
415 /// the last redraw: the TUI's toast surface will never be painted again, so
416 /// the only honest way to report is on the restored terminal after the
417 /// alternate screen is gone.
418 #[must_use]
419 pub fn shutdown(&self) -> Vec<StartupDefaultFailure> {
420 self.flush();
421 self.drain_failures()
422 }
423
424 /// How many updates are queued but not yet applied. Tests use it to prove
425 /// an unauthorized caller enqueued *nothing*, rather than enqueuing work a
426 /// later drain could inherit.
427 #[cfg(test)]
428 pub(crate) fn pending_len(&self) -> usize {
429 self.lock_queue().len()
430 }
431
432 /// Drain any pending failures for display.
433 pub fn drain_failures(&self) -> Vec<StartupDefaultFailure> {
434 self.inner.failures.drain()
435 }
436
437 fn drain_pending(&self) {
438 let _write = self.lock_write();
439 self.drain_locked();
440 }
441
442 /// Apply every queued update in FIFO order. Caller holds the write lock.
443 fn drain_locked(&self) {
444 loop {
445 let Some(update) = self.lock_queue().pop_front() else {
446 return;
447 };
448 // Re-check the test gate at apply time, not just at enqueue time.
449 // `TestWriteGuard` now waits for outstanding drains, so a straggler
450 // from a *gated* test can no longer reach here after its `HOME`
451 // guard is gone. This stays as the backstop for the untracked
452 // paths — an inline drain reached from a later, ungated test, or a
453 // queue entry that survived a panicking transaction.
454 if !spawn_writes_permitted() {
455 continue;
456 }
457 update.apply_reporting(&self.inner.failures);
458 }
459 }
460
461 /// Take the transaction lock.
462 ///
463 /// A panic inside one transaction must not wedge persistence for the rest
464 /// of the session; the mutex protects ordering, not invariants, so a
465 /// poisoned guard is recovered rather than propagated.
466 #[cfg(not(test))]
467 fn lock_write(&self) -> MutexGuard<'_, ()> {
468 self.inner
469 .write
470 .lock()
471 .unwrap_or_else(std::sync::PoisonError::into_inner)
472 }
473
474 /// Test build of `lock_write`, with a watchdog.
475 ///
476 /// Production blocks indefinitely, which is correct: the only thing ahead
477 /// of it is a bounded settings transaction. In a test binary an indefinite
478 /// wait is indistinguishable from the lock-order inversion described in the
479 /// module docs, and a hung test job reports nothing. Polling with a deadline
480 /// is not a synchronization device — every acquisition below is expected to
481 /// succeed on the first `try_lock` or shortly after — it exists purely so a
482 /// regression fails loudly.
483 #[cfg(test)]
484 fn lock_write(&self) -> MutexGuard<'_, ()> {
485 use std::sync::TryLockError;
486
487 let deadline = std::time::Instant::now() + WRITE_LOCK_TEST_DEADLINE;
488 loop {
489 match self.inner.write.try_lock() {
490 Ok(guard) => return guard,
491 Err(TryLockError::Poisoned(poisoned)) => return poisoned.into_inner(),
492 Err(TryLockError::WouldBlock) => {}
493 }
494 assert!(
495 std::time::Instant::now() < deadline,
496 "startup-defaults write lock was not released within {WRITE_LOCK_TEST_DEADLINE:?}. \
497 Some thread is holding it across a settings transaction that cannot finish — \
498 usually because it is blocked on a lock this test already holds (see the \
499 no-deadlock contract in tui::startup_defaults)."
500 );
501 std::thread::sleep(std::time::Duration::from_millis(1));
502 }
503 }
504
505 fn lock_queue(&self) -> MutexGuard<'_, VecDeque<StartupDefaults>> {
506 self.inner
507 .queue
508 .lock()
509 .unwrap_or_else(std::sync::PoisonError::into_inner)
510 }
511 }
512
513 /// Whether the fire-and-forget [`StartupDefaultsWriter::spawn`] path may touch
514 /// disk *on the calling thread's behalf*.
515 ///
516 /// Mode and thinking cycling happen inside a great many `App` unit tests that do
517 /// not seal `HOME`. Those tests predate this write and must not start rewriting
518 /// the developer's real `~/.codewhale/settings.toml`, so under `cfg(test)` the
519 /// background write is inert unless the caller is inside a sealed env scope that
520 /// opted in with `allow_writes_in_tests`.
521 ///
522 /// **This is deliberately not a process-global flag.** A global bool is true for
523 /// as long as *any* test has opted in, so an unrelated test running in parallel
524 /// would pass the gate, resolve no env scope of its own, and then block on the
525 /// sealed test's env lock inside path resolution — while the sealed test's guard
526 /// waited for that very drain to finish. Authorization is therefore keyed to the
527 /// concrete env-scope generation the write belongs to, and only a thread that is
528 /// actually inside that scope (its owner, or a worker it adopted) is permitted.
529 ///
530 /// In production this is unconditionally true: a real write is never skipped.
531 ///
532 /// The synchronous [`StartupDefaultsWriter::apply_blocking`] path is not gated:
533 /// its callers (the model/effort pickers) seal `HOME` themselves and need to
534 /// know whether the write landed.
535 fn spawn_writes_permitted() -> bool {
536 #[cfg(test)]
537 {
538 authorized_test_write_generation().is_some()
539 }
540 #[cfg(not(test))]
541 {
542 true
543 }
544 }
545
546 /// The env-scope generation the calling thread is authorized to write for, if
547 /// any: it must be inside a live env scope *and* that scope must be the one a
548 /// live [`TestWriteGuard`] opened.
549 #[cfg(test)]
550 fn authorized_test_write_generation() -> Option<u64> {
551 let generation = crate::test_support::current_env_scope_generation()?;
552 let scopes = lock_test_write_scopes();
553 scopes
554 .authorized
555 .contains(&generation)
556 .then_some(generation)
557 }
558
559 /// How long a test may wait for the transaction lock, or for outstanding
560 /// background drains, before it is treated as wedged. Every real wait here is
561 /// sub-millisecond; this only has to be shorter than a CI job timeout and
562 /// longer than any honest settings write.
563 #[cfg(test)]
564 const WRITE_LOCK_TEST_DEADLINE: std::time::Duration = std::time::Duration::from_secs(15);
565
566 /// Which env-scope generations may write, and how many background drains each
567 /// one still has outstanding.
568 ///
569 /// Keyed by generation so a guard only ever waits for the drains *it* authorized.
570 /// Sharing one count across all tests is what let a sealed test's `drop` block
571 /// on a foreign test's queued work.
572 #[cfg(test)]
573 #[derive(Default)]
574 struct TestWriteScopes {
575 authorized: Vec<u64>,
576 outstanding: Vec<(u64, usize)>,
577 }
578
579 #[cfg(test)]
580 impl TestWriteScopes {
581 fn outstanding_for(&self, generation: u64) -> usize {
582 self.outstanding
583 .iter()
584 .find(|(scope, _)| *scope == generation)
585 .map_or(0, |(_, count)| *count)
586 }
587
588 fn adjust(&mut self, generation: u64, delta: isize) {
589 if let Some(entry) = self
590 .outstanding
591 .iter_mut()
592 .find(|(scope, _)| *scope == generation)
593 {
594 entry.1 = entry.1.saturating_add_signed(delta);
595 if entry.1 == 0 {
596 self.outstanding.retain(|(scope, _)| *scope != generation);
597 }
598 } else if delta > 0 {
599 self.outstanding.push((generation, delta as usize));
600 }
601 }
602 }
603
604 #[cfg(test)]
605 fn test_write_scopes() -> &'static (Mutex<TestWriteScopes>, std::sync::Condvar) {
606 static SCOPES: std::sync::OnceLock<(Mutex<TestWriteScopes>, std::sync::Condvar)> =
607 std::sync::OnceLock::new();
608 SCOPES.get_or_init(|| {
609 (
610 Mutex::new(TestWriteScopes::default()),
611 std::sync::Condvar::new(),
612 )
613 })
614 }
615
616 #[cfg(test)]
617 fn lock_test_write_scopes() -> MutexGuard<'static, TestWriteScopes> {
618 test_write_scopes()
619 .0
620 .lock()
621 .unwrap_or_else(std::sync::PoisonError::into_inner)
622 }
623
624 /// Opt the calling test's sealed env scope into real background startup-default
625 /// writes.
626 ///
627 /// Callers must already hold `test_support::lock_test_env()` and have sealed
628 /// `HOME`. Panics otherwise, because an ungated opt-in would authorize writes
629 /// into the developer's real settings file.
630 #[cfg(test)]
631 pub(crate) fn allow_writes_in_tests() -> TestWriteGuard {
632 let generation = crate::test_support::current_env_scope_generation().expect(
633 "allow_writes_in_tests() requires the calling thread to hold \
634 test_support::lock_test_env() with a sealed HOME",
635 );
636 let mut scopes = lock_test_write_scopes();
637 if !scopes.authorized.contains(&generation) {
638 scopes.authorized.push(generation);
639 }
640 drop(scopes);
641 TestWriteGuard { generation }
642 }
643
644 #[cfg(test)]
645 pub(crate) struct TestWriteGuard {
646 generation: u64,
647 }
648
649 #[cfg(test)]
650 impl Drop for TestWriteGuard {
651 /// Close this scope's gate only once every drain *this scope* handed to the
652 /// blocking pool has finished.
653 ///
654 /// The drain re-checks authorization per item, which stops a straggler from
655 /// writing after the gate closes — but it does so by *discarding* the item,
656 /// which is a silent hole in whatever the test just asserted, and it does not
657 /// stop a straggler that is already mid-write. Waiting here makes the gate's
658 /// lifetime cover the writes it authorized.
659 ///
660 /// It cannot deadlock on a foreign test: the wait is scoped to this
661 /// generation, and every drain counted under this generation is enrolled in
662 /// this test's env scope, so none of them can be parked on a lock this
663 /// thread holds.
664 fn drop(&mut self) {
665 let drained = wait_for_outstanding_test_drains(self.generation);
666 let mut scopes = lock_test_write_scopes();
667 scopes.authorized.retain(|scope| *scope != self.generation);
668 drop(scopes);
669 // Report the timeout as a failure, but never as a second panic during
670 // an unwind — that aborts the whole test binary and hides the real
671 // assertion that started it.
672 assert!(
673 drained || std::thread::panicking(),
674 "background startup-default drain(s) for env scope {} did not finish within \
675 {WRITE_LOCK_TEST_DEADLINE:?}; see the no-deadlock contract in \
676 tui::startup_defaults",
677 self.generation
678 );
679 }
680 }
681
682 /// Wait for every drain authorized by `generation`, returning `false` if the
683 /// deadline expired first. Never panics: the caller decides how to report a
684 /// timeout.
685 #[cfg(test)]
686 fn wait_for_outstanding_test_drains(generation: u64) -> bool {
687 let (scopes, done) = test_write_scopes();
688 let mut guard = scopes
689 .lock()
690 .unwrap_or_else(std::sync::PoisonError::into_inner);
691 let deadline = std::time::Instant::now() + WRITE_LOCK_TEST_DEADLINE;
692 while guard.outstanding_for(generation) > 0 {
693 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
694 if remaining.is_zero() {
695 return false;
696 }
697 let (next, _timeout) = done
698 .wait_timeout(guard, remaining)
699 .unwrap_or_else(std::sync::PoisonError::into_inner);
700 guard = next;
701 }
702 true
703 }
704
705 /// One background drain's claim on the enclosing test: its env scope, and its
706 /// slot in that scope's outstanding-drain count.
707 ///
708 /// Minted only when the spawning thread is authorized, so an unauthorized test
709 /// never enqueues work and never takes out a claim it would then have to be
710 /// waited on for. Captured on the spawning thread and moved into the blocking
711 /// task, so the count is decremented whether the task ran or the runtime shut
712 /// down and dropped it un-run.
713 #[cfg(test)]
714 struct TestDrainTicket {
715 env: crate::test_support::EnvScopeTicket,
716 }
717
718 #[cfg(test)]
719 impl TestDrainTicket {
720 /// `None` when the caller is not the owner of an authorized env scope — the
721 /// caller must then not enqueue anything.
722 fn capture() -> Option<Self> {
723 let generation = authorized_test_write_generation()?;
724 let env = crate::test_support::env_scope_ticket()?;
725 // Only the scope owner can hand its scope to a worker, and the ticket
726 // must describe the same generation we just authorized.
727 if env.generation() != generation {
728 return None;
729 }
730 lock_test_write_scopes().adjust(generation, 1);
731 Some(Self { env })
732 }
733
734 fn enter(&self) -> Option<crate::test_support::EnvScopeMembership> {
735 crate::test_support::join_env_scope(Some(self.env))
736 }
737 }
738
739 #[cfg(test)]
740 impl Drop for TestDrainTicket {
741 fn drop(&mut self) {
742 let (scopes, done) = test_write_scopes();
743 let mut guard = scopes
744 .lock()
745 .unwrap_or_else(std::sync::PoisonError::into_inner);
746 guard.adjust(self.env.generation(), -1);
747 drop(guard);
748 done.notify_all();
749 }
750 }
751
752 /// Mailbox for non-blocking startup-default write failures.
753 ///
754 /// The event loop drains this every iteration so a failed write becomes a
755 /// visible warning instead of a silent revert on the next launch, and shutdown
756 /// drains it one last time so a write that failed after the final redraw is
757 /// still reported.
758 #[derive(Debug, Clone, Default)]
759 pub struct StartupDefaultFailures(Arc<Mutex<Vec<StartupDefaultFailure>>>);
760
761 impl StartupDefaultFailures {
762 fn record(&self, failure: StartupDefaultFailure) {
763 // A poisoned mailbox must not take down the writer thread; the write
764 // itself already happened (or failed) and was logged.
765 if let Ok(mut guard) = self.0.lock() {
766 guard.push(failure);
767 }
768 }
769
770 /// Take every pending failure, leaving the mailbox empty.
771 pub fn drain(&self) -> Vec<StartupDefaultFailure> {
772 self.0
773 .lock()
774 .map(|mut guard| std::mem::take(&mut *guard))
775 .unwrap_or_default()
776 }
777 }
778
779 #[cfg(test)]
780 mod tests {
781 use super::*;
782
783 #[test]
784 fn mode_update_only_targets_default_mode() {
785 let update = StartupDefaults::mode(AppMode::Operate);
786 assert_eq!(update.mode, Some("operate"));
787 assert!(update.reasoning_effort.is_none());
788 assert!(update.default_model.is_none());
789 assert_eq!(update.subjects(), vec![StartupDefaultSubject::Mode]);
790 }
791
792 #[test]
793 fn subjects_stay_typed_for_a_combined_model_and_thinking_update() {
794 let update = StartupDefaults::default()
795 .with_default_model("deepseek-chat")
796 .with_reasoning_effort("high");
797 assert_eq!(
798 update.subjects(),
799 vec![
800 StartupDefaultSubject::Thinking,
801 StartupDefaultSubject::Model
802 ]
803 );
804 }
805
806 /// A failure toast is one of the few strings that reliably ends up in a
807 /// screenshot, so the detail must not carry the settings path (which
808 /// contains the user's home directory, and often their real name).
809 #[test]
810 fn safe_error_detail_keeps_the_cause_and_drops_the_path() {
811 let err = anyhow::anyhow!("Permission denied (os error 13)")
812 .context("Failed to write settings to /Users/real-name/.codewhale/settings.toml");
813 let detail = safe_error_detail(&err);
814 assert_eq!(detail, "Permission denied (os error 13)");
815 assert!(!detail.contains("real-name"));
816 assert!(!detail.contains(".codewhale"));
817
818 // Even when the root cause itself names a path, nothing path-shaped
819 // survives.
820 let rooted = anyhow::anyhow!("cannot open /Users/real-name/.codewhale/settings.toml");
821 let scrubbed = safe_error_detail(&rooted);
822 assert_eq!(scrubbed, "cannot open <path>");
823 }
824
825 #[test]
826 fn legacy_yolo_selection_persists_the_mode_it_actually_installs() {
827 assert_eq!(StartupDefaults::mode(AppMode::Yolo).mode, Some("agent"));
828 }
829
830 #[test]
831 fn empty_update_is_a_no_op() {
832 assert!(StartupDefaults::default().is_empty());
833 StartupDefaults::default()
834 .apply()
835 .expect("empty update must not touch disk");
836 }
837
838 #[test]
839 fn failure_mailbox_drains_once() {
840 let failures = StartupDefaultFailures::default();
841 let failure = StartupDefaultFailure {
842 subjects: vec![StartupDefaultSubject::Mode],
843 detail: "boom".to_string(),
844 };
845 failures.record(failure.clone());
846 assert_eq!(failures.drain(), vec![failure]);
847 assert!(failures.drain().is_empty());
848 }
849
850 /// The deadlock this module shipped with, reduced to its two threads.
851 ///
852 /// A worker takes the transaction lock and runs a settings transaction
853 /// while the test thread — which holds the process-wide env lock for its
854 /// whole body — waits for that worker. Before `spawn` enrolled its drain in
855 /// the test's env scope, the worker parked inside
856 /// `settings_path_candidates` holding `write`, the test thread parked on
857 /// `write`, and the test hung instead of failing.
858 ///
859 /// The channel is the barrier: a regression makes `recv_timeout` expire and
860 /// the test *fails*, with no thread left for CI to wait on.
861 #[test]
862 fn a_worker_enrolled_in_the_test_env_scope_completes_a_transaction() {
863 use std::sync::mpsc;
864 use std::time::Duration;
865
866 let _lock = crate::test_support::lock_test_env();
867 let tmp = tempfile::TempDir::new().expect("tempdir");
868 let _home = crate::test_support::EnvVarGuard::set("HOME", tmp.path());
869 let _user_profile = crate::test_support::EnvVarGuard::set("USERPROFILE", tmp.path());
870 let _codewhale_home =
871 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path().join(".codewhale"));
872 let _deepseek_config = crate::test_support::EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
873 let _codewhale_config = crate::test_support::EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
874 let _writes = allow_writes_in_tests();
875
876 let writer = StartupDefaultsWriter::default();
877 let ticket = crate::test_support::env_scope_ticket();
878 assert!(
879 ticket.is_some(),
880 "the thread holding lock_test_env must be able to mint a scope ticket"
881 );
882
883 let (done_tx, done_rx) = mpsc::channel();
884 let worker = writer.clone();
885 let handle = std::thread::spawn(move || {
886 let _membership = crate::test_support::join_env_scope(ticket);
887 let result = worker.apply_blocking(StartupDefaults::mode(AppMode::Operate));
888 done_tx.send(result).ok();
889 });
890
891 let result = done_rx
892 .recv_timeout(Duration::from_secs(10))
893 .expect("an enrolled worker must not block on the env lock its own test holds");
894 result.expect("the transaction must land");
895 handle.join().expect("worker thread");
896
897 // Reachable from the env-lock holder for the same reason.
898 writer.flush();
899
900 // Proof the worker resolved the *sealed* settings path rather than
901 // falling back to the isolated root a foreign reader would get.
902 assert_eq!(
903 Settings::load_persisted()
904 .expect("reload settings")
905 .default_mode,
906 "operate"
907 );
908 assert!(tmp.path().join(".codewhale/settings.toml").exists());
909 }
910
911 /// Seal `HOME`/`CODEWHALE_HOME` onto `tmp`. Caller must already hold
912 /// `lock_test_env()`.
913 fn seal_home(tmp: &std::path::Path) -> Vec<crate::test_support::EnvVarGuard> {
914 use crate::test_support::EnvVarGuard;
915 vec![
916 EnvVarGuard::set("HOME", tmp),
917 EnvVarGuard::set("USERPROFILE", tmp),
918 EnvVarGuard::set("CODEWHALE_HOME", tmp.join(".codewhale")),
919 EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH"),
920 EnvVarGuard::remove("CODEWHALE_CONFIG_PATH"),
921 ]
922 }
923
924 /// The second deadlock the scoped gate exists to prevent.
925 ///
926 /// A process-global "writes enabled" bool is true for as long as *any* test
927 /// has opted in. An unrelated test running in parallel — no env lock of its
928 /// own, so no sealed `HOME` — would therefore pass the gate, enqueue an
929 /// update, and hand it to a blocking thread that could mint no env-scope
930 /// ticket. That thread then parked inside `settings_path_candidates` waiting
931 /// for the env lock *this* test holds, while this test's `TestWriteGuard`
932 /// drop waited for that same drain to finish: a 15-second mutual wait ending
933 /// in a failure that pointed at the wrong test.
934 ///
935 /// Two things are asserted, both of which used to be false:
936 ///
937 /// 1. The unauthorized thread returns promptly instead of blocking. The
938 /// channel is the barrier — a regression expires `recv_timeout` and the
939 /// test *fails* rather than hanging CI.
940 /// 2. It leaves nothing behind: no queue entry (which a later authorized
941 /// drain would inherit and write), no outstanding-drain claim, and no
942 /// settings file — in particular not the developer's real one.
943 #[test]
944 fn an_unauthorized_thread_neither_writes_nor_waits_for_a_sealed_scope() {
945 use std::sync::mpsc;
946 use std::time::Duration;
947
948 let _lock = crate::test_support::lock_test_env();
949 let tmp = tempfile::TempDir::new().expect("tempdir");
950 let _env = seal_home(tmp.path());
951 let _writes = allow_writes_in_tests();
952
953 let writer = StartupDefaultsWriter::default();
954 let foreign = writer.clone();
955 let (done_tx, done_rx) = mpsc::channel();
956 let handle = std::thread::spawn(move || {
957 // Deliberately *not* enrolled: this is the shape of an unrelated
958 // `App` test calling `select_mode` while another test is sealed.
959 foreign.spawn(StartupDefaults::mode(AppMode::Plan));
960 done_tx.send(()).ok();
961 });
962 done_rx
963 .recv_timeout(Duration::from_secs(5))
964 .expect("an unauthorized writer must return immediately, never block on the env lock");
965 handle.join().expect("foreign thread");
966
967 assert_eq!(
968 writer.pending_len(),
969 0,
970 "an unauthorized caller must not enqueue work an authorized drain could inherit"
971 );
972 assert!(
973 !tmp.path().join(".codewhale/settings.toml").exists(),
974 "an unauthorized caller must not write any settings file"
975 );
976
977 // The sealed scope itself is unaffected: its own write still lands, and
978 // its guard has nothing foreign to wait for.
979 writer
980 .apply_blocking(StartupDefaults::mode(AppMode::Operate))
981 .expect("the sealed scope's own write must land");
982 assert_eq!(
983 Settings::load_persisted()
984 .expect("reload settings")
985 .default_mode,
986 "operate"
987 );
988 }
989
990 /// Two sealed scopes must not be able to authorize, or wait for, each
991 /// other's work.
992 ///
993 /// The env mutex means two sealed bodies never overlap, but their *drains*
994 /// can: a straggler handed to the blocking pool by scope N can still be
995 /// alive when scope N+1 opens. With one global flag and one global
996 /// outstanding count, scope N+1 inherited both — it could write under scope
997 /// N's authorization, and either guard could block on the other's work.
998 /// Authorization and drain accounting are keyed to the env-scope generation
999 /// so neither is possible.
1000 #[test]
1001 fn two_sealed_scopes_share_neither_write_authorization_nor_drain_accounting() {
1002 let first_generation;
1003 let stale_ticket;
1004
1005 {
1006 let _lock = crate::test_support::lock_test_env();
1007 let tmp = tempfile::TempDir::new().expect("tempdir");
1008 let _env = seal_home(tmp.path());
1009 let _writes = allow_writes_in_tests();
1010
1011 first_generation = crate::test_support::current_env_scope_generation()
1012 .expect("a sealed scope must have a generation");
1013 stale_ticket = crate::test_support::env_scope_ticket();
1014 assert_eq!(
1015 authorized_test_write_generation(),
1016 Some(first_generation),
1017 "the scope that opted in must be the one authorized"
1018 );
1019
1020 let writer = StartupDefaultsWriter::default();
1021 writer
1022 .apply_blocking(StartupDefaults::mode(AppMode::Plan))
1023 .expect("first scope's write must land");
1024 assert_eq!(
1025 Settings::load_persisted().expect("reload").default_mode,
1026 "plan"
1027 );
1028 assert_eq!(
1029 lock_test_write_scopes().outstanding_for(first_generation),
1030 0,
1031 "the first scope must have no outstanding drain left to wait on"
1032 );
1033 }
1034
1035 {
1036 let _lock = crate::test_support::lock_test_env();
1037 let tmp = tempfile::TempDir::new().expect("tempdir");
1038 let _env = seal_home(tmp.path());
1039
1040 let second_generation = crate::test_support::current_env_scope_generation()
1041 .expect("a sealed scope must have a generation");
1042 assert_ne!(
1043 second_generation, first_generation,
1044 "each acquisition must open a fresh generation"
1045 );
1046 assert_eq!(
1047 authorized_test_write_generation(),
1048 None,
1049 "a new scope must not inherit the previous scope's opt-in"
1050 );
1051 assert!(
1052 crate::test_support::join_env_scope(stale_ticket).is_none(),
1053 "a ticket from a closed scope must not enroll a thread in the current one"
1054 );
1055
1056 // Without its own opt-in, this scope's background path stays inert
1057 // and writes nothing — not even into its own sealed HOME.
1058 let writer = StartupDefaultsWriter::default();
1059 writer.spawn(StartupDefaults::mode(AppMode::Operate));
1060 assert_eq!(writer.pending_len(), 0);
1061 assert!(!tmp.path().join(".codewhale/settings.toml").exists());
1062
1063 // With its own opt-in it writes into *its* home, keyed to *its*
1064 // generation.
1065 let _writes = allow_writes_in_tests();
1066 assert_eq!(authorized_test_write_generation(), Some(second_generation));
1067 writer.spawn(StartupDefaults::mode(AppMode::Operate));
1068 writer.flush();
1069 assert_eq!(
1070 Settings::load_persisted().expect("reload").default_mode,
1071 "operate"
1072 );
1073 assert!(tmp.path().join(".codewhale/settings.toml").exists());
1074 }
1075 }
1076
1077 /// The transactional boundary is `Settings::transact`, not this writer's own
1078 /// mutex.
1079 ///
1080 /// A direct settings writer holds the transaction across its whole
1081 /// load / modify / save. A concurrent startup-default drain must not be able
1082 /// to slip a save in between — if it could, this test's `save` would write
1083 /// back a pre-image and silently revert the queued selection, which is
1084 /// exactly how `default_mode` was lost to a Shift+Tab posture write.
1085 #[test]
1086 fn a_direct_writer_holds_the_boundary_against_a_queued_startup_default() {
1087 let _lock = crate::test_support::lock_test_env();
1088 let tmp = tempfile::TempDir::new().expect("tempdir");
1089 let _env = seal_home(tmp.path());
1090 let _writes = allow_writes_in_tests();
1091
1092 let writer = StartupDefaultsWriter::default();
1093 let ticket = crate::test_support::env_scope_ticket();
1094
1095 let mut handle = None;
1096 crate::settings::with_settings_transaction(|transaction| {
1097 let mut direct = transaction.load().expect("load inside the transaction");
1098 // A field with a non-default value, so the final assertion cannot
1099 // pass by accident: `max_input_history` defaults to 100.
1100 direct
1101 .set("max_history", "321")
1102 .expect("set an unrelated field");
1103
1104 // A drain on another thread, enrolled so it resolves the sealed
1105 // path. It must block on the transaction above rather than
1106 // interleave.
1107 let queued = writer.clone();
1108 handle = Some(std::thread::spawn(move || {
1109 let _membership = crate::test_support::join_env_scope(ticket);
1110 queued
1111 .apply_blocking(StartupDefaults::mode(AppMode::Plan))
1112 .expect("the queued write must land once the boundary is released");
1113 }));
1114
1115 // Give the worker a real chance to reach (and be refused by) the
1116 // boundary. This is not the assertion's synchronization — the
1117 // assertion is that the value is *still absent*, which a sleep can
1118 // only make easier to violate.
1119 std::thread::sleep(std::time::Duration::from_millis(150));
1120 assert_eq!(
1121 transaction.load().expect("re-read").default_mode,
1122 Settings::default().default_mode,
1123 "no other writer may save while a transaction is open"
1124 );
1125
1126 transaction.save(&direct).expect("commit the direct write");
1127 Ok(())
1128 })
1129 .expect("the direct transaction must complete");
1130 handle
1131 .expect("worker spawned")
1132 .join()
1133 .expect("queued writer thread");
1134
1135 let settled = Settings::load_persisted().expect("reload");
1136 assert_eq!(
1137 settled.max_input_history, 321,
1138 "the direct write must survive the queued startup-default write"
1139 );
1140 assert_eq!(
1141 settled.default_mode, "plan",
1142 "the queued startup-default write must survive the direct write"
1143 );
1144 }
1145 }
1146
1146 lines RUST