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