返回 CodeWhale
test_support.rs
根目录 / crates / tui / src / test_support.rs
1 //! Shared test-only helpers.
2
3 use std::ffi::{OsStr, OsString};
4 use std::path::{Path, PathBuf};
5 use std::sync::{Mutex, OnceLock};
6 use std::time::{SystemTime, UNIX_EPOCH};
7
8 pub(crate) use crate::shell_dispatcher::test_env_lock::{
9 EnvScopeMembership, EnvScopeTicket, TestEnvLock, current_env_scope_generation,
10 current_thread_holds_test_env_lock, env_scope_ticket, join_env_scope, lock_test_env,
11 with_test_env_lock,
12 };
13
14 /// Process-wide state root for unit tests that do not intentionally provide an
15 /// explicit config/settings path.
16 ///
17 /// The production fallback is the user's real home. That is useful at runtime
18 /// and unsafe in a parallel test binary: an unguarded save can otherwise read
19 /// or overwrite the developer's config. Tests that exercise path precedence
20 /// still hold [`lock_test_env`] and provide explicit temporary environment
21 /// values; every other test is confined here — enforced by
22 /// [`guarded_environment_provides_state_paths`], not assumed.
23 pub(crate) fn isolated_test_state_root() -> &'static Path {
24 static ROOT: OnceLock<PathBuf> = OnceLock::new();
25 ROOT.get_or_init(|| {
26 let nonce = SystemTime::now()
27 .duration_since(UNIX_EPOCH)
28 .unwrap_or_default()
29 .as_nanos();
30 let root = std::env::temp_dir().join(format!(
31 "codewhale-tui-test-state-{}-{nonce}",
32 std::process::id()
33 ));
34 std::fs::create_dir_all(&root).unwrap_or_else(|error| {
35 panic!(
36 "failed to create isolated unit-test state root {}: {error}",
37 root.display()
38 )
39 });
40 root
41 })
42 }
43
44 /// Where the calling test's state should live when it has not sealed the
45 /// environment itself.
46 ///
47 /// Two different callers land here. A test that never took [`lock_test_env`]
48 /// gets the shared root, exactly as before — those tests already coexist there
49 /// under [`with_test_state_io_lock`]. A test that *holds* the lock but sealed
50 /// nothing gets a private directory instead: before #5359 it resolved the
51 /// developer's real home, so it has never shared the process root, and several
52 /// such tests run full settings transactions. Adding that traffic to the shared
53 /// root pushed the transaction lock past its deadline and hung unrelated
54 /// `config_command_*` tests. Keep them isolated from the developer *and* from
55 /// each other.
56 pub(crate) fn unsealed_test_state_root() -> PathBuf {
57 let shared = isolated_test_state_root();
58 if !current_thread_holds_test_env_lock() {
59 return shared.to_path_buf();
60 }
61 // libtest runs each test in a fresh thread. Keep one root for that thread:
62 // a settings save resolves its path more than once, while different tests
63 // must not inherit each other's files.
64 HOLDER_ROOT.with(|cached| {
65 cached
66 .get_or_init(|| {
67 let root = shared.join(format!("env-holder-{:?}", std::thread::current().id()));
68 std::fs::create_dir_all(&root).unwrap_or_else(|error| {
69 panic!(
70 "failed to create per-holder test state root {}: {error}",
71 root.display()
72 )
73 });
74 root
75 })
76 .clone()
77 })
78 }
79
80 thread_local! {
81 static HOLDER_ROOT: OnceLock<PathBuf> = const { OnceLock::new() };
82 }
83
84 /// Build a syntactically valid, non-secret JWT fixture without embedding a
85 /// high-entropy token-shaped literal in Git history.
86 pub(crate) fn future_test_jwt(label: &str) -> String {
87 use base64::Engine as _;
88
89 let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(br#"{"exp":9999999999}"#);
90 format!("test.{payload}.{label}")
91 }
92
93 fn state_io_lock() -> &'static Mutex<()> {
94 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
95 LOCK.get_or_init(|| Mutex::new(()))
96 }
97
98 /// Serialize read/merge/write operations against the process-wide isolated
99 /// test state root.
100 ///
101 /// Path isolation protects the developer's files, but parallel tests still
102 /// share the same temporary files. Settings persistence is a multi-step
103 /// operation, so it needs this second barrier around the complete I/O
104 /// transaction rather than only around path resolution.
105 pub(crate) fn with_test_state_io_lock<T>(operation: impl FnOnce() -> T) -> T {
106 let _guard = match state_io_lock().lock() {
107 Ok(guard) => guard,
108 Err(poisoned) => poisoned.into_inner(),
109 };
110 operation()
111 }
112
113 /// Restore one environment variable when dropped.
114 ///
115 /// Callers that mutate process-global environment variables must hold
116 /// [`lock_test_env`] until after this guard is dropped.
117 ///
118 /// Every live guard is also recorded in [`guarded_env_keys`], so path
119 /// resolution can distinguish a test that deliberately redirected `HOME`
120 /// from one that merely holds the lock to serialize unrelated env access —
121 /// see [`guarded_environment_provides_state_paths`].
122 pub(crate) struct EnvVarGuard {
123 key: &'static str,
124 previous: Option<OsString>,
125 }
126
127 fn guarded_env_keys() -> &'static Mutex<std::collections::HashMap<&'static str, usize>> {
128 static KEYS: OnceLock<Mutex<std::collections::HashMap<&'static str, usize>>> = OnceLock::new();
129 KEYS.get_or_init(|| Mutex::new(std::collections::HashMap::new()))
130 }
131
132 fn register_guarded_env_key(key: &'static str) {
133 let mut keys = match guarded_env_keys().lock() {
134 Ok(keys) => keys,
135 Err(poisoned) => poisoned.into_inner(),
136 };
137 *keys.entry(key).or_insert(0) += 1;
138 }
139
140 fn unregister_guarded_env_key(key: &'static str) {
141 let mut keys = match guarded_env_keys().lock() {
142 Ok(keys) => keys,
143 Err(poisoned) => poisoned.into_inner(),
144 };
145 if let Some(count) = keys.get_mut(key) {
146 *count -= 1;
147 if *count == 0 {
148 keys.remove(key);
149 }
150 }
151 }
152
153 /// Whether some live [`EnvVarGuard`] currently covers `key`.
154 pub(crate) fn env_var_currently_guarded(key: &str) -> bool {
155 match guarded_env_keys().lock() {
156 Ok(keys) => keys.contains_key(key),
157 Err(poisoned) => poisoned.into_inner().contains_key(key),
158 }
159 }
160
161 /// Whether the calling test actually provided the state-path environment it
162 /// is about to resolve.
163 ///
164 /// Holding [`lock_test_env`] alone is not that: many tests hold the lock only
165 /// to serialize access to unrelated variables (`TERM_PROGRAM`, API keys) and
166 /// have provided no temporary paths at all. Trusting the lock routed those
167 /// tests to the developer's real `~/.codewhale` state, which is exactly the
168 /// leak the isolated root exists to prevent (#5359). A test earns environment
169 /// resolution by holding the lock *and* either setting one of the explicit
170 /// override variables or redirecting `HOME`/`USERPROFILE` through
171 /// [`EnvVarGuard`].
172 pub(crate) fn guarded_environment_provides_state_paths() -> bool {
173 if !current_thread_holds_test_env_lock() {
174 return false;
175 }
176 let guarded_path_is_present = |var: &str| {
177 env_var_currently_guarded(var)
178 && std::env::var_os(var)
179 .is_some_and(|value| value.to_str().is_none_or(|text| !text.trim().is_empty()))
180 };
181 [
182 "CODEWHALE_HOME",
183 "CODEWHALE_CONFIG_PATH",
184 "DEEPSEEK_CONFIG_PATH",
185 "HOME",
186 "USERPROFILE",
187 ]
188 .iter()
189 .any(|var| guarded_path_is_present(var))
190 }
191
192 impl EnvVarGuard {
193 pub(crate) fn set(key: &'static str, value: impl AsRef<OsStr>) -> Self {
194 debug_assert!(
195 current_thread_holds_test_env_lock(),
196 "EnvVarGuard::set({key}) requires lock_test_env()"
197 );
198 let previous = std::env::var_os(key);
199 // SAFETY: callers hold the process-wide test env mutex.
200 unsafe { std::env::set_var(key, value) };
201 register_guarded_env_key(key);
202 Self { key, previous }
203 }
204
205 pub(crate) fn remove(key: &'static str) -> Self {
206 debug_assert!(
207 current_thread_holds_test_env_lock(),
208 "EnvVarGuard::remove({key}) requires lock_test_env()"
209 );
210 let previous = std::env::var_os(key);
211 // SAFETY: callers hold the process-wide test env mutex.
212 unsafe { std::env::remove_var(key) };
213 register_guarded_env_key(key);
214 Self { key, previous }
215 }
216
217 pub(crate) fn previous(&self) -> Option<OsString> {
218 self.previous.clone()
219 }
220 }
221
222 impl Drop for EnvVarGuard {
223 fn drop(&mut self) {
224 // SAFETY: callers hold the process-wide test env mutex until after this
225 // guard is dropped.
226 unsafe {
227 if let Some(value) = self.previous.take() {
228 std::env::set_var(self.key, value);
229 } else {
230 std::env::remove_var(self.key);
231 }
232 }
233 unregister_guarded_env_key(self.key);
234 }
235 }
236
237 /// Find the byte position of the first divergence between two strings,
238 /// returning a windowed view (`±32 bytes` around the divergence) so failures
239 /// in cache-prefix-stability tests show *which* bytes drifted, not just that
240 /// they did. Returns `None` when the strings are byte-identical.
241 pub(crate) fn first_divergence(a: &str, b: &str) -> Option<(usize, String, String)> {
242 let a_bytes = a.as_bytes();
243 let b_bytes = b.as_bytes();
244 let max = a_bytes.len().min(b_bytes.len());
245 for i in 0..max {
246 if a_bytes[i] != b_bytes[i] {
247 let lo = i.saturating_sub(32);
248 let a_hi = (i + 32).min(a_bytes.len());
249 let b_hi = (i + 32).min(b_bytes.len());
250 let a_ctx = String::from_utf8_lossy(&a_bytes[lo..a_hi]).into_owned();
251 let b_ctx = String::from_utf8_lossy(&b_bytes[lo..b_hi]).into_owned();
252 return Some((i, a_ctx, b_ctx));
253 }
254 }
255 if a_bytes.len() != b_bytes.len() {
256 return Some((
257 max,
258 format!("(len={})", a_bytes.len()),
259 format!("(len={})", b_bytes.len()),
260 ));
261 }
262 None
263 }
264
265 /// Assert two strings are byte-identical, panicking with a windowed diff
266 /// around the first divergence when they aren't. Used by the prefix-cache
267 /// stability harness (#263, #280) to pin construction surfaces that land in
268 /// DeepSeek's KV cache prefix.
269 #[track_caller]
270 pub(crate) fn assert_byte_identical(label: &str, a: &str, b: &str) {
271 if let Some((pos, a_ctx, b_ctx)) = first_divergence(a, b) {
272 panic!(
273 "{label}: prompt construction is non-deterministic — first diff at byte {pos}\n\
274 ── side A (±32B) ──\n{a_ctx:?}\n── side B (±32B) ──\n{b_ctx:?}",
275 );
276 }
277 }
278
279 // ── Shared App/TuiOptions fixtures (#3923) ──────────────────────────────
280 //
281 // Before this module owned them, `create_test_app` was copy-pasted across 28
282 // test modules, each spelling out the full `TuiOptions` literal — 87 literals
283 // in all. The copies had drifted: different modules pinned different locales,
284 // currencies, and onboarding flags without anyone having chosen that, which is
285 // the non-hermeticity behind the intermittent `config_command_allow_shell_*`
286 // failures. Adding a `TuiOptions` field meant editing up to 87 sites.
287 //
288 // Express intentional differences by mutating the returned value at the call
289 // site, so the difference is visible as a deliberate line of test code rather
290 // than hidden inside another near-identical literal.
291
292 /// Default `TuiOptions` for tests, pinned to the deepseek-v4-pro fixture route.
293 pub(crate) fn test_tui_options(workspace: impl AsRef<Path>) -> crate::tui::app::TuiOptions {
294 let workspace = workspace.as_ref().to_path_buf();
295 crate::tui::app::TuiOptions {
296 model: "deepseek-v4-pro".to_string(),
297 workspace,
298 config_path: None,
299 config_profile: None,
300 allow_shell: false,
301 screen_mode: crate::tui::app::ScreenMode::Fullscreen,
302 use_mouse_capture: false,
303 mouse_capture_preference: false,
304 use_bracketed_paste: true,
305 max_subagents: 1,
306 skills_dir: PathBuf::from("."),
307 memory_path: PathBuf::from("memory.md"),
308 notes_path: PathBuf::from("notes.txt"),
309 mcp_config_path: PathBuf::from("mcp.json"),
310 use_memory: false,
311 // Majority-of-fixtures defaults, measured across the 89 literals this
312 // helper replaced. Modules that need the other value say so explicitly.
313 start_in_agent_mode: false,
314 skip_onboarding: true,
315 yolo: false,
316 resume_session_id: None,
317 initial_input: None,
318 startup_notice: None,
319 }
320 }
321
322 /// Build an `App` whose observable state does not depend on the developer's
323 /// machine.
324 ///
325 /// `App::new` consults real persisted settings (provider/model maps,
326 /// auto-model, route limits, locale, currency), so an un-pinned fixture
327 /// computes against whatever the developer last configured. Every pin below
328 /// exists because some test was observed to depend on it. This fixture models
329 /// a session after the user has chosen a Startup action; direct `App::new`
330 /// tests remain the clean-launch authority.
331 pub(crate) fn test_app_with_options(options: crate::tui::app::TuiOptions) -> crate::tui::app::App {
332 let config = crate::config::Config::default();
333 let mut app = crate::tui::app::App::new(options, &config);
334
335 // Shared behavior tests operate on the live session surface. Do not make
336 // the production startup conditional for them: clean launches are covered
337 // by direct `App::new` tests that retain the Tideline Startup Hero.
338 app.launch.visible = false;
339
340 // Deterministic presentation regardless of host locale.
341 app.cost_currency = crate::pricing::CostCurrency::Usd;
342 app.ui_locale = codewhale_localization::Locale::En;
343 // Transcript tests must not depend on a concurrently swapped settings
344 // home. Tests for hidden reasoning opt out explicitly.
345 app.show_thinking = true;
346 // Pin the route identity: without this, a machine with customized
347 // settings computes context-window assertions against a different model
348 // than the requested deepseek-v4-pro.
349 app.set_provider_identity(crate::config::ApiProvider::Deepseek, "deepseek");
350 app.billing_presentation = crate::route_billing::BillingPresentation::Metered;
351 app.model = "deepseek-v4-pro".to_string();
352 app.auto_model = false;
353 app.last_effective_model = None;
354 app.active_route_limits = None;
355 app.active_context_window_override = None;
356 // Fixtures replace `app.workspace` freely. Do not retain `App::new`'s real
357 // process cwd as a second discovery root: parallel tests and a large
358 // developer checkout can otherwise consume the bounded mention index
359 // before the fixture workspace is scanned.
360 app.composer.mention_cwd = None;
361 // `App::new` derives onboarding state from the real `~/.codewhale`, and a
362 // pending step makes `ui::frame::render` take its onboarding early return
363 // before it assigns `last_prompt_area` or any other chrome geometry. CI
364 // has no such state, so a layout test written against that machine passes
365 // there and fails on any developer box mid-onboarding — for no product
366 // reason. Shared fixtures render the ordinary session surface; onboarding
367 // has its own tests that set this state deliberately.
368 app.onboarding = crate::tui::app::OnboardingState::None;
369 app
370 }
371
372 #[cfg(test)]
373 mod tests {
374 use super::*;
375 use std::sync::mpsc;
376 use std::time::Duration;
377
378 #[test]
379 fn ambient_codewhale_home_is_not_a_test_seal() {
380 let _lock = lock_test_env();
381 let _ambient = EnvVarGuard::set("CODEWHALE_HOME", "/tmp/ambient-codewhale-home");
382 unregister_guarded_env_key("CODEWHALE_HOME");
383
384 let sealed = guarded_environment_provides_state_paths();
385
386 register_guarded_env_key("CODEWHALE_HOME");
387 assert!(!sealed, "ambient developer state must remain confined");
388 }
389
390 #[test]
391 fn removing_overrides_does_not_seal_the_ambient_home() {
392 let _lock = lock_test_env();
393 let _codewhale_home = EnvVarGuard::remove("CODEWHALE_HOME");
394 let _codewhale_config = EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
395 let _deepseek_config = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
396
397 assert!(
398 !guarded_environment_provides_state_paths(),
399 "removing an override must not expose the developer's HOME"
400 );
401 }
402
403 #[test]
404 fn removing_home_variables_does_not_seal_a_missing_path() {
405 let _lock = lock_test_env();
406 let _home = EnvVarGuard::remove("HOME");
407 let _userprofile = EnvVarGuard::remove("USERPROFILE");
408
409 assert!(
410 !guarded_environment_provides_state_paths(),
411 "removing HOME variables must keep state in the isolated test root"
412 );
413 assert_eq!(
414 crate::config_persistence::config_toml_path(None)
415 .expect("resolve isolated config path"),
416 unsealed_test_state_root().join(codewhale_config::CONFIG_FILE_NAME)
417 );
418 }
419
420 #[test]
421 fn lock_without_sealed_paths_does_not_use_developer_config() {
422 let _lock = lock_test_env();
423 let path = crate::config_persistence::config_toml_path(None)
424 .expect("resolve isolated config path");
425 let root = isolated_test_state_root();
426 assert!(
427 path.starts_with(root),
428 "holding lock_test_env without an EnvVarGuard must not read ~/.codewhale ({})",
429 path.display()
430 );
431 assert_eq!(
432 path,
433 unsealed_test_state_root().join(codewhale_config::CONFIG_FILE_NAME)
434 );
435 }
436
437 #[test]
438 fn unguarded_state_writes_use_isolated_test_root() {
439 const PROBE_ENV: &str = "CODEWHALE_TEST_STATE_ISOLATION_PROBE";
440 const RECEIPT_ENV: &str = "CODEWHALE_TEST_STATE_ISOLATION_RECEIPT";
441
442 if std::env::var_os(PROBE_ENV).is_some() {
443 let config_path =
444 crate::config_persistence::persist_root_bool_key(None, "allow_shell", true)
445 .expect("write isolated config");
446 let direct_config_path =
447 crate::config::save_workspace_trust(Path::new("/tmp/codewhale-test-workspace"))
448 .expect("write through direct default config path");
449 crate::settings::Settings::default()
450 .save()
451 .expect("write isolated settings");
452 let settings_path =
453 crate::settings::Settings::path().expect("resolve isolated settings");
454 let root = isolated_test_state_root();
455 assert!(config_path.starts_with(root), "{}", config_path.display());
456 assert!(
457 settings_path.starts_with(root),
458 "{}",
459 settings_path.display()
460 );
461 assert!(
462 direct_config_path.starts_with(root),
463 "{}",
464 direct_config_path.display()
465 );
466 let receipt = std::env::var_os(RECEIPT_ENV).expect("receipt path");
467 std::fs::write(
468 receipt,
469 format!(
470 "{}\n{}\n{}\n{}\n",
471 root.display(),
472 config_path.display(),
473 settings_path.display(),
474 direct_config_path.display()
475 ),
476 )
477 .expect("write isolation receipt");
478 return;
479 }
480
481 let sentinel = tempfile::tempdir().expect("sentinel home");
482 let user_state = sentinel.path().join(".codewhale");
483 std::fs::create_dir_all(&user_state).expect("create sentinel state");
484 let config_path = user_state.join("config.toml");
485 let settings_path = user_state.join("settings.toml");
486 let config_sentinel = b"# developer config sentinel\n";
487 let settings_sentinel = b"# developer settings sentinel\n";
488 std::fs::write(&config_path, config_sentinel).expect("seed config");
489 std::fs::write(&settings_path, settings_sentinel).expect("seed settings");
490 let receipt_path = sentinel.path().join("receipt.txt");
491
492 let output = std::process::Command::new(std::env::current_exe().expect("test binary"))
493 .arg("--exact")
494 .arg("test_support::tests::unguarded_state_writes_use_isolated_test_root")
495 .arg("--test-threads=1")
496 .env(PROBE_ENV, "1")
497 .env(RECEIPT_ENV, &receipt_path)
498 .env("HOME", sentinel.path())
499 .env("USERPROFILE", sentinel.path())
500 .env_remove("CODEWHALE_HOME")
501 .env_remove("CODEWHALE_CONFIG_PATH")
502 .env_remove("DEEPSEEK_CONFIG_PATH")
503 .output()
504 .expect("run isolated-state probe");
505 assert!(
506 output.status.success(),
507 "probe failed\nstdout:\n{}\nstderr:\n{}",
508 String::from_utf8_lossy(&output.stdout),
509 String::from_utf8_lossy(&output.stderr)
510 );
511
512 assert_eq!(
513 std::fs::read(&config_path).expect("read config sentinel"),
514 config_sentinel
515 );
516 assert_eq!(
517 std::fs::read(&settings_path).expect("read settings sentinel"),
518 settings_sentinel
519 );
520
521 let receipt = std::fs::read_to_string(&receipt_path).expect("read isolation receipt");
522 let mut paths = receipt.lines().map(PathBuf::from);
523 let isolated_root = paths.next().expect("root receipt");
524 let written_config = paths.next().expect("config receipt");
525 let written_settings = paths.next().expect("settings receipt");
526 let direct_config = paths.next().expect("direct config receipt");
527 assert!(!isolated_root.starts_with(sentinel.path()));
528 assert!(written_config.starts_with(&isolated_root));
529 assert!(written_settings.starts_with(&isolated_root));
530 assert!(direct_config.starts_with(&isolated_root));
531 assert!(written_config.exists());
532 assert!(written_settings.exists());
533 }
534
535 #[test]
536 fn config_path_read_waits_for_foreign_env_redirect_to_restore() {
537 let (started_tx, started_rx) = mpsc::channel();
538 let (tx, rx) = mpsc::channel();
539 let redirected = std::env::temp_dir().join(format!(
540 "codewhale-config-path-read-barrier-{}",
541 std::process::id()
542 ));
543
544 let reader = {
545 let lock = lock_test_env();
546 let redirect = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &redirected);
547 let reader = std::thread::spawn(move || {
548 started_tx.send(()).expect("signal config path read start");
549 tx.send(crate::config_persistence::config_toml_path(None))
550 .expect("send resolved config path");
551 });
552
553 started_rx
554 .recv_timeout(Duration::from_secs(2))
555 .expect("reader reached config path resolution");
556 assert!(
557 rx.recv_timeout(Duration::from_millis(50)).is_err(),
558 "a foreign reader observed the temporary config redirect"
559 );
560 drop(redirect);
561 drop(lock);
562 reader
563 };
564
565 let resolved = rx
566 .recv_timeout(Duration::from_secs(2))
567 .expect("reader resumed after the redirect was restored")
568 .expect("resolve config path");
569 reader.join().expect("reader thread");
570 assert_ne!(resolved, redirected);
571 }
572
573 #[test]
574 fn settings_save_waits_for_foreign_state_io_transaction() {
575 let (holder_ready_tx, holder_ready_rx) = mpsc::channel();
576 let (release_tx, release_rx) = mpsc::channel();
577 let holder = std::thread::spawn(move || {
578 with_test_state_io_lock(|| {
579 holder_ready_tx.send(()).expect("signal state lock held");
580 release_rx.recv().expect("release state lock");
581 });
582 });
583 holder_ready_rx
584 .recv_timeout(Duration::from_secs(2))
585 .expect("holder acquired state I/O lock");
586
587 let (started_tx, started_rx) = mpsc::channel();
588 let (saved_tx, saved_rx) = mpsc::channel();
589 let writer = std::thread::spawn(move || {
590 started_tx.send(()).expect("signal settings save start");
591 saved_tx
592 .send(crate::settings::Settings::default().save())
593 .expect("send settings save result");
594 });
595 started_rx
596 .recv_timeout(Duration::from_secs(2))
597 .expect("writer reached settings save");
598 assert!(
599 saved_rx.recv_timeout(Duration::from_millis(50)).is_err(),
600 "settings save did not wait for an in-flight state transaction"
601 );
602
603 release_tx.send(()).expect("release holder");
604 holder.join().expect("holder thread");
605 saved_rx
606 .recv_timeout(Duration::from_secs(2))
607 .expect("settings save resumed")
608 .expect("settings save succeeded");
609 writer.join().expect("writer thread");
610 }
611 }
612
612 lines RUST