Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/src/components/LogViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -499,7 +499,7 @@ const LogViewer: React.FC<LogViewerProps> = ({ 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 (
<div className="flex flex-col h-full">
Expand Down
1 change: 1 addition & 0 deletions frontend/src/hooks/useSerialLogs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
60 changes: 60 additions & 0 deletions src-tauri/src/serial_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -1181,6 +1192,10 @@ mod tests {
enum ScriptEvent {
/// Next read() returns these bytes.
Bytes(Vec<u8>),
/// 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<u8> },
/// Next read() fails with a non-timeout error (kills the reader thread).
Fail,
}
Expand Down Expand Up @@ -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"))
}
Expand Down Expand Up @@ -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(
Expand Down
Loading