From 6594a3518e903d2dc0b441e7994e10fed97f9a90 Mon Sep 17 00:00:00 2001 From: cpendery Date: Thu, 13 Aug 2026 23:51:05 -0700 Subject: [PATCH] fix: finalize capture decoders independently --- crates/tui-test/src/record.rs | 65 ++++++++++++++++++++++++++++ crates/tui-test/src/record/cast.rs | 1 - crates/tui-test/src/record/worker.rs | 47 ++++++++++++++++---- 3 files changed, 103 insertions(+), 10 deletions(-) diff --git a/crates/tui-test/src/record.rs b/crates/tui-test/src/record.rs index c946f906..15319417 100644 --- a/crates/tui-test/src/record.rs +++ b/crates/tui-test/src/record.rs @@ -180,6 +180,7 @@ pub(crate) fn sidecar_path(target: &std::path::Path) -> PathBuf { #[cfg(test)] mod tests { use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; #[test] fn flush_acknowledges_all_prior_capture_messages() { @@ -203,4 +204,68 @@ mod tests { drop(recorder); std::fs::remove_file(path).unwrap(); } + + #[test] + fn stopped_recording_finishes_pending_decoder_bytes() { + let primary = temp_path("primary"); + let target = temp_path("selected"); + let recorder = Recorder::create( + primary.clone(), + 80, + 30, + &[], + Arc::new(crate::logger::Logger::disabled()), + ); + recorder + .start(StartRecording { + target_path: target.clone(), + capture_path: target.clone(), + format: RecordingFormat::Cast, + cols: 80, + rows: 30, + env: Vec::new(), + initial_output: String::new(), + #[cfg(feature = "recording-raster")] + timeline: frames::TimelineOptions::default(), + }) + .unwrap(); + recorder.capture().on_data(b"tail\x1b[?"); + recorder.stop().unwrap(); + + let cast = std::fs::read_to_string(&target).unwrap(); + assert!(cast.contains("tail")); + assert!(cast.contains(r#"\u001b[?"#)); + + drop(recorder); + std::fs::remove_file(primary).unwrap(); + std::fs::remove_file(target).unwrap(); + } + + #[test] + fn shutdown_finishes_pending_primary_decoder_bytes() { + let path = temp_path("shutdown"); + let recorder = Recorder::create( + path.clone(), + 80, + 30, + &[], + Arc::new(crate::logger::Logger::disabled()), + ); + recorder.capture().on_data(b"tail\x1b[?"); + drop(recorder); + + let cast = std::fs::read_to_string(&path).unwrap(); + assert!(cast.contains("tail")); + assert!(cast.contains(r#"\u001b[?"#)); + std::fs::remove_file(path).unwrap(); + } + + fn temp_path(label: &str) -> PathBuf { + static SEQUENCE: AtomicU64 = AtomicU64::new(0); + std::env::temp_dir().join(format!( + "tui-test-recorder-{label}-{}-{}.cast", + std::process::id(), + SEQUENCE.fetch_add(1, Ordering::Relaxed) + )) + } } diff --git a/crates/tui-test/src/record/cast.rs b/crates/tui-test/src/record/cast.rs index 0d6009f4..3ab07197 100644 --- a/crates/tui-test/src/record/cast.rs +++ b/crates/tui-test/src/record/cast.rs @@ -142,7 +142,6 @@ impl IncrementalDecoder { output } - #[allow(dead_code)] // The capture worker finalizes independent decoders in a later stack layer. pub fn finish(&mut self) -> String { self.utf8_pending.append(&mut self.filter_pending); let output = String::from_utf8_lossy(&self.utf8_pending).into_owned(); diff --git a/crates/tui-test/src/record/worker.rs b/crates/tui-test/src/record/worker.rs index ae677595..1d2724f4 100644 --- a/crates/tui-test/src/record/worker.rs +++ b/crates/tui-test/src/record/worker.rs @@ -5,8 +5,10 @@ use super::{cast, CaptureError, Message, StartRecording, StoppedRecording}; struct ActiveRecording { writer: cast::CastWriter, + decoder: cast::IncrementalDecoder, request: StartRecording, started: Instant, + last_at: Instant, error: Option, } @@ -15,24 +17,30 @@ pub(super) fn worker_loop( mut primary: Option, logger: Arc, ) { - let mut decoder = cast::IncrementalDecoder::default(); + let mut primary_decoder = cast::IncrementalDecoder::default(); + let mut primary_last_at = Instant::now(); let mut active: Option = None; while let Ok(message) = receiver.recv() { match message { Message::Data { at, bytes } => { - let text = decoder.push(&bytes); - if text.is_empty() { - continue; - } - if let Some(writer) = primary.as_mut() { - if let Err(error) = writer.write_output(at, &text) { - logger.event(&format!("automatic recording failed: {error}")); - primary = None; + primary_last_at = at; + let text = primary_decoder.push(&bytes); + if !text.is_empty() { + if let Some(writer) = primary.as_mut() { + if let Err(error) = writer.write_output(at, &text) { + logger.event(&format!("automatic recording failed: {error}")); + primary = None; + } } } if let Some(recording) = active.as_mut().filter(|recording| at >= recording.started) { + recording.last_at = at; + let text = recording.decoder.push(&bytes); + if text.is_empty() { + continue; + } let result = recording.writer.write_output(at, &text); remember_error(recording, result); } @@ -75,8 +83,10 @@ pub(super) fn worker_loop( } active = Some(ActiveRecording { writer, + decoder: primary_decoder.clone(), request, started: at, + last_at: at, error: None, }); let _ = reply.send(Ok(())); @@ -86,6 +96,11 @@ pub(super) fn worker_loop( let _ = reply.send(Err(CaptureError::NotActive)); continue; }; + let tail = recording.decoder.finish(); + if !tail.is_empty() { + let result = recording.writer.write_output(recording.last_at, &tail); + remember_error(&mut recording, result); + } if recording.error.is_none() { if let Err(error) = recording.writer.flush() { recording.error = Some(error.to_string()); @@ -125,10 +140,24 @@ pub(super) fn worker_loop( let _ = reply.send(result); } Message::Shutdown => { + let tail = primary_decoder.finish(); + if !tail.is_empty() { + if let Some(writer) = primary.as_mut() { + if let Err(error) = writer.write_output(primary_last_at, &tail) { + logger.event(&format!("automatic recording failed: {error}")); + primary = None; + } + } + } if let Some(writer) = primary.as_mut() { let _ = writer.flush(); } if let Some(recording) = active.as_mut() { + let tail = recording.decoder.finish(); + if !tail.is_empty() { + let result = recording.writer.write_output(recording.last_at, &tail); + remember_error(recording, result); + } let _ = recording.writer.flush(); } break;