| 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 |