| 1 | //! Cross-session composer input history (#366). |
| 2 | //! |
| 3 | //! Persists user-typed prompts to `~/.codewhale/composer_history.jsonl` |
| 4 | //! (using the legacy `~/.deepseek` root only when one already exists, |
| 5 | //! #3240) so pressing Up-arrow at the composer recalls |
| 6 | //! submissions from previous sessions, not just the current one. One entry |
| 7 | //! per JSON line, oldest first. Existing `composer_history.txt` files are |
| 8 | //! read verbatim on migration and left intact. The history is |
| 9 | //! capped at [`MAX_HISTORY_ENTRIES`] entries (older entries are pruned |
| 10 | //! at append time). Encoding every entry as a JSON string keeps multiline |
| 11 | //! prompts intact without mistaking old quoted prompts for encoded records. |
| 12 | //! |
| 13 | //! Slash commands are stored as well: recalling `/theme` or `/compact` |
| 14 | //! with Up-arrow is ordinary recall (#6006), and filtering on the `/` |
| 15 | //! prefix also dropped absolute paths like `cat /etc/fstab`. Empty / |
| 16 | //! whitespace-only inputs are still skipped. |
| 17 | //! |
| 18 | //! ## Off-thread writes (#1927) |
| 19 | //! |
| 20 | //! [`append_history`] used to block the caller for a read-then-atomic- |
| 21 | //! rewrite of the full file. That ran on the UI thread inside |
| 22 | //! `submit_input`, contributing a perceptible stall after Enter. The |
| 23 | //! public entry point now hands work to a dedicated writer thread via |
| 24 | //! [`writer_sender`] and returns immediately. Submissions stay serialised |
| 25 | //! in arrival order, so the on-disk file keeps its "oldest first" |
| 26 | //! invariant. |
| 27 | |
| 28 | use std::fs; |
| 29 | use std::io::{BufRead, BufReader}; |
| 30 | use std::path::{Path, PathBuf}; |
| 31 | use std::sync::OnceLock; |
| 32 | use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel}; |
| 33 | use std::time::Duration; |
| 34 | |
| 35 | /// Hard cap on persisted history. Keeps the file small (typical entries |
| 36 | /// are < 200 chars, so 1000 entries ≈ 200 KB) and bounds startup load |
| 37 | /// time. |
| 38 | pub const MAX_HISTORY_ENTRIES: usize = 1000; |
| 39 | |
| 40 | const HISTORY_FILE_NAME: &str = "composer_history.jsonl"; |
| 41 | const LEGACY_HISTORY_FILE_NAME: &str = "composer_history.txt"; |
| 42 | |
| 43 | fn default_history_path() -> Option<PathBuf> { |
| 44 | // Submitting a prompt in a UI test appends to the history; an unsealed test |
| 45 | // must not write the developer's real `~/.codewhale/composer_history.jsonl`. |
| 46 | #[cfg(test)] |
| 47 | if let Some(home) = crate::test_support::unsealed_state_dir(".") { |
| 48 | return history_path_with_home(Some(home)); |
| 49 | } |
| 50 | history_path_with_home(crate::config::effective_home_dir()) |
| 51 | } |
| 52 | |
| 53 | /// Resolve the composer-history file under `home`, preferring the CodeWhale |
| 54 | /// root and only falling back to the legacy `.deepseek` root when a legacy |
| 55 | /// file already exists. |
| 56 | /// |
| 57 | /// On a fresh install (neither file present) this returns the `.codewhale` |
| 58 | /// path, so the writer never recreates `~/.deepseek/` at runtime (#3240), |
| 59 | /// while users who haven't migrated keep their existing history in that |
| 60 | /// root. Mirrors the primary/legacy resolution used by |
| 61 | /// `snapshot::paths` and `artifacts`. |
| 62 | fn history_path_with_home(home: Option<PathBuf>) -> Option<PathBuf> { |
| 63 | let home = home?; |
| 64 | let primary = home.join(".codewhale").join(HISTORY_FILE_NAME); |
| 65 | if primary.exists() || primary.with_file_name(LEGACY_HISTORY_FILE_NAME).exists() { |
| 66 | return Some(primary); |
| 67 | } |
| 68 | let legacy = home.join(".deepseek").join(HISTORY_FILE_NAME); |
| 69 | if legacy.exists() || legacy.with_file_name(LEGACY_HISTORY_FILE_NAME).exists() { |
| 70 | return Some(legacy); |
| 71 | } |
| 72 | Some(primary) |
| 73 | } |
| 74 | |
| 75 | /// Read the persisted history into memory. Returns an empty vec if the |
| 76 | /// file doesn't exist or can't be parsed — this is best-effort. |
| 77 | #[must_use] |
| 78 | pub fn load_history() -> Vec<String> { |
| 79 | let Some(path) = default_history_path() else { |
| 80 | return Vec::new(); |
| 81 | }; |
| 82 | load_history_from(&path) |
| 83 | } |
| 84 | |
| 85 | fn load_history_from(path: &Path) -> Vec<String> { |
| 86 | // A separate filename is the format discriminator: arbitrary legacy |
| 87 | // prompts can themselves be valid JSON strings (including escapes). |
| 88 | let (source, encoded) = if path.exists() { |
| 89 | (path.to_path_buf(), true) |
| 90 | } else { |
| 91 | (path.with_file_name(LEGACY_HISTORY_FILE_NAME), false) |
| 92 | }; |
| 93 | let Ok(file) = fs::File::open(source) else { |
| 94 | return Vec::new(); |
| 95 | }; |
| 96 | BufReader::new(file) |
| 97 | .lines() |
| 98 | .map_while(Result::ok) |
| 99 | .filter(|line| !line.trim().is_empty()) |
| 100 | .map(|line| { |
| 101 | if encoded { |
| 102 | // Keep a malformed record as text rather than losing it on |
| 103 | // the next append/rewrite. |
| 104 | serde_json::from_str::<String>(&line).unwrap_or(line) |
| 105 | } else { |
| 106 | line |
| 107 | } |
| 108 | }) |
| 109 | .collect() |
| 110 | } |
| 111 | |
| 112 | /// Append an entry to the persisted history, pruning old entries to |
| 113 | /// stay within [`MAX_HISTORY_ENTRIES`]. Prompts and slash commands are kept; |
| 114 | /// empty input is skipped. |
| 115 | /// |
| 116 | /// Best-effort and non-blocking — work is forwarded to a dedicated writer |
| 117 | /// thread so the caller (typically the UI submit handler) returns |
| 118 | /// immediately. See module docs for the rationale (#1927). Failures on |
| 119 | /// the writer thread are logged via `tracing` but not propagated. |
| 120 | pub fn append_history(entry: &str) { |
| 121 | let Some(path) = default_history_path() else { |
| 122 | return; |
| 123 | }; |
| 124 | append_history_dispatched(&path, entry); |
| 125 | } |
| 126 | |
| 127 | /// Path-injectable variant of [`append_history`] used by tests. Forwards |
| 128 | /// the work to the dedicated writer thread (or falls back to a synchronous |
| 129 | /// write if the channel send fails) so callers never block on disk I/O. |
| 130 | fn append_history_dispatched(path: &Path, entry: &str) { |
| 131 | let entry = entry.to_string(); |
| 132 | if let Err(err) = writer_sender().send(HistoryWrite::Append(path.to_path_buf(), entry)) { |
| 133 | match err.0 { |
| 134 | HistoryWrite::Append(path, entry) => append_history_to(&path, &entry), |
| 135 | #[cfg(test)] |
| 136 | HistoryWrite::Flush(_) => unreachable!("flush messages are only sent by tests"), |
| 137 | } |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | enum HistoryWrite { |
| 142 | Append(PathBuf, String), |
| 143 | #[cfg(test)] |
| 144 | Flush(Sender<()>), |
| 145 | } |
| 146 | |
| 147 | /// Lazy singleton sender for the dedicated composer-history writer |
| 148 | /// thread. Initialised on first use; the thread runs for the lifetime |
| 149 | /// of the process and drains queued writes in arrival order. |
| 150 | fn writer_sender() -> &'static Sender<HistoryWrite> { |
| 151 | static SENDER: OnceLock<Sender<HistoryWrite>> = OnceLock::new(); |
| 152 | SENDER.get_or_init(|| { |
| 153 | let (tx, rx) = channel::<HistoryWrite>(); |
| 154 | let spawn_result = std::thread::Builder::new() |
| 155 | .name("composer-history-writer".to_string()) |
| 156 | .spawn(move || { |
| 157 | // recv() returns Err when all senders have dropped, which |
| 158 | // only happens at process shutdown because the singleton |
| 159 | // sender lives in a static for the lifetime of the process. |
| 160 | while let Ok(message) = rx.recv() { |
| 161 | match message { |
| 162 | HistoryWrite::Append(path, entry) => { |
| 163 | append_history_batch(&rx, (path, entry)); |
| 164 | } |
| 165 | #[cfg(test)] |
| 166 | HistoryWrite::Flush(done) => { |
| 167 | let _ = done.send(()); |
| 168 | } |
| 169 | } |
| 170 | } |
| 171 | }); |
| 172 | if let Err(err) = spawn_result { |
| 173 | tracing::warn!("Failed to spawn composer-history-writer: {err}"); |
| 174 | } |
| 175 | tx |
| 176 | }) |
| 177 | } |
| 178 | |
| 179 | fn append_history_batch(rx: &Receiver<HistoryWrite>, first: (PathBuf, String)) { |
| 180 | let mut pending = vec![first]; |
| 181 | #[cfg(test)] |
| 182 | let mut flush = None; |
| 183 | |
| 184 | loop { |
| 185 | match rx.recv_timeout(Duration::from_millis(2)) { |
| 186 | Ok(HistoryWrite::Append(path, entry)) => pending.push((path, entry)), |
| 187 | #[cfg(test)] |
| 188 | Ok(HistoryWrite::Flush(done)) => { |
| 189 | flush = Some(done); |
| 190 | break; |
| 191 | } |
| 192 | Err(RecvTimeoutError::Timeout) => break, |
| 193 | Err(RecvTimeoutError::Disconnected) => break, |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | for (path, entries) in group_history_writes_by_path(pending) { |
| 198 | append_history_entries_to(&path, entries.iter().map(String::as_str)); |
| 199 | } |
| 200 | |
| 201 | #[cfg(test)] |
| 202 | if let Some(done) = flush { |
| 203 | let _ = done.send(()); |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | fn group_history_writes_by_path(writes: Vec<(PathBuf, String)>) -> Vec<(PathBuf, Vec<String>)> { |
| 208 | let mut grouped: Vec<(PathBuf, Vec<String>)> = Vec::new(); |
| 209 | |
| 210 | for (path, entry) in writes { |
| 211 | if let Some((_, entries)) = grouped |
| 212 | .iter_mut() |
| 213 | .find(|(existing_path, _)| existing_path == &path) |
| 214 | { |
| 215 | entries.push(entry); |
| 216 | } else { |
| 217 | grouped.push((path, vec![entry])); |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | grouped |
| 222 | } |
| 223 | |
| 224 | fn append_history_to(path: &Path, entry: &str) { |
| 225 | append_history_entries_to(path, std::iter::once(entry)); |
| 226 | } |
| 227 | |
| 228 | /// Keep immediate recall and persisted history on the same duplicate rule. |
| 229 | /// Callers choose whether to preserve the submitted whitespace in their copy. |
| 230 | pub(crate) fn push_history_entry(entries: &mut Vec<String>, entry: &str) -> bool { |
| 231 | let trimmed = entry.trim(); |
| 232 | if trimmed.is_empty() || entries.last().is_some_and(|last| last.trim() == trimmed) { |
| 233 | return false; |
| 234 | } |
| 235 | entries.push(entry.to_string()); |
| 236 | true |
| 237 | } |
| 238 | |
| 239 | fn append_history_entries_to<'a>( |
| 240 | path: &Path, |
| 241 | entries_to_append: impl IntoIterator<Item = &'a str>, |
| 242 | ) { |
| 243 | if let Some(parent) = path.parent() |
| 244 | && let Err(err) = fs::create_dir_all(parent) |
| 245 | { |
| 246 | tracing::warn!( |
| 247 | "Failed to create composer history dir {}: {err}", |
| 248 | parent.display() |
| 249 | ); |
| 250 | return; |
| 251 | } |
| 252 | |
| 253 | // Read existing entries, append the new ones, prune from the front |
| 254 | // until under the cap, then atomically rewrite. |
| 255 | let mut entries = load_history_from(path); |
| 256 | let mut changed = false; |
| 257 | for entry in entries_to_append { |
| 258 | changed |= push_history_entry(&mut entries, entry.trim()); |
| 259 | } |
| 260 | |
| 261 | if !changed { |
| 262 | return; |
| 263 | } |
| 264 | |
| 265 | if entries.len() > MAX_HISTORY_ENTRIES { |
| 266 | let excess = entries.len() - MAX_HISTORY_ENTRIES; |
| 267 | entries.drain(0..excess); |
| 268 | } |
| 269 | |
| 270 | let payload = entries |
| 271 | .iter() |
| 272 | .map(|entry| serde_json::to_string(entry).expect("serializing a string cannot fail")) |
| 273 | .collect::<Vec<_>>() |
| 274 | .join("\n") |
| 275 | + "\n"; |
| 276 | if let Err(err) = write_history_atomic(path, payload.as_bytes()) { |
| 277 | tracing::warn!( |
| 278 | "Failed to persist composer history at {}: {err}", |
| 279 | path.display() |
| 280 | ); |
| 281 | } |
| 282 | } |
| 283 | |
| 284 | fn write_history_atomic(path: &Path, payload: &[u8]) -> std::io::Result<()> { |
| 285 | const RETRY_DELAYS: &[Duration] = &[ |
| 286 | Duration::from_millis(5), |
| 287 | Duration::from_millis(10), |
| 288 | Duration::from_millis(25), |
| 289 | Duration::from_millis(50), |
| 290 | Duration::from_millis(100), |
| 291 | Duration::from_millis(200), |
| 292 | Duration::from_millis(400), |
| 293 | ]; |
| 294 | |
| 295 | for (attempt, delay) in RETRY_DELAYS |
| 296 | .iter() |
| 297 | .map(Some) |
| 298 | .chain(std::iter::once(None)) |
| 299 | .enumerate() |
| 300 | { |
| 301 | match crate::utils::write_atomic(path, payload) { |
| 302 | Ok(()) => return Ok(()), |
| 303 | Err(err) if delay.is_some() => { |
| 304 | tracing::debug!( |
| 305 | "Retrying composer history write to {} after attempt {} failed: {err}", |
| 306 | path.display(), |
| 307 | attempt + 1 |
| 308 | ); |
| 309 | std::thread::sleep(*delay.expect("delay checked")); |
| 310 | } |
| 311 | Err(err) => return Err(err), |
| 312 | } |
| 313 | } |
| 314 | |
| 315 | unreachable!("retry iterator always ends with a final write attempt") |
| 316 | } |
| 317 | |
| 318 | #[cfg(test)] |
| 319 | pub(crate) fn flush_history_writer_for_tests(timeout: Duration) { |
| 320 | let (done_tx, done_rx) = channel(); |
| 321 | writer_sender() |
| 322 | .send(HistoryWrite::Flush(done_tx)) |
| 323 | .expect("history writer accepts flush"); |
| 324 | done_rx |
| 325 | .recv_timeout(timeout) |
| 326 | .expect("history writer flush timed out"); |
| 327 | } |
| 328 | |
| 329 | #[cfg(test)] |
| 330 | mod tests { |
| 331 | use super::*; |
| 332 | use std::time::{Duration, Instant}; |
| 333 | |
| 334 | /// Tests use the path-injecting `*_from` / `*_to` helpers so they |
| 335 | /// don't have to mutate `HOME` (which is not honored by |
| 336 | /// `crate::config::effective_home_dir()` on Windows — it reads `USERPROFILE` / |
| 337 | /// `SHGetKnownFolderPath` instead). This makes the suite portable |
| 338 | /// across all three CI runners without per-platform env juggling. |
| 339 | fn temp_history_path() -> (tempfile::TempDir, PathBuf) { |
| 340 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 341 | let path = tmp.path().join(HISTORY_FILE_NAME); |
| 342 | (tmp, path) |
| 343 | } |
| 344 | |
| 345 | // #3240: a fresh install must resolve the history file under `.codewhale`, |
| 346 | // never the legacy `.deepseek` dir, so normal use doesn't recreate it. |
| 347 | #[test] |
| 348 | fn fresh_install_uses_codewhale_not_legacy() { |
| 349 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 350 | let path = history_path_with_home(Some(tmp.path().to_path_buf())) |
| 351 | .expect("path resolves with a home dir"); |
| 352 | assert_eq!(path, tmp.path().join(".codewhale").join(HISTORY_FILE_NAME)); |
| 353 | assert!( |
| 354 | !path.starts_with(tmp.path().join(".deepseek")), |
| 355 | "fresh install must not target the legacy .deepseek dir: {path:?}" |
| 356 | ); |
| 357 | } |
| 358 | |
| 359 | // Migration care: an existing legacy text history keeps its root and |
| 360 | // survives the first JSONL write byte-for-byte. |
| 361 | #[test] |
| 362 | fn existing_legacy_history_is_still_used() { |
| 363 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 364 | let legacy = tmp.path().join(".deepseek").join(LEGACY_HISTORY_FILE_NAME); |
| 365 | fs::create_dir_all(legacy.parent().expect("legacy parent")).expect("mkdir legacy"); |
| 366 | fs::write(&legacy, "old entry\n").expect("seed legacy history"); |
| 367 | let path = history_path_with_home(Some(tmp.path().to_path_buf())).expect("path resolves"); |
| 368 | assert_eq!(path, legacy.with_file_name(HISTORY_FILE_NAME)); |
| 369 | assert_eq!(load_history_from(&path), ["old entry"]); |
| 370 | append_history_to(&path, "new\nentry"); |
| 371 | assert_eq!(load_history_from(&path), ["old entry", "new\nentry"]); |
| 372 | assert_eq!(fs::read_to_string(&legacy).unwrap(), "old entry\n"); |
| 373 | } |
| 374 | |
| 375 | // Once a `.codewhale` history exists it wins over either legacy format. |
| 376 | #[test] |
| 377 | fn codewhale_history_preferred_over_legacy() { |
| 378 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 379 | let primary = tmp.path().join(".codewhale").join(LEGACY_HISTORY_FILE_NAME); |
| 380 | let legacy = tmp.path().join(".deepseek").join(HISTORY_FILE_NAME); |
| 381 | for p in [&primary, &legacy] { |
| 382 | fs::create_dir_all(p.parent().expect("parent")).expect("mkdir"); |
| 383 | fs::write(p, "x\n").expect("seed"); |
| 384 | } |
| 385 | let path = history_path_with_home(Some(tmp.path().to_path_buf())).expect("path resolves"); |
| 386 | assert_eq!(path, primary.with_file_name(HISTORY_FILE_NAME)); |
| 387 | } |
| 388 | |
| 389 | #[test] |
| 390 | fn append_and_load_round_trip() { |
| 391 | let (_tmp, path) = temp_history_path(); |
| 392 | append_history_to(&path, "first"); |
| 393 | append_history_to(&path, "second"); |
| 394 | append_history_to(&path, "third"); |
| 395 | assert_eq!(load_history_from(&path), vec!["first", "second", "third"]); |
| 396 | } |
| 397 | |
| 398 | #[test] |
| 399 | fn slash_commands_and_absolute_paths_stored() { |
| 400 | let (_tmp, path) = temp_history_path(); |
| 401 | append_history_to(&path, "/help"); |
| 402 | append_history_to(&path, "real prompt"); |
| 403 | append_history_to(&path, "/cost"); |
| 404 | append_history_to(&path, "cat /etc/fstab"); |
| 405 | assert_eq!( |
| 406 | load_history_from(&path), |
| 407 | vec!["/help", "real prompt", "/cost", "cat /etc/fstab"] |
| 408 | ); |
| 409 | } |
| 410 | |
| 411 | #[test] |
| 412 | fn multi_line_entries_round_trip_as_one_entry() { |
| 413 | let (_tmp, path) = temp_history_path(); |
| 414 | append_history_to(&path, "first"); |
| 415 | append_history_to(&path, "fn main() {\n run();\n}"); |
| 416 | append_history_to(&path, "\"quoted\" start"); |
| 417 | append_history_to(&path, "last"); |
| 418 | assert_eq!( |
| 419 | load_history_from(&path), |
| 420 | vec![ |
| 421 | "first", |
| 422 | "fn main() {\n run();\n}", |
| 423 | "\"quoted\" start", |
| 424 | "last" |
| 425 | ] |
| 426 | ); |
| 427 | // The same multi-line prompt twice is still one consecutive duplicate. |
| 428 | append_history_to(&path, "a\nb"); |
| 429 | append_history_to(&path, "a\nb"); |
| 430 | assert_eq!(load_history_from(&path).len(), 5); |
| 431 | } |
| 432 | |
| 433 | #[test] |
| 434 | fn legacy_plain_quoted_lines_keep_their_quotes() { |
| 435 | let (_tmp, path) = temp_history_path(); |
| 436 | // Older writers trimmed entries and stored every remaining byte raw. |
| 437 | // JSON-looking strings are user text, even when decoding and encoding |
| 438 | // them would round-trip: the escapes must not turn into control chars. |
| 439 | let legacy = [r#""yes""#, r#""a" and "b""#, r#""a\nb""#, r#""\"quoted\"""#]; |
| 440 | let legacy_path = path.with_file_name(LEGACY_HISTORY_FILE_NAME); |
| 441 | let original = legacy.join("\n") + "\n"; |
| 442 | fs::write(&legacy_path, &original).expect("seed legacy file"); |
| 443 | assert_eq!(load_history_from(&path), legacy); |
| 444 | // Rewriting on the next append must preserve every legacy prompt. |
| 445 | append_history_to(&path, "next\nline"); |
| 446 | let expected: Vec<String> = legacy |
| 447 | .into_iter() |
| 448 | .chain(["next\nline"]) |
| 449 | .map(str::to_string) |
| 450 | .collect(); |
| 451 | assert_eq!(load_history_from(&path), expected); |
| 452 | assert_eq!(fs::read_to_string(&legacy_path).unwrap(), original); |
| 453 | } |
| 454 | |
| 455 | #[test] |
| 456 | fn malformed_json_records_stay_literal() { |
| 457 | let (_tmp, path) = temp_history_path(); |
| 458 | fs::write(&path, "malformed record").expect("seed malformed record"); |
| 459 | assert_eq!(load_history_from(&path), ["malformed record"]); |
| 460 | append_history_to(&path, "next"); |
| 461 | assert_eq!(load_history_from(&path), ["malformed record", "next"]); |
| 462 | } |
| 463 | |
| 464 | #[test] |
| 465 | fn consecutive_duplicate_commands_deduped() { |
| 466 | let (_tmp, path) = temp_history_path(); |
| 467 | append_history_to(&path, "/theme"); |
| 468 | append_history_to(&path, "/theme"); |
| 469 | append_history_to(&path, "/theme"); |
| 470 | assert_eq!(load_history_from(&path), vec!["/theme"]); |
| 471 | } |
| 472 | |
| 473 | #[test] |
| 474 | fn empty_and_whitespace_skipped() { |
| 475 | let (_tmp, path) = temp_history_path(); |
| 476 | append_history_to(&path, ""); |
| 477 | append_history_to(&path, " "); |
| 478 | append_history_to(&path, "\n\t"); |
| 479 | append_history_to(&path, "real"); |
| 480 | assert_eq!(load_history_from(&path), vec!["real"]); |
| 481 | } |
| 482 | |
| 483 | #[test] |
| 484 | fn consecutive_duplicates_deduped() { |
| 485 | let (_tmp, path) = temp_history_path(); |
| 486 | append_history_to(&path, "same"); |
| 487 | append_history_to(&path, "same"); |
| 488 | append_history_to(&path, "same"); |
| 489 | append_history_to(&path, "different"); |
| 490 | append_history_to(&path, "same"); |
| 491 | assert_eq!(load_history_from(&path), vec!["same", "different", "same"]); |
| 492 | } |
| 493 | |
| 494 | #[test] |
| 495 | fn pruned_to_cap_at_append_time() { |
| 496 | let (_tmp, path) = temp_history_path(); |
| 497 | // One batched rewrite — a per-entry loop would fsync 1000+ times. |
| 498 | let entries: Vec<String> = (0..(MAX_HISTORY_ENTRIES + 50)) |
| 499 | .map(|i| format!("entry {i}")) |
| 500 | .collect(); |
| 501 | append_history_entries_to(&path, entries.iter().map(String::as_str)); |
| 502 | let history = load_history_from(&path); |
| 503 | assert_eq!(history.len(), MAX_HISTORY_ENTRIES); |
| 504 | // Newest entries survive; oldest 50 were pruned. |
| 505 | assert_eq!(history.first().map(String::as_str), Some("entry 50")); |
| 506 | assert_eq!( |
| 507 | history.last().map(String::as_str), |
| 508 | Some(format!("entry {}", MAX_HISTORY_ENTRIES + 49)).as_deref() |
| 509 | ); |
| 510 | |
| 511 | // Keep a cheap boundary check on the singleton production wrapper: |
| 512 | // seed to one below the cap in one write, then cross it with only two |
| 513 | // fsyncing appends. The second append must prune exactly the oldest |
| 514 | // entry rather than only enforcing the cap for batched callers. |
| 515 | let (_boundary_tmp, boundary_path) = temp_history_path(); |
| 516 | let seeded: Vec<String> = (0..(MAX_HISTORY_ENTRIES - 1)) |
| 517 | .map(|i| format!("entry {i}")) |
| 518 | .collect(); |
| 519 | append_history_entries_to(&boundary_path, seeded.iter().map(String::as_str)); |
| 520 | append_history_to( |
| 521 | &boundary_path, |
| 522 | &format!("entry {}", MAX_HISTORY_ENTRIES - 1), |
| 523 | ); |
| 524 | append_history_to(&boundary_path, &format!("entry {MAX_HISTORY_ENTRIES}")); |
| 525 | let boundary = load_history_from(&boundary_path); |
| 526 | assert_eq!(boundary.len(), MAX_HISTORY_ENTRIES); |
| 527 | assert_eq!(boundary.first().map(String::as_str), Some("entry 1")); |
| 528 | assert_eq!( |
| 529 | boundary.last().map(String::as_str), |
| 530 | Some(format!("entry {MAX_HISTORY_ENTRIES}")).as_deref() |
| 531 | ); |
| 532 | } |
| 533 | |
| 534 | #[test] |
| 535 | fn missing_file_loads_empty() { |
| 536 | let (_tmp, path) = temp_history_path(); |
| 537 | assert!(load_history_from(&path).is_empty()); |
| 538 | } |
| 539 | |
| 540 | /// Regression for #1927 — the dispatched append path must return |
| 541 | /// promptly even when a synchronous write of the seeded file would |
| 542 | /// be slow. We pre-populate the file with ~1000 entries (the cap) |
| 543 | /// so a sync read-modify-write would take real disk time on any |
| 544 | /// platform, then call `append_history_dispatched` many times and |
| 545 | /// assert that the cumulative wall-clock cost stays well below the |
| 546 | /// stall the user reports. |
| 547 | #[test] |
| 548 | fn append_history_dispatched_does_not_block_the_caller() { |
| 549 | let (_tmp, path) = temp_history_path(); |
| 550 | // Seed close to the cap so a synchronous rewrite is non-trivial. |
| 551 | let seed = (0..(MAX_HISTORY_ENTRIES - 50)) |
| 552 | .map(|i| format!("seed entry {i}")) |
| 553 | .collect::<Vec<_>>() |
| 554 | .join("\n") |
| 555 | + "\n"; |
| 556 | std::fs::write(&path, seed).expect("seed history"); |
| 557 | |
| 558 | let start = Instant::now(); |
| 559 | for i in 0..50 { |
| 560 | append_history_dispatched(&path, &format!("new entry {i}")); |
| 561 | } |
| 562 | let dispatch_elapsed = start.elapsed(); |
| 563 | |
| 564 | // 50 sync read-modify-write cycles on a ~200KB file would be |
| 565 | // measurable (tens of ms even on a fast SSD). The dispatch path |
| 566 | // hands work to the writer thread and returns; the whole loop |
| 567 | // should finish in single-digit ms. Pick a generous CI-safe |
| 568 | // bound that still catches a regression to the old sync path. |
| 569 | assert!( |
| 570 | dispatch_elapsed < Duration::from_millis(150), |
| 571 | "append_history dispatch was too slow: {dispatch_elapsed:?} \ |
| 572 | (likely re-introduced #1927: caller blocked on disk write)" |
| 573 | ); |
| 574 | |
| 575 | flush_history_writer_for_tests(Duration::from_secs(if cfg!(windows) { 10 } else { 5 })); |
| 576 | |
| 577 | let loaded = load_history_from(&path); |
| 578 | assert!( |
| 579 | loaded.iter().any(|line| line == "new entry 49"), |
| 580 | "writer thread did not persist the dispatched entries; \ |
| 581 | loaded {} entries, last = {:?}", |
| 582 | loaded.len(), |
| 583 | loaded.last() |
| 584 | ); |
| 585 | assert!(loaded.iter().any(|line| line == "new entry 0")); |
| 586 | } |
| 587 | } |
| 588 |