From 8246ea1e511e67bc0a2654c15df80103c6f2180c Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Thu, 27 Aug 2026 00:25:44 -0500 Subject: [PATCH 1/6] fix(capture): scope secret-prompt dedup to the prompt being answered --- src/commands/capture.rs | 381 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 366 insertions(+), 15 deletions(-) diff --git a/src/commands/capture.rs b/src/commands/capture.rs index 8770d6a..93e737d 100644 --- a/src/commands/capture.rs +++ b/src/commands/capture.rs @@ -84,22 +84,33 @@ fn ms(t0: Instant) -> u64 { /// `inquire` emits the prompt immediately followed by `\r` (`Vault passphrase:\r…`), /// so checking only at the chunk end (after the `\r` cleared it) missed it and the /// secret leaked. Latches `sensitive` and records the prompt label when found. +/// When a completed non-secret line is seen, sets `secret_prompt_cleared` to +/// signal the input thread that the dedup guard can be cleared. Only set on +/// completed lines (at `\n`/`\r`), not on partial lines at chunk boundaries, +/// to avoid spurious clears when a prompt label is split across PTY reads. fn track_and_detect( line: &mut String, text: &str, sensitive: &AtomicBool, secret_prompt: &Mutex>, + secret_prompt_cleared: &AtomicBool, ) { - let check = |line: &str| { + let detect_secret = |line: &str| { if is_secret_prompt(line) { sensitive.store(true, Ordering::SeqCst); *secret_prompt.lock().unwrap() = Some(clean_prompt(line)); } }; + let mark_cleared = |line: &str| { + if !is_secret_prompt(line) && !line.is_empty() { + secret_prompt_cleared.store(true, Ordering::SeqCst); + } + }; for ch in text.chars() { match ch { '\n' | '\r' => { - check(line); + detect_secret(line); + mark_cleared(line); line.clear(); } c if c.is_control() => {} @@ -115,7 +126,8 @@ fn track_and_detect( } } // The prompt may sit at the chunk end with no trailing newline yet. - check(line); + // Only detect secrets here; don't set the cleared flag on partial lines. + detect_secret(line); } /// Longest a terminal line can be and still count as a secret prompt. Real @@ -163,6 +175,34 @@ fn clean_prompt(s: &str) -> String { .to_string() } +/// Decide whether a submitted secret prompt should produce a `Secret` event. +/// Returns true when the caller must record one. +/// +/// `prompt_left_screen` is true when the output thread has observed a completed +/// non-secret line since the last submission, meaning the prompt has left the +/// screen and the dedup guard should be cleared before checking. +/// +/// The dedup covers only redraws of the prompt currently being answered: once +/// `prompt_left_screen` is true the guard is cleared, so the same prompt text +/// detected later records a new event. Two consecutive detections of the same +/// prompt with no submission in between still collapse into one. +fn secret_step_on_submit( + last_secret_prompt: &mut Option, + prompt_left_screen: bool, + prompt: &str, +) -> bool { + if prompt_left_screen { + *last_secret_prompt = None; + } + let is_dup = last_secret_prompt.as_ref().map(|s| s.as_str()) == Some(prompt); + if !is_dup { + *last_secret_prompt = Some(prompt.to_string()); + true + } else { + false + } +} + /// Heuristic: does this line look like a program prompting for a secret? Matches /// a secret keyword on a line that ends like a prompt (`:` or `?`), so a typed /// command that merely mentions "password" is not mistaken for a prompt. Long @@ -864,6 +904,11 @@ pub fn run(args: CaptureArgs) -> Result<()> { // captured so a `Secret` event can record WHICH secret was entered — never the // value. Set by the output thread, consumed by the input thread on Enter. let secret_prompt: Arc>> = Arc::new(Mutex::new(None)); + // Set by the output thread when a non-secret line is seen after a secret prompt + // was detected. The input thread reads this on Enter to decide whether to clear + // the dedup guard: if true, the prompt has left the screen and the same prompt + // text detected later should record a new Secret event. + let secret_prompt_cleared = Arc::new(AtomicBool::new(false)); // Browser reveals armed by `demo open --when `, fired by the output // thread when the pattern appears. let pending_opens: PendingWhen = Arc::new(Mutex::new(Vec::new())); @@ -948,6 +993,7 @@ pub fn run(args: CaptureArgs) -> Result<()> { let exited = shell_exited.clone(); let sensitive = sensitive.clone(); let secret_prompt = secret_prompt.clone(); + let secret_prompt_cleared = secret_prompt_cleared.clone(); let debug = debug.clone(); let pending_opens = pending_opens.clone(); let muting = muting.clone(); @@ -988,7 +1034,13 @@ pub fn run(args: CaptureArgs) -> Result<()> { // Detect the secret prompt at each line boundary and LATCH // the flag (only set here; the input thread clears it on // Enter), so the masked redraws can't unset it mid-secret. - track_and_detect(&mut line, &text, &sensitive, &secret_prompt); + track_and_detect( + &mut line, + &text, + &sensitive, + &secret_prompt, + &secret_prompt_cleared, + ); // Pre-roll: discard prompt-setup output until the readiness // marker; record only what follows it (the clean prompt). if !ready.load(Ordering::SeqCst) { @@ -1057,6 +1109,7 @@ pub fn run(args: CaptureArgs) -> Result<()> { let stop = stop.clone(); let sensitive = sensitive.clone(); let secret_prompt = secret_prompt.clone(); + let secret_prompt_cleared = secret_prompt_cleared.clone(); let debug = debug.clone(); let muting = muting.clone(); let mute_since = mute_since.clone(); @@ -1121,11 +1174,15 @@ pub fn run(args: CaptureArgs) -> Result<()> { if masked { if outcome.to_pty.iter().any(|b| *b == b'\r' || *b == b'\n') { sensitive.store(false, Ordering::SeqCst); + let prompt_left_screen = + secret_prompt_cleared.swap(false, Ordering::SeqCst); let prompt = secret_prompt.lock().unwrap().take().unwrap_or_default(); - let is_dup = last_secret_prompt.as_ref() == Some(&prompt); - if !is_dup { - last_secret_prompt = Some(prompt.clone()); + if secret_step_on_submit( + &mut last_secret_prompt, + prompt_left_screen, + &prompt, + ) { events.lock().unwrap().push(RawEvent::Secret { t_ms: ms(t0), prompt, @@ -1648,6 +1705,7 @@ mod tests { // detection must fire on the prompt BEFORE the `\r` clears the line. let sensitive = AtomicBool::new(false); let secret_prompt = Mutex::new(None); + let secret_prompt_cleared = AtomicBool::new(false); let mut line = String::new(); // `\x1b` (ESC) is a control char; `[?25h` is the residue after it. track_and_detect( @@ -1655,6 +1713,7 @@ mod tests { " Vault passphrase:\r\x1b[?25h", &sensitive, &secret_prompt, + &secret_prompt_cleared, ); assert!( sensitive.load(Ordering::SeqCst), @@ -1701,8 +1760,15 @@ mod tests { let sensitive = AtomicBool::new(false); let secret_prompt = Mutex::new(None); + let secret_prompt_cleared = AtomicBool::new(false); let mut line = String::new(); - track_and_detect(&mut line, &huge, &sensitive, &secret_prompt); + track_and_detect( + &mut line, + &huge, + &sensitive, + &secret_prompt, + &secret_prompt_cleared, + ); assert!(!sensitive.load(Ordering::SeqCst)); assert!(secret_prompt.lock().unwrap().is_none()); // And the tracker's memory stays bounded while the paint streams on. @@ -2210,8 +2276,15 @@ mod tests { fn track_and_detect_multiple_lines() { let sensitive = AtomicBool::new(false); let secret_prompt = Mutex::new(None); + let secret_prompt_cleared = AtomicBool::new(false); let mut line = String::new(); - track_and_detect(&mut line, "hello\nworld\n", &sensitive, &secret_prompt); + track_and_detect( + &mut line, + "hello\nworld\n", + &sensitive, + &secret_prompt, + &secret_prompt_cleared, + ); assert!(!sensitive.load(Ordering::SeqCst)); assert_eq!(line, ""); } @@ -2220,8 +2293,15 @@ mod tests { fn track_and_detect_partial_line() { let sensitive = AtomicBool::new(false); let secret_prompt = Mutex::new(None); + let secret_prompt_cleared = AtomicBool::new(false); let mut line = String::new(); - track_and_detect(&mut line, "partial", &sensitive, &secret_prompt); + track_and_detect( + &mut line, + "partial", + &sensitive, + &secret_prompt, + &secret_prompt_cleared, + ); assert!(!sensitive.load(Ordering::SeqCst)); assert_eq!(line, "partial"); } @@ -2230,12 +2310,14 @@ mod tests { fn track_and_detect_csi_residue_in_line() { let sensitive = AtomicBool::new(false); let secret_prompt = Mutex::new(None); + let secret_prompt_cleared = AtomicBool::new(false); let mut line = String::new(); track_and_detect( &mut line, "Vault passphrase:\r\x1b[?25h", &sensitive, &secret_prompt, + &secret_prompt_cleared, ); assert!(sensitive.load(Ordering::SeqCst)); } @@ -2244,8 +2326,15 @@ mod tests { fn track_and_detect_control_chars_ignored() { let sensitive = AtomicBool::new(false); let secret_prompt = Mutex::new(None); + let secret_prompt_cleared = AtomicBool::new(false); let mut line = String::new(); - track_and_detect(&mut line, "abc\x01\x02\x03def", &sensitive, &secret_prompt); + track_and_detect( + &mut line, + "abc\x01\x02\x03def", + &sensitive, + &secret_prompt, + &secret_prompt_cleared, + ); assert_eq!(line, "abcdef"); } @@ -2515,8 +2604,15 @@ mod tests { fn track_and_detect_secret_at_newline_boundary() { let sensitive = AtomicBool::new(false); let secret_prompt = Mutex::new(None); + let secret_prompt_cleared = AtomicBool::new(false); let mut line = String::new(); - track_and_detect(&mut line, "Password:\n", &sensitive, &secret_prompt); + track_and_detect( + &mut line, + "Password:\n", + &sensitive, + &secret_prompt, + &secret_prompt_cleared, + ); assert!(sensitive.load(Ordering::SeqCst)); } @@ -2524,8 +2620,15 @@ mod tests { fn track_and_detect_no_secret_without_colon() { let sensitive = AtomicBool::new(false); let secret_prompt = Mutex::new(None); + let secret_prompt_cleared = AtomicBool::new(false); let mut line = String::new(); - track_and_detect(&mut line, "Password\n", &sensitive, &secret_prompt); + track_and_detect( + &mut line, + "Password\n", + &sensitive, + &secret_prompt, + &secret_prompt_cleared, + ); assert!(!sensitive.load(Ordering::SeqCst)); } @@ -2533,10 +2636,17 @@ mod tests { fn track_and_detect_long_line_not_truncated() { let sensitive = AtomicBool::new(false); let secret_prompt = Mutex::new(None); + let secret_prompt_cleared = AtomicBool::new(false); let mut line = String::new(); // Line under MAX_PROMPT_LINE should be kept let short = "a".repeat(100); - track_and_detect(&mut line, &short, &sensitive, &secret_prompt); + track_and_detect( + &mut line, + &short, + &sensitive, + &secret_prompt, + &secret_prompt_cleared, + ); assert_eq!(line.len(), 100); } @@ -2544,10 +2654,17 @@ mod tests { fn track_and_detect_long_line_truncated() { let sensitive = AtomicBool::new(false); let secret_prompt = Mutex::new(None); + let secret_prompt_cleared = AtomicBool::new(false); let mut line = String::new(); // Line over MAX_PROMPT_LINE should be truncated let long = "a".repeat(300); - track_and_detect(&mut line, &long, &sensitive, &secret_prompt); + track_and_detect( + &mut line, + &long, + &sensitive, + &secret_prompt, + &secret_prompt_cleared, + ); assert!(line.len() <= MAX_PROMPT_LINE); } @@ -2739,4 +2856,238 @@ mod tests { assert!(!is_meta_command("demo")); assert!(!is_meta_command("demo ")); } + + #[test] + fn secret_step_on_submit_no_guard_records() { + let mut last: Option = None; + assert!(secret_step_on_submit(&mut last, false, "Password:")); + assert_eq!(last.as_deref(), Some("Password:")); + } + + #[test] + fn secret_step_on_submit_same_prompt_is_dup() { + let mut last = Some("Password:".to_string()); + assert!(!secret_step_on_submit(&mut last, false, "Password:")); + } + + #[test] + fn secret_step_on_submit_different_prompt_records() { + let mut last = Some("Password:".to_string()); + assert!(secret_step_on_submit(&mut last, false, "Token:")); + assert_eq!(last.as_deref(), Some("Token:")); + } + + #[test] + fn secret_step_on_submit_clears_guard_when_prompt_left_screen() { + let mut last = Some("Password:".to_string()); + assert!(secret_step_on_submit(&mut last, true, "Password:")); + assert_eq!(last.as_deref(), Some("Password:")); + } + + #[test] + fn secret_dedup_same_prompt_with_submission_produces_two_events() { + let sensitive = AtomicBool::new(false); + let secret_prompt = Mutex::new(None); + let secret_prompt_cleared = AtomicBool::new(false); + let mut last_secret_prompt: Option = None; + let mut events = Vec::new(); + + let mut line = String::new(); + track_and_detect( + &mut line, + "Password:\n", + &sensitive, + &secret_prompt, + &secret_prompt_cleared, + ); + let prompt = secret_prompt.lock().unwrap().take().unwrap(); + let cleared = secret_prompt_cleared.swap(false, Ordering::SeqCst); + if secret_step_on_submit(&mut last_secret_prompt, cleared, &prompt) { + events.push(prompt); + } + sensitive.store(false, Ordering::SeqCst); + + let mut line = String::new(); + track_and_detect( + &mut line, + "Welcome to sudo\n", + &sensitive, + &secret_prompt, + &secret_prompt_cleared, + ); + assert!(secret_prompt_cleared.load(Ordering::SeqCst)); + + let mut line = String::new(); + track_and_detect( + &mut line, + "Password:\n", + &sensitive, + &secret_prompt, + &secret_prompt_cleared, + ); + let prompt = secret_prompt.lock().unwrap().take().unwrap(); + let cleared = secret_prompt_cleared.swap(false, Ordering::SeqCst); + if secret_step_on_submit(&mut last_secret_prompt, cleared, &prompt) { + events.push(prompt); + } + + assert_eq!(events.len(), 2); + } + + #[test] + fn secret_dedup_same_prompt_no_submission_produces_one_event() { + let sensitive = AtomicBool::new(false); + let secret_prompt = Mutex::new(None); + let secret_prompt_cleared = AtomicBool::new(false); + let mut last_secret_prompt: Option = None; + let mut events = Vec::new(); + + let mut line = String::new(); + track_and_detect( + &mut line, + "Password:\n", + &sensitive, + &secret_prompt, + &secret_prompt_cleared, + ); + + let mut line = String::new(); + track_and_detect( + &mut line, + "Password:\n", + &sensitive, + &secret_prompt, + &secret_prompt_cleared, + ); + + let prompt = secret_prompt.lock().unwrap().take().unwrap(); + let cleared = secret_prompt_cleared.swap(false, Ordering::SeqCst); + if secret_step_on_submit(&mut last_secret_prompt, cleared, &prompt) { + events.push(prompt); + } + + assert_eq!(events.len(), 1); + } + + #[test] + fn secret_dedup_different_prompts_produce_two_events() { + let sensitive = AtomicBool::new(false); + let secret_prompt = Mutex::new(None); + let secret_prompt_cleared = AtomicBool::new(false); + let mut last_secret_prompt: Option = None; + let mut events = Vec::new(); + + let mut line = String::new(); + track_and_detect( + &mut line, + "Password:\n", + &sensitive, + &secret_prompt, + &secret_prompt_cleared, + ); + let prompt = secret_prompt.lock().unwrap().take().unwrap(); + let cleared = secret_prompt_cleared.swap(false, Ordering::SeqCst); + if secret_step_on_submit(&mut last_secret_prompt, cleared, &prompt) { + events.push(prompt); + } + sensitive.store(false, Ordering::SeqCst); + + let mut line = String::new(); + track_and_detect( + &mut line, + "Some output\n", + &sensitive, + &secret_prompt, + &secret_prompt_cleared, + ); + + let mut line = String::new(); + track_and_detect( + &mut line, + "Token:\n", + &sensitive, + &secret_prompt, + &secret_prompt_cleared, + ); + let prompt = secret_prompt.lock().unwrap().take().unwrap(); + let cleared = secret_prompt_cleared.swap(false, Ordering::SeqCst); + if secret_step_on_submit(&mut last_secret_prompt, cleared, &prompt) { + events.push(prompt); + } + + assert_eq!(events.len(), 2); + } + + #[test] + fn secret_dedup_guard_must_be_cleared_by_prompt_leaving_screen() { + // Exercises the extracted helper directly: same prompt submitted twice + // with prompt_left_screen=true between them must produce two events. + // If the guard were capture-wide (prompt_left_screen ignored), the + // second call would return false and this test would fail. + let mut last: Option = None; + assert!(secret_step_on_submit(&mut last, false, "Password:")); + assert!(secret_step_on_submit(&mut last, true, "Password:")); + } + + #[test] + fn secret_dedup_immediate_redraw_after_enter_is_suppressed() { + let sensitive = AtomicBool::new(false); + let secret_prompt = Mutex::new(None); + let secret_prompt_cleared = AtomicBool::new(false); + let mut last_secret_prompt: Option = None; + let mut events = Vec::new(); + + let mut line = String::new(); + track_and_detect( + &mut line, + "Password:\n", + &sensitive, + &secret_prompt, + &secret_prompt_cleared, + ); + let prompt = secret_prompt.lock().unwrap().take().unwrap(); + let cleared = secret_prompt_cleared.swap(false, Ordering::SeqCst); + if secret_step_on_submit(&mut last_secret_prompt, cleared, &prompt) { + events.push(prompt); + } + sensitive.store(false, Ordering::SeqCst); + + let mut line = String::new(); + track_and_detect( + &mut line, + "Password:\n", + &sensitive, + &secret_prompt, + &secret_prompt_cleared, + ); + let prompt = secret_prompt.lock().unwrap().take().unwrap(); + let cleared = secret_prompt_cleared.swap(false, Ordering::SeqCst); + if secret_step_on_submit(&mut last_secret_prompt, cleared, &prompt) { + events.push(prompt); + } + + assert_eq!(events.len(), 1); + } + + #[test] + fn secret_prompt_cleared_not_set_on_partial_line() { + // A prompt split across PTY reads must not spuriously set the cleared + // flag: "[sudo] password for " at chunk end (no newline) is a partial + // line, not a completed non-secret line. + let sensitive = AtomicBool::new(false); + let secret_prompt = Mutex::new(None); + let secret_prompt_cleared = AtomicBool::new(false); + let mut line = String::new(); + track_and_detect( + &mut line, + "[sudo] password for ", + &sensitive, + &secret_prompt, + &secret_prompt_cleared, + ); + assert!( + !secret_prompt_cleared.load(Ordering::SeqCst), + "partial line at chunk end must not set secret_prompt_cleared" + ); + } } From c77701ccd1b670356e22298cc8fe216ef54fff9d Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Thu, 27 Aug 2026 01:15:48 -0500 Subject: [PATCH 2/6] fix(capture): arm --after on the current command and drain queued reveals at shutdown --- src/commands/capture.rs | 289 +++++++++++++++++++++++++++++++++++++--- src/export/browser.rs | 1 + src/export/composite.rs | 1 + src/export/pdf.rs | 1 + src/export/raster.rs | 2 + src/export/recording.rs | 2 +- 6 files changed, 278 insertions(+), 18 deletions(-) diff --git a/src/commands/capture.rs b/src/commands/capture.rs index 93e737d..bfe7643 100644 --- a/src/commands/capture.rs +++ b/src/commands/capture.rs @@ -688,8 +688,6 @@ struct RouteOutcome { /// A `demo open`/`demo stop`/`demo focus` was just entered — its echo and any /// wizard/confirmation must be excised from the recording. mute_command: bool, - /// A normal command was just entered — arm any pending `--after` reveal. - arm_after: bool, } /// Forward a keystroke chunk to the PTY and track the current command line so the @@ -704,7 +702,6 @@ fn route_input_chunk( ) -> RouteOutcome { let mut to_pty: Vec = Vec::with_capacity(chunk.len()); let mut mute_command = false; - let mut arm_after = false; let mut i = 0; let n = chunk.len(); // Mark the start of a fresh command line at its first printable char, so a @@ -721,8 +718,6 @@ fn route_input_chunk( let t = cmd_line.trim_start(); if is_meta_command(t) { mute_command = true; - } else if !t.is_empty() { - arm_after = true; } cmd_line.clear(); *cmd_start = None; @@ -761,7 +756,6 @@ fn route_input_chunk( RouteOutcome { to_pty, mute_command, - arm_after, } } @@ -912,8 +906,8 @@ pub fn run(args: CaptureArgs) -> Result<()> { // Browser reveals armed by `demo open --when `, fired by the output // thread when the pattern appears. let pending_opens: PendingWhen = Arc::new(Mutex::new(Vec::new())); - // Reveals armed by `demo open --after`: fired once the next foreground command - // produces output and then goes quiet (back at the prompt). `after_running` + // Reveals armed by `demo open --after`: fired when the current foreground command + // finishes (produces output and then goes quiet, back at the prompt). `after_running` // tracks that such a command is in flight; `after_last_out` its last output. let after_opens: PendingAfter = Arc::new(Mutex::new(Vec::new())); let after_running = Arc::new(AtomicBool::new(false)); @@ -1115,9 +1109,6 @@ pub fn run(args: CaptureArgs) -> Result<()> { let mute_since = mute_since.clone(); let mute_start = mute_start.clone(); let ready = ready.clone(); - let after_opens = after_opens.clone(); - let after_running = after_running.clone(); - let after_last_out = after_last_out.clone(); thread::spawn(move || { let mut buf = [0u8; 1024]; let mut stdin = std::io::stdin(); @@ -1148,12 +1139,6 @@ pub fn run(args: CaptureArgs) -> Result<()> { if s.is_none() { *s = Some(saved_cmd_start.unwrap_or_else(|| ms(t0))); } - } else if outcome.arm_after - && !after_opens.lock().unwrap().is_empty() - && !after_running.load(Ordering::SeqCst) - { - *after_last_out.lock().unwrap() = Instant::now(); - after_running.store(true, Ordering::SeqCst); } if !outcome.to_pty.is_empty() { if writer.write_all(&outcome.to_pty).is_err() { @@ -1226,6 +1211,8 @@ pub fn run(args: CaptureArgs) -> Result<()> { &events, &pending_opens, &after_opens, + &after_running, + &after_last_out, &muting, &mute_start, &mute_spans, @@ -1284,6 +1271,45 @@ pub fn run(args: CaptureArgs) -> Result<()> { let _ = std::fs::remove_file(control_abs.with_file_name(control::SOURCES_FILE)); let _ = std::fs::remove_file(control_abs.with_file_name(control::META_FILE)); + let drain_summary = { + let mut evs = events.lock().unwrap(); + let raw_for_cutoff = crate::model::RawMacro { + meta: crate::model::RawMeta { + shell: String::new(), + cols: 0, + rows: 0, + idle_timeout_ms: 0, + resolution: None, + fps: None, + stage: None, + mute_spans: Vec::new(), + }, + events: evs.clone(), + }; + let drain_ts = recording::stop_cutoff_ms(&raw_for_cutoff) + .map(|c| c.saturating_sub(1)) + .unwrap_or_else(|| { + evs.iter() + .filter_map(|e| match e { + RawEvent::Output { t_ms, .. } => Some(*t_ms), + _ => None, + }) + .max() + .unwrap_or(0) + }); + drain_remaining_reveals(&after_opens, &pending_opens, &mut evs, drain_ts) + }; + for pat in &drain_summary.when_unmatched { + eprintln!( + "warning: --when cue {pat:?} never matched during capture; reveal appended at end" + ); + } + for summary in &drain_summary.after_summaries { + eprintln!( + "warning: --after reveal {summary} never fired during capture; reveal appended at end" + ); + } + let events = events.lock().unwrap().clone(); let mut mute_spans = mute_spans.lock().unwrap().clone(); // Close a span still open at stop time (e.g. a wizard cut short by `demo stop`). @@ -1478,6 +1504,39 @@ fn cue_matches(recent: &str, pattern: &str) -> bool { } } +/// Summary of what the shutdown drain resolved from the pending queues. +struct DrainSummary { + after_summaries: Vec, + when_unmatched: Vec, +} + +/// Drain any remaining `--after` and `--when` queues at shutdown, emitting +/// their reveals as events so they are recorded rather than silently dropped. +/// Returns a summary of what was fired and which `--when` cues never matched. +fn drain_remaining_reveals( + after_opens: &PendingAfter, + pending_opens: &PendingWhen, + events: &mut Vec, + now: u64, +) -> DrainSummary { + let after_remaining: Vec = after_opens.lock().unwrap().drain(..).collect(); + let mut after_summaries = Vec::new(); + for r in after_remaining { + after_summaries.push(r.summary()); + events.push(r.to_event(now)); + } + let mut when_unmatched = Vec::new(); + let pending: Vec<(Reveal, String)> = pending_opens.lock().unwrap().drain(..).collect(); + for (r, pat) in pending { + when_unmatched.push(pat.clone()); + events.push(r.to_event(now)); + } + DrainSummary { + after_summaries, + when_unmatched, + } +} + /// Read any new control-file commands (`demo focus`/`demo open`/`demo stop`). /// Records immediate reveals, arms `--when`/`--after` reveals, and returns /// `Some(reason)` on stop. @@ -1488,6 +1547,8 @@ fn read_control( events: &Arc>>, pending: &PendingWhen, after: &PendingAfter, + after_running: &AtomicBool, + after_last_out: &Mutex, muting: &Arc, mute_start: &Arc>>, mute_spans: &Arc>>, @@ -1546,6 +1607,8 @@ fn read_control( d.note(&format!("reveal armed: {} after command", reveal.summary())); } after.lock().unwrap().push(reveal); + after_running.store(true, Ordering::SeqCst); + *after_last_out.lock().unwrap() = Instant::now(); } else { // Immediate reveal: use current time (when command finished) if let Some(d) = debug { @@ -3090,4 +3153,196 @@ mod tests { "partial line at chunk end must not set secret_prompt_cleared" ); } + + fn test_reveal() -> Reveal { + Reveal { + panes: vec![RevealPane { + id: "main".into(), + url: Some("http://example.com".into()), + theme: None, + }], + orientation: Orientation::Horizontal, + hold_ms: None, + scroll: false, + } + } + + #[test] + fn read_control_arms_after_running_when_queueing_after_reveal() { + let cpath = std::env::temp_dir().join(format!("demo-test-control-{}", std::process::id())); + let cmd = serde_json::json!({ + "cmd": "reveal", + "after": true, + "panes": [{"id": "main", "url": "http://example.com"}], + }); + std::fs::write(&cpath, serde_json::to_string(&cmd).unwrap()).unwrap(); + + let events: Arc>> = Arc::new(Mutex::new(Vec::new())); + let pending: PendingWhen = Arc::new(Mutex::new(Vec::new())); + let after: PendingAfter = Arc::new(Mutex::new(Vec::new())); + let after_running = AtomicBool::new(false); + let after_last_out = Mutex::new(Instant::now()); + let muting = Arc::new(AtomicBool::new(false)); + let mute_start: Arc>> = Arc::new(Mutex::new(None)); + let mute_spans: Arc>> = Arc::new(Mutex::new(Vec::new())); + let t0 = Instant::now(); + let mut read = 0u64; + + let result = read_control( + &cpath, + &mut read, + &events, + &pending, + &after, + &after_running, + &after_last_out, + &muting, + &mute_start, + &mute_spans, + t0, + None, + ); + + assert!(result.is_none()); + assert!( + after_running.load(Ordering::SeqCst), + "--after must arm after_running immediately so the current command is tracked" + ); + assert_eq!(after.lock().unwrap().len(), 1); + assert!(events.lock().unwrap().is_empty(), "no immediate reveal"); + let _ = std::fs::remove_file(&cpath); + } + + #[test] + fn drain_remaining_reveals_emits_after_queue_as_events() { + let after: PendingAfter = Arc::new(Mutex::new(vec![test_reveal()])); + let pending: PendingWhen = Arc::new(Mutex::new(Vec::new())); + let mut events = Vec::new(); + + let summary = drain_remaining_reveals(&after, &pending, &mut events, 9999); + + assert_eq!(summary.after_summaries.len(), 1); + assert!(summary.when_unmatched.is_empty()); + assert_eq!(events.len(), 1); + assert!(after.lock().unwrap().is_empty()); + match &events[0] { + RawEvent::Reveal { t_ms, .. } => assert_eq!(*t_ms, 9999), + other => panic!("expected Reveal, got {other:?}"), + } + } + + #[test] + fn drain_remaining_reveals_reports_unmatched_when_cues() { + let after: PendingAfter = Arc::new(Mutex::new(Vec::new())); + let pending: PendingWhen = Arc::new(Mutex::new(vec![( + test_reveal(), + "never-gonna-appear".into(), + )])); + let mut events = Vec::new(); + + let summary = drain_remaining_reveals(&after, &pending, &mut events, 5000); + + assert_eq!(summary.after_summaries.len(), 0); + assert_eq!( + summary.when_unmatched, + vec!["never-gonna-appear".to_string()] + ); + assert_eq!(events.len(), 1); + assert!(pending.lock().unwrap().is_empty()); + } + + #[test] + fn drain_remaining_reveals_handles_both_queues_at_once() { + let after: PendingAfter = Arc::new(Mutex::new(vec![test_reveal()])); + let pending: PendingWhen = Arc::new(Mutex::new(vec![ + (test_reveal(), "cue-alpha".into()), + (test_reveal(), "re:cue-beta".into()), + ])); + let mut events = Vec::new(); + + let summary = drain_remaining_reveals(&after, &pending, &mut events, 1000); + + assert_eq!(summary.after_summaries.len(), 1); + assert_eq!(summary.when_unmatched.len(), 2); + assert_eq!(events.len(), 3); + assert!(after.lock().unwrap().is_empty()); + assert!(pending.lock().unwrap().is_empty()); + } + + #[test] + fn drained_reveal_survives_from_raw_after_demo_stop() { + let mut events = vec![ + RawEvent::Output { + t_ms: 100, + data: "real output".into(), + }, + RawEvent::Input { + t_ms: 2000, + bytes: "demo stop\r".into(), + }, + RawEvent::Output { + t_ms: 2010, + data: "demo stop".into(), + }, + ]; + let after: PendingAfter = Arc::new(Mutex::new(vec![test_reveal()])); + let pending: PendingWhen = + Arc::new(Mutex::new(vec![(test_reveal(), "never-matched".into())])); + let raw_for_cutoff = crate::model::RawMacro { + meta: crate::model::RawMeta { + shell: String::new(), + cols: 0, + rows: 0, + idle_timeout_ms: 0, + resolution: None, + fps: None, + stage: None, + mute_spans: Vec::new(), + }, + events: events.clone(), + }; + let drain_ts = recording::stop_cutoff_ms(&raw_for_cutoff) + .map(|c| c.saturating_sub(1)) + .unwrap_or_else(|| { + events + .iter() + .filter_map(|e| match e { + RawEvent::Output { t_ms, .. } => Some(*t_ms), + _ => None, + }) + .max() + .unwrap_or(0) + }); + drain_remaining_reveals(&after, &pending, &mut events, drain_ts); + let raw = crate::model::RawMacro { + meta: crate::model::RawMeta { + shell: "/bin/bash".into(), + cols: 80, + rows: 24, + idle_timeout_ms: 0, + resolution: None, + fps: None, + stage: None, + mute_spans: Vec::new(), + }, + events, + }; + let (rec, layout, _) = recording::from_raw(&raw, "t"); + assert!( + rec.events.iter().any(|(_, data)| data == "real output"), + "real output must survive" + ); + assert!( + !layout.panes.is_empty(), + "drained reveal must survive normalization" + ); + let has_browser = layout + .panes + .iter() + .any(|p| p.kind == crate::model::PaneKind::Browser); + assert!( + has_browser, + "drained --after reveal must produce a browser pane" + ); + } } diff --git a/src/export/browser.rs b/src/export/browser.rs index de22652..8a63859 100644 --- a/src/export/browser.rs +++ b/src/export/browser.rs @@ -523,6 +523,7 @@ mod tests { } #[test] + #[allow(clippy::chunks_exact_to_as_chunks)] fn png_to_rgba_crops_to_target_size() { // Create a 4x4 RGB PNG let mut buf = std::io::Cursor::new(Vec::new()); diff --git a/src/export/composite.rs b/src/export/composite.rs index cb3b7f5..816446c 100644 --- a/src/export/composite.rs +++ b/src/export/composite.rs @@ -16,6 +16,7 @@ pub struct Layer<'a> { /// Composite `layers` onto a `canvas_w`×`canvas_h` canvas filled with `bg`. /// Layers are drawn in order (later layers on top) and clipped to the canvas. +#[allow(clippy::chunks_exact_to_as_chunks)] pub fn composite(canvas_w: usize, canvas_h: usize, bg: [u8; 3], layers: &[Layer]) -> Vec { let mut img = vec![0u8; canvas_w * canvas_h * 4]; for px in img.chunks_exact_mut(4) { diff --git a/src/export/pdf.rs b/src/export/pdf.rs index 4f54b37..6993df5 100644 --- a/src/export/pdf.rs +++ b/src/export/pdf.rs @@ -22,6 +22,7 @@ const PAGE_FRAC: f64 = 0.90; /// Capture a PDF as a browser-pane [`Scene`]: keyframe 0 shows the top of the /// document, and `scroll_keyframes` more pan evenly down to the last page. +#[allow(clippy::chunks_exact_to_as_chunks)] pub fn capture_scene( pdf_path: &Path, pane_w: usize, diff --git a/src/export/raster.rs b/src/export/raster.rs index 60ad309..3a089b3 100644 --- a/src/export/raster.rs +++ b/src/export/raster.rs @@ -1267,6 +1267,7 @@ fps = 0 } #[test] + #[allow(clippy::chunks_exact_to_as_chunks)] fn blit_glyph_clips_at_top() { let mut img_clipped = vec![0u8; 20 * 20 * 4]; let mut img_full = vec![0u8; 20 * 20 * 4]; @@ -1368,6 +1369,7 @@ fps = 0 // ── render_cells ─────────────────────────────────────────────── #[test] + #[allow(clippy::chunks_exact_to_as_chunks)] fn render_cells_empty_screen() { let rec = Recording { cols: 4, diff --git a/src/export/recording.rs b/src/export/recording.rs index a622190..0dd851e 100644 --- a/src/export/recording.rs +++ b/src/export/recording.rs @@ -495,7 +495,7 @@ fn browser_pane( /// The `t_ms` at which the user started typing the final `demo stop` line, if the /// capture ended that way — so its echo (and the "stopping" message) is dropped. -fn stop_cutoff_ms(raw: &RawMacro) -> Option { +pub fn stop_cutoff_ms(raw: &RawMacro) -> Option { let mut line = String::new(); let mut line_start: Option = None; let mut cutoff: Option = None; From 30f487a32033d9bf8493f85ad7f7ca4b2ee55e9c Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Thu, 27 Aug 2026 01:31:33 -0500 Subject: [PATCH 3/6] fix(capture): close the mute span when demo focus/open fails instead of stranding it for 90s --- src/commands/capture.rs | 288 ++++++++++++++++++++++++++++++++++++++-- src/commands/focus.rs | 21 ++- src/commands/open.rs | 9 ++ 3 files changed, 306 insertions(+), 12 deletions(-) diff --git a/src/commands/capture.rs b/src/commands/capture.rs index bfe7643..cddaeb0 100644 --- a/src/commands/capture.rs +++ b/src/commands/capture.rs @@ -79,6 +79,35 @@ fn ms(t0: Instant) -> u64 { t0.elapsed().as_millis() as u64 } +/// If muting has been on for more than 90 seconds, close the stranded mute span, +/// emit a diagnostic, and return true. Otherwise return false. +fn maybe_close_safety_valve( + muting: &AtomicBool, + mute_since: &Mutex, + mute_start: &Mutex>, + mute_spans: &Mutex>, + t0: Instant, + debug: Option<&DebugLog>, +) -> bool { + if !muting.load(Ordering::SeqCst) { + return false; + } + if mute_since.lock().unwrap().elapsed() <= Duration::from_secs(90) { + return false; + } + muting.store(false, Ordering::SeqCst); + if let Some(start) = mute_start.lock().unwrap().take() { + mute_spans.lock().unwrap().push((start, ms(t0))); + } + eprintln!( + "⚠ safety valve: mute span closed after 90s — a meta-command (demo focus/open) failed to report back" + ); + if let Some(d) = debug { + d.note("safety valve: 90s mute span closed — meta-command did not report back"); + } + true +} + /// Track the current terminal line and detect a secret prompt at each line /// boundary (`\r` redraw or `\n`) — crucially BEFORE the boundary clears the line. /// `inquire` emits the prompt immediately followed by `\r` (`Vault passphrase:\r…`), @@ -1239,15 +1268,14 @@ pub fn run(args: CaptureArgs) -> Result<()> { } // Safety: an abandoned `demo open` (wizard cancelled, command never sent) // shouldn't mute the rest of the demo forever. - if muting.load(Ordering::SeqCst) - && mute_since.lock().unwrap().elapsed() > Duration::from_secs(90) - { - muting.store(false, Ordering::SeqCst); - // Close a stranded open span so it doesn't swallow the rest of the demo. - if let Some(start) = mute_start.lock().unwrap().take() { - mute_spans.lock().unwrap().push((start, ms(t0))); - } - } + maybe_close_safety_valve( + &muting, + &mute_since, + &mute_start, + &mute_spans, + t0, + debug.as_deref(), + ); // Safety: if the readiness marker never arrives (odd shell), start // recording anyway rather than capturing nothing. if !ready.load(Ordering::SeqCst) && t0.elapsed() > Duration::from_secs(4) { @@ -1582,6 +1610,18 @@ fn read_control( muting.store(false, Ordering::SeqCst); stop = Some("demo stop"); } + Some("reveal_cancel") => { + // A meta-command failed/was cancelled → close the mute span + // without recording a reveal (the command leaves no trace). + muting.store(false, Ordering::SeqCst); + let mute_span_start = mute_start.lock().unwrap().take(); + if let Some(start) = mute_span_start { + mute_spans.lock().unwrap().push((start, ms(t0))); + } + if let Some(d) = debug { + d.note("reveal_cancel — meta-command failed or was cancelled"); + } + } Some("reveal") => { // The command finished → stop muting and close its excision span. muting.store(false, Ordering::SeqCst); @@ -3345,4 +3385,234 @@ mod tests { "drained --after reveal must produce a browser pane" ); } + + #[test] + fn reveal_cancel_closes_mute_span_without_recording_reveal() { + let cpath = std::env::temp_dir().join(format!("demo-test-cancel-{}", std::process::id())); + let cmd = serde_json::json!({ "cmd": "reveal_cancel" }); + std::fs::write(&cpath, serde_json::to_string(&cmd).unwrap()).unwrap(); + + let events: Arc>> = Arc::new(Mutex::new(Vec::new())); + let pending: PendingWhen = Arc::new(Mutex::new(Vec::new())); + let after: PendingAfter = Arc::new(Mutex::new(Vec::new())); + let after_running = AtomicBool::new(false); + let after_last_out = Mutex::new(Instant::now()); + let muting = Arc::new(AtomicBool::new(true)); + let mute_start: Arc>> = Arc::new(Mutex::new(Some(1000))); + let mute_spans: Arc>> = Arc::new(Mutex::new(Vec::new())); + let t0 = Instant::now(); + let mut read = 0u64; + + let result = read_control( + &cpath, + &mut read, + &events, + &pending, + &after, + &after_running, + &after_last_out, + &muting, + &mute_start, + &mute_spans, + t0, + None, + ); + + assert!(result.is_none()); + assert!( + !muting.load(Ordering::SeqCst), + "reveal_cancel must stop muting" + ); + assert!( + mute_start.lock().unwrap().is_none(), + "reveal_cancel must take the mute_start" + ); + assert_eq!( + mute_spans.lock().unwrap().len(), + 1, + "reveal_cancel must close the span" + ); + assert!( + events.lock().unwrap().is_empty(), + "reveal_cancel must not record a reveal event" + ); + let _ = std::fs::remove_file(&cpath); + } + + #[test] + fn reveal_closes_mute_span_and_records_event() { + let cpath = + std::env::temp_dir().join(format!("demo-test-reveal-close-{}", std::process::id())); + let cmd = serde_json::json!({ + "cmd": "reveal", + "panes": [{"id": "main", "url": "http://example.com"}], + }); + std::fs::write(&cpath, serde_json::to_string(&cmd).unwrap()).unwrap(); + + let events: Arc>> = Arc::new(Mutex::new(Vec::new())); + let pending: PendingWhen = Arc::new(Mutex::new(Vec::new())); + let after: PendingAfter = Arc::new(Mutex::new(Vec::new())); + let after_running = AtomicBool::new(false); + let after_last_out = Mutex::new(Instant::now()); + let muting = Arc::new(AtomicBool::new(true)); + let mute_start: Arc>> = Arc::new(Mutex::new(Some(1000))); + let mute_spans: Arc>> = Arc::new(Mutex::new(Vec::new())); + let t0 = Instant::now(); + let mut read = 0u64; + + let result = read_control( + &cpath, + &mut read, + &events, + &pending, + &after, + &after_running, + &after_last_out, + &muting, + &mute_start, + &mute_spans, + t0, + None, + ); + + assert!(result.is_none()); + assert!(!muting.load(Ordering::SeqCst), "reveal must stop muting"); + assert!( + mute_start.lock().unwrap().is_none(), + "reveal must take the mute_start" + ); + assert_eq!( + mute_spans.lock().unwrap().len(), + 1, + "reveal must close the span" + ); + assert_eq!( + events.lock().unwrap().len(), + 1, + "reveal must record a reveal event" + ); + let _ = std::fs::remove_file(&cpath); + } + + #[test] + fn double_close_is_safe() { + let cpath = + std::env::temp_dir().join(format!("demo-test-double-close-{}", std::process::id())); + // First cancel, then reveal — both try to close the same span. + let cmds = format!( + "{}\n{}", + serde_json::to_string(&serde_json::json!({ "cmd": "reveal_cancel" })).unwrap(), + serde_json::to_string(&serde_json::json!({ + "cmd": "reveal", + "panes": [{"id": "main", "url": "http://example.com"}], + })) + .unwrap() + ); + std::fs::write(&cpath, cmds).unwrap(); + + let events: Arc>> = Arc::new(Mutex::new(Vec::new())); + let pending: PendingWhen = Arc::new(Mutex::new(Vec::new())); + let after: PendingAfter = Arc::new(Mutex::new(Vec::new())); + let after_running = AtomicBool::new(false); + let after_last_out = Mutex::new(Instant::now()); + let muting = Arc::new(AtomicBool::new(true)); + let mute_start: Arc>> = Arc::new(Mutex::new(Some(1000))); + let mute_spans: Arc>> = Arc::new(Mutex::new(Vec::new())); + let t0 = Instant::now(); + let mut read = 0u64; + + let result = read_control( + &cpath, + &mut read, + &events, + &pending, + &after, + &after_running, + &after_last_out, + &muting, + &mute_start, + &mute_spans, + t0, + None, + ); + + assert!(result.is_none()); + assert_eq!( + mute_spans.lock().unwrap().len(), + 1, + "only the first close must record a span (second finds mute_start empty)" + ); + assert_eq!( + events.lock().unwrap().len(), + 1, + "reveal still records its event even after cancel closed the span" + ); + let _ = std::fs::remove_file(&cpath); + } + + #[test] + fn safety_valve_closes_span_and_emits_diagnostic() { + let t0 = Instant::now(); + let muting = AtomicBool::new(true); + // Pretend muting started 91 seconds ago. + let mute_since = Mutex::new(t0 - Duration::from_secs(91)); + let mute_start: Mutex> = Mutex::new(Some(500)); + let mute_spans: Mutex> = Mutex::new(Vec::new()); + + let log_path = std::env::temp_dir().join(format!("demo-test-valve-{}", std::process::id())); + let debug = DebugLog::create(&log_path, t0).unwrap(); + + let fired = maybe_close_safety_valve( + &muting, + &mute_since, + &mute_start, + &mute_spans, + t0, + Some(&debug), + ); + + assert!(fired, "safety valve must report that it fired"); + assert!( + !muting.load(Ordering::SeqCst), + "safety valve must stop muting" + ); + assert!( + mute_start.lock().unwrap().is_none(), + "safety valve must take the mute_start" + ); + assert_eq!( + mute_spans.lock().unwrap().len(), + 1, + "safety valve must close the span into mute_spans" + ); + let log_contents = std::fs::read_to_string(&log_path).unwrap(); + assert!( + log_contents.contains("safety valve:"), + "safety valve must write its diagnostic to the debug log, got: {log_contents:?}" + ); + let _ = std::fs::remove_file(&log_path); + } + + #[test] + fn safety_valve_does_not_fire_before_90s() { + let t0 = Instant::now(); + let muting = AtomicBool::new(true); + let mute_since = Mutex::new(t0 - Duration::from_secs(60)); + let mute_start: Mutex> = Mutex::new(Some(500)); + let mute_spans: Mutex> = Mutex::new(Vec::new()); + + let fired = + maybe_close_safety_valve(&muting, &mute_since, &mute_start, &mute_spans, t0, None); + + assert!(!fired, "safety valve must not fire before 90s"); + assert!( + muting.load(Ordering::SeqCst), + "muting must remain on when valve hasn't fired" + ); + assert_eq!( + mute_spans.lock().unwrap().len(), + 0, + "no span must be closed when valve hasn't fired" + ); + } } diff --git a/src/commands/focus.rs b/src/commands/focus.rs index 1c072d5..8f8efd3 100644 --- a/src/commands/focus.rs +++ b/src/commands/focus.rs @@ -37,6 +37,21 @@ pub fn run(args: FocusArgs) -> Result<()> { control::find()?; let sources = control::read_sources(); + // Whether we're running inside the captured shell — if so, the capture's + // input thread opened a mute span when it saw `demo focus` typed, and we + // must close it on every exit path (success or failure). + let in_session = in_session(); + + let result = run_inner(args, &sources, in_session); + if result.is_err() && in_session { + // Close the mute span that the input thread opened when it saw the + // command typed, so a failure doesn't leave 90s of black. + let _ = control::send(serde_json::json!({ "cmd": "reveal_cancel" })); + } + result +} + +fn run_inner(args: FocusArgs, sources: &[Source], in_session: bool) -> Result<()> { let wizard_out = if args.sources.is_empty() { if !std::io::stdin().is_terminal() { return Err(Error::Export( @@ -44,7 +59,7 @@ pub fn run(args: FocusArgs) -> Result<()> { .to_string(), )); } - Some(wizard(&sources, &args)?) + Some(wizard(sources, &args)?) } else { None }; @@ -76,11 +91,11 @@ pub fn run(args: FocusArgs) -> Result<()> { } // Resolve each id to a reveal pane (terminal, or a browser source's URL). - let panes = build_panes(&chosen, split_with_main, &sources, args.theme.as_deref())?; + let panes = build_panes(&chosen, split_with_main, sources, args.theme.as_deref())?; // In-session, mute this command's echo/wizard from now (from another terminal // there's nothing in the captured shell to mute). - if in_session() { + if in_session { let _ = control::send(serde_json::json!({ "cmd": "reveal_begin" })); } diff --git a/src/commands/open.rs b/src/commands/open.rs index a4341b5..ff176a0 100644 --- a/src/commands/open.rs +++ b/src/commands/open.rs @@ -58,6 +58,15 @@ pub fn run(args: OpenArgs) -> Result<()> { let _ = control::send(serde_json::json!({ "cmd": "reveal_begin" })); } + let result = run_inner(args, in_session); + if result.is_err() && in_session { + // Close the mute span so a failure doesn't leave 90s of black. + let _ = control::send(serde_json::json!({ "cmd": "reveal_cancel" })); + } + result +} + +fn run_inner(args: OpenArgs, in_session: bool) -> Result<()> { let r = resolve(args, in_session)?; if r.view { From 77518146e22f106b72aa13d8979fda972798b180 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Thu, 27 Aug 2026 01:44:11 -0500 Subject: [PATCH 4/6] fix(capture): final read_control drain so a reveal in the last 100ms lands --- src/commands/capture.rs | 297 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 297 insertions(+) diff --git a/src/commands/capture.rs b/src/commands/capture.rs index cddaeb0..c249e28 100644 --- a/src/commands/capture.rs +++ b/src/commands/capture.rs @@ -1286,6 +1286,23 @@ pub fn run(args: CaptureArgs) -> Result<()> { } thread::sleep(Duration::from_millis(100)); }; + // Final drain: the watchdog checks exit conditions *before* read_control, + // so a control line written in the last ≤100 ms before shell exit would + // otherwise be lost. One extra read picks it up without waiting. + let _ = read_control( + &control_abs, + &mut control_read, + &events, + &pending_opens, + &after_opens, + &after_running, + &after_last_out, + &muting, + &mute_start, + &mute_spans, + t0, + debug.as_deref(), + ); if let Some(d) = &debug { d.note(&format!("stopping — reason: {reason}")); } @@ -3615,4 +3632,284 @@ mod tests { "no span must be closed when valve hasn't fired" ); } + + #[allow(clippy::type_complexity)] + fn make_read_control_fixtures() -> ( + std::path::PathBuf, + Arc>>, + PendingWhen, + PendingAfter, + AtomicBool, + Mutex, + Arc, + Arc>>, + Arc>>, + Instant, + ) { + let cpath = + std::env::temp_dir().join(format!("demo-test-final-drain-{}", std::process::id())); + let _ = std::fs::remove_file(&cpath); + let events: Arc>> = Arc::new(Mutex::new(Vec::new())); + let pending: PendingWhen = Arc::new(Mutex::new(Vec::new())); + let after: PendingAfter = Arc::new(Mutex::new(Vec::new())); + let after_running = AtomicBool::new(false); + let after_last_out = Mutex::new(Instant::now()); + let muting = Arc::new(AtomicBool::new(false)); + let mute_start: Arc>> = Arc::new(Mutex::new(None)); + let mute_spans: Arc>> = Arc::new(Mutex::new(Vec::new())); + let t0 = Instant::now(); + ( + cpath, + events, + pending, + after, + after_running, + after_last_out, + muting, + mute_start, + mute_spans, + t0, + ) + } + + #[test] + fn final_drain_picks_up_control_line_appended_after_last_watchdog_read() { + let ( + cpath, + events, + pending, + after, + after_running, + after_last_out, + muting, + mute_start, + mute_spans, + t0, + ) = make_read_control_fixtures(); + let mut read = 0u64; + + std::fs::write(&cpath, "").unwrap(); + let _ = read_control( + &cpath, + &mut read, + &events, + &pending, + &after, + &after_running, + &after_last_out, + &muting, + &mute_start, + &mute_spans, + t0, + None, + ); + assert_eq!(read, 0); + + let reveal = serde_json::to_string(&serde_json::json!({ + "cmd": "reveal", + "panes": [{"id": "main", "url": "http://example.com"}], + })) + .unwrap(); + std::fs::write(&cpath, format!("{reveal}\n")).unwrap(); + + let _ = read_control( + &cpath, + &mut read, + &events, + &pending, + &after, + &after_running, + &after_last_out, + &muting, + &mute_start, + &mute_spans, + t0, + None, + ); + + assert_eq!( + events.lock().unwrap().len(), + 1, + "final drain must process a reveal appended after the last watchdog pass" + ); + let _ = std::fs::remove_file(&cpath); + } + + #[test] + fn final_drain_on_empty_or_fully_consumed_file_is_noop() { + let ( + cpath, + events, + pending, + after, + after_running, + after_last_out, + muting, + mute_start, + mute_spans, + t0, + ) = make_read_control_fixtures(); + let mut read = 0u64; + + std::fs::write(&cpath, "").unwrap(); + let result = read_control( + &cpath, + &mut read, + &events, + &pending, + &after, + &after_running, + &after_last_out, + &muting, + &mute_start, + &mute_spans, + t0, + None, + ); + assert!(result.is_none()); + assert_eq!(read, 0); + assert!(events.lock().unwrap().is_empty()); + + let reveal = serde_json::to_string(&serde_json::json!({ + "cmd": "reveal", + "panes": [{"id": "main", "url": "http://example.com"}], + })) + .unwrap(); + std::fs::write(&cpath, format!("{reveal}\n")).unwrap(); + let _ = read_control( + &cpath, + &mut read, + &events, + &pending, + &after, + &after_running, + &after_last_out, + &muting, + &mute_start, + &mute_spans, + t0, + None, + ); + assert_eq!(events.lock().unwrap().len(), 1); + let offset_after_first = read; + + let result = read_control( + &cpath, + &mut read, + &events, + &pending, + &after, + &after_running, + &after_last_out, + &muting, + &mute_start, + &mute_spans, + t0, + None, + ); + assert!(result.is_none()); + assert_eq!( + read, offset_after_first, + "offset must not change on empty re-read" + ); + assert_eq!(events.lock().unwrap().len(), 1, "no duplicate events"); + let _ = std::fs::remove_file(&cpath); + } + + #[test] + fn byte_offset_never_rewinds_and_torn_line_is_not_double_consumed() { + let ( + cpath, + events, + pending, + after, + after_running, + after_last_out, + muting, + mute_start, + mute_spans, + t0, + ) = make_read_control_fixtures(); + let mut read = 0u64; + + std::fs::write(&cpath, "tea").unwrap(); + let _ = read_control( + &cpath, + &mut read, + &events, + &pending, + &after, + &after_running, + &after_last_out, + &muting, + &mute_start, + &mute_spans, + t0, + None, + ); + let offset_after_partial = read; + assert_eq!( + offset_after_partial, 3, + "offset must advance past the partial bytes" + ); + + std::fs::write(&cpath, "tear\n").unwrap(); + let offset_before = read; + let _ = read_control( + &cpath, + &mut read, + &events, + &pending, + &after, + &after_running, + &after_last_out, + &muting, + &mute_start, + &mute_spans, + t0, + None, + ); + assert!( + read >= offset_before, + "offset must never rewind (was {offset_before}, now {read})" + ); + assert!( + events.lock().unwrap().is_empty(), + "torn partial must not produce a phantom event" + ); + + let reveal = serde_json::to_string(&serde_json::json!({ + "cmd": "reveal", + "panes": [{"id": "main", "url": "http://example.com"}], + })) + .unwrap(); + let mut file = std::fs::OpenOptions::new() + .append(true) + .open(&cpath) + .unwrap(); + use std::io::Write; + writeln!(file, "{reveal}").unwrap(); + drop(file); + + let _ = read_control( + &cpath, + &mut read, + &events, + &pending, + &after, + &after_running, + &after_last_out, + &muting, + &mute_start, + &mute_spans, + t0, + None, + ); + assert_eq!( + events.lock().unwrap().len(), + 1, + "reveal appended after the torn line must be processed exactly once" + ); + let _ = std::fs::remove_file(&cpath); + } } From 34e71d8669489fc32a4ab6cf696c7f0e32d6f5fc Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Thu, 27 Aug 2026 01:57:05 -0500 Subject: [PATCH 5/6] fix(export): scale terminal font_size on --resolution and report the true original size --- src/commands/export.rs | 120 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 116 insertions(+), 4 deletions(-) diff --git a/src/commands/export.rs b/src/commands/export.rs index 66e6c8a..56a9c4c 100644 --- a/src/commands/export.rs +++ b/src/commands/export.rs @@ -36,11 +36,9 @@ pub fn run(args: ExportArgs) -> Result<()> { if let Some((new_w, new_h)) = resolve_export_resolution(&args, score.layout.width, score.layout.height)? { + let (old_w, old_h) = (score.layout.width, score.layout.height); rescale_layout(&mut score, new_w, new_h); - eprintln!( - "note: overriding resolution to {}x{} (capture was {}x{})", - new_w, new_h, score.layout.width, score.layout.height - ); + eprintln!("{}", resolution_override_note(new_w, new_h, old_w, old_h)); } if faithful { @@ -154,6 +152,7 @@ fn rescale_layout(score: &mut Score, new_w: u32, new_h: u32) { } let scale_x = new_w as f64 / old_w as f64; let scale_y = new_h as f64 / old_h as f64; + let font_scale = scale_x.min(scale_y); score.layout.width = new_w; score.layout.height = new_h; @@ -163,9 +162,21 @@ fn rescale_layout(score: &mut Score, new_w: u32, new_h: u32) { pane.y = (pane.y as f64 * scale_y).round() as u32; pane.width = (pane.width as f64 * scale_x).round() as u32; pane.height = (pane.height as f64 * scale_y).round() as u32; + if pane.kind == crate::model::PaneKind::Terminal { + if let Some(ref mut fs) = pane.font_size { + *fs = ((*fs as f64 * font_scale).round() as u32).max(1); + } + } } } +fn resolution_override_note(new_w: u32, new_h: u32, old_w: u32, old_h: u32) -> String { + format!( + "note: overriding resolution to {}x{} (capture was {}x{})", + new_w, new_h, old_w, old_h + ) +} + #[cfg(test)] mod tests { use super::*; @@ -320,6 +331,107 @@ mod tests { assert_eq!(score.layout.panes[0].height, 100); } + #[test] + fn rescale_layout_scales_terminal_font_size_by_half() { + use crate::export::run::Recording; + let mut score: Score = toml::from_str( + r#" +[demo] +name = "t" +[layout] +width = 800 +height = 480 + [[layout.panes]] + id = "c" + type = "terminal" + x = 0 + y = 0 + width = 800 + height = 480 + font_size = 20 +"#, + ) + .unwrap(); + let rec = Recording { + cols: 80, + rows: 24, + title: "t".into(), + events: vec![], + captions: vec![], + focuses: vec![], + duration: 0.0, + }; + let plan_before = crate::export::raster::plan(&rec, &score); + rescale_layout(&mut score, 400, 240); + let plan_after = crate::export::raster::plan(&rec, &score); + assert_eq!(plan_after.width, plan_before.width / 2); + assert_eq!(plan_after.height, plan_before.height / 2); + } + + #[test] + fn rescale_layout_non_square_picks_smaller_font_factor() { + let mut score: Score = toml::from_str( + r#" +[demo] +name = "t" +[layout] +width = 100 +height = 100 + [[layout.panes]] + id = "c" + type = "terminal" + x = 0 + y = 0 + width = 100 + height = 100 + font_size = 20 +"#, + ) + .unwrap(); + rescale_layout(&mut score, 50, 200); + assert_eq!(score.layout.panes[0].font_size, Some(10)); + } + + #[test] + fn rescale_layout_font_size_floor_is_one() { + let mut score: Score = toml::from_str( + r#" +[demo] +name = "t" +[layout] +width = 100 +height = 100 + [[layout.panes]] + id = "c" + type = "terminal" + x = 0 + y = 0 + width = 100 + height = 100 + font_size = 2 +"#, + ) + .unwrap(); + rescale_layout(&mut score, 10, 10); + assert_eq!(score.layout.panes[0].font_size, Some(1)); + } + + #[test] + fn rescale_layout_does_not_scale_browser_font_size() { + let mut score = test_score(100, 100); + score.layout.panes[1].font_size = Some(20); + rescale_layout(&mut score, 200, 200); + assert_eq!(score.layout.panes[1].font_size, Some(20)); + } + + #[test] + fn resolution_override_note_names_real_original_size() { + let note = resolution_override_note(1280, 720, 800, 480); + assert!(note.contains("capture was 800x480")); + assert!(!note.contains("capture was 1280x720")); + assert!(note.contains("overriding resolution to 1280x720")); + } + #[test] fn resolve_export_resolution_from_resolution() { let args = ExportArgs { From 849474a0eb03ecea122adab8d40ae971cf989759 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Thu, 27 Aug 2026 13:55:26 -0500 Subject: [PATCH 6/6] test(capture): key the control fixture per call, not per process --- src/commands/capture.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/commands/capture.rs b/src/commands/capture.rs index c249e28..51475b8 100644 --- a/src/commands/capture.rs +++ b/src/commands/capture.rs @@ -3646,8 +3646,18 @@ mod tests { Arc>>, Instant, ) { - let cpath = - std::env::temp_dir().join(format!("demo-test-final-drain-{}", std::process::id())); + // Unique per CALL, not per process. `std::process::id()` alone is enough + // under nextest, which gives every test its own process — and useless + // under `cargo test`, which runs them as threads in one process, so two + // tests sharing this fixture raced over the same file. CI runs + // `cargo test`; a pid-keyed temp path is a test that only passes on the + // runner that isolates it for you. + static FIXTURE_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = FIXTURE_SEQ.fetch_add(1, Ordering::SeqCst); + let cpath = std::env::temp_dir().join(format!( + "demo-test-final-drain-{}-{seq}", + std::process::id() + )); let _ = std::fs::remove_file(&cpath); let events: Arc>> = Arc::new(Mutex::new(Vec::new())); let pending: PendingWhen = Arc::new(Mutex::new(Vec::new()));