diff --git a/frontend/src/components/LogViewer.tsx b/frontend/src/components/LogViewer.tsx index eec0626..00ebef1 100644 --- a/frontend/src/components/LogViewer.tsx +++ b/frontend/src/components/LogViewer.tsx @@ -499,7 +499,7 @@ const LogViewer: React.FC = ({ logs, onClear, onExport, isConnec const sentCount = logs.filter(log => log.direction === 'Sent').length; const receivedCount = logs.filter(log => log.direction === 'Received').length; - const totalBytes = logs.reduce((acc, log) => acc + log.data.length, 0); + const totalBytes = logs.reduce((acc, log) => acc + (log.byte_len ?? log.data.length), 0); return (
diff --git a/frontend/src/hooks/useSerialLogs.ts b/frontend/src/hooks/useSerialLogs.ts index 4c35f71..123ac99 100644 --- a/frontend/src/hooks/useSerialLogs.ts +++ b/frontend/src/hooks/useSerialLogs.ts @@ -135,6 +135,7 @@ export function useSerialLogs(enabled: boolean) { timestamp_formatted: f.timestamp_formatted ?? undefined, seq: f.seq, session: f.session, + byte_len: f.len, }); lastSeqRef.current = f.seq; } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index f55bce5..82da244 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -49,6 +49,9 @@ export interface LogEntry { seq?: number; /** Owning session id */ session?: number; + /** Byte length carried by event-sourced entries (whose `data` is empty by + * design, RFC #3 Step 4); undefined for snapshot entries with real data */ + byte_len?: number; /** Frontend-only marker for synthesized "frames dropped" placeholder rows */ gap_key?: string; } diff --git a/src-tauri/src/serial_manager.rs b/src-tauri/src/serial_manager.rs index 2dbbd1e..959eee8 100644 --- a/src-tauri/src/serial_manager.rs +++ b/src-tauri/src/serial_manager.rs @@ -331,6 +331,17 @@ impl SerialManager { } } + // A read that returns fresh bytes may have blocked + // through an idle gap LONGER than the segmentation + // timeout without ever surfacing TimedOut (e.g. a + // 28 ms gap at high duty cycle vs the 50 ms read + // timeout — Issue #9). Close the pending frame on + // the observed gap BEFORE feeding, so timeout + // semantics hold regardless of read granularity. + if let Some(frame) = segmenter.flush_if_idle(Instant::now()) { + emit_frame(frame, &disp_settings); + } + for frame in segmenter.feed(received_bytes, Instant::now()) { emit_frame(frame, &disp_settings); } @@ -1181,6 +1192,10 @@ mod tests { enum ScriptEvent { /// Next read() returns these bytes. Bytes(Vec), + /// Next read() blocks for `delay_ms` (as POSIX read does when bytes + /// arrive mid-wait), THEN returns these bytes. Models a high-duty- + ///-cycle stream whose idle gap never surfaces as TimedOut (#9). + DelayedBytes { delay_ms: u64, bytes: Vec }, /// Next read() fails with a non-timeout error (kills the reader thread). Fail, } @@ -1219,6 +1234,17 @@ mod tests { buf[..bytes.len()].copy_from_slice(&bytes); Ok(bytes.len()) } + Some(ScriptEvent::DelayedBytes { delay_ms, bytes }) => { + assert!( + bytes.len() <= buf.len(), + "script chunk {} bytes exceeds read buffer {}", + bytes.len(), + buf.len() + ); + thread::sleep(Duration::from_millis(delay_ms)); + buf[..bytes.len()].copy_from_slice(&bytes); + Ok(bytes.len()) + } Some(ScriptEvent::Fail) => { Err(io::Error::new(io::ErrorKind::Other, "scripted failure")) } @@ -1384,6 +1410,40 @@ mod tests { assert_eq!(frame_bytes(&logs), vec![b"OK\r\nOK\r\n".to_vec()]); } + #[test] + fn timeout_mode_cuts_frame_when_read_returns_after_idle_gap() { + // Issue #9: at 115200 baud / 2560 B / 250 ms period the inter-frame + // gap (~28 ms) is shorter than the 50 ms read timeout, so read() + // returns the NEXT chunk directly without a TimedOut in between. + // The idle check must run before feed() or frames merge invisibly + // until the 64 KiB hard cap. 30 ms gap > 10 ms seg timeout here. + let logs = run_script( + vec![ + ScriptEvent::Bytes(b"AAAA".to_vec()), + ScriptEvent::DelayedBytes { delay_ms: 30, bytes: b"BBBB".to_vec() }, + ], + seg_timeout(), + 2, + ); + assert_eq!(frame_bytes(&logs), vec![b"AAAA".to_vec(), b"BBBB".to_vec()]); + } + + #[test] + fn timeout_mode_merges_when_gap_stays_below_timeout() { + // Companion pin: chunks arriving back-to-back (no observable idle + // gap) must still merge — the fix may not turn continuous flow into + // per-chunk cuts. + let logs = run_script( + vec![ + ScriptEvent::Bytes(b"AAAA".to_vec()), + ScriptEvent::Bytes(b"BBBB".to_vec()), + ], + seg_timeout(), + 1, + ); + assert_eq!(frame_bytes(&logs), vec![b"AAAABBBB".to_vec()]); + } + #[test] fn golden_combined_any_newline_frames_on_arrival() { let logs = run_script(