From 47054238c20028dfd1e61c3d329c4b0d60dea6a3 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Wed, 9 Sep 2026 08:13:56 +0000 Subject: [PATCH 1/8] test(engine): H1 large-session resume harness for terminal attach budget Add the integration-test scaffolding the terminal-attach-budget work (WI-121) builds on, driving real bytes through the PTY reader, the 1024-slot broadcast channel and the outbound coalesce path rather than the HTTP fill helper: - boot_with(scrollback_bytes): choose the ring size per test; the suite default stays 64 KiB. - load_session_command(count): a bash loop emitting coloured, sequence-numbered lines (a monotonic SEQ spine for gap/duplicate detection) with periodic cursor-home frames, then idling so the ring is stable for a reattach. - read_snapshot now returns (reset, len, first_bytes); assert_ground_state_ aligned pins the invariant that a snapshot never begins on a UTF-8 continuation byte or in a chopped escape fragment. Tests: - large_stale_warm_reattach_today_floods_the_whole_ring (64 KiB ring) and four_mib_ring_stale_reattach_is_ground_state_aligned_and_bounded (4 MiB ring, ~6.5 MiB pushed): pin today's aged-out-cursor flood (full untrimmed ring, reset:true) with a comment marking the F1 (WI-125) size inversion; the ground-state alignment assertion holds across that change. - retained_cursor_deltas_are_byte_exact_under_load: two warm reattaches from retained cursors Ca < Cb resolve to byte-exact, gap-free, duplicate-free deltas (the later delta equals the earlier past the Cb-Ca boundary). - lagging_subscriber_recovers_in_band_bounded_and_without_duplicates: a client that stops reading while a load session floods past the broadcast capacity is recovered in-band (send_resync), bounded by the ring, spine strictly increasing (no duplicate/reorder across the resync boundary). Each test runs in ~3.5 s, well under the 10 s guard. Full integration suite green (103 passed). WI-122. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UtEFLJAhLiq4NfZCN2vuBb --- engine/server/tests/integration.rs | 521 ++++++++++++++++++++++++++++- 1 file changed, 513 insertions(+), 8 deletions(-) diff --git a/engine/server/tests/integration.rs b/engine/server/tests/integration.rs index 539fa114..9b0dfb15 100644 --- a/engine/server/tests/integration.rs +++ b/engine/server/tests/integration.rs @@ -82,6 +82,21 @@ async fn boot() -> (String, tokio::task::JoinHandle<()>) { boot_with_config(test_config()).await } +/// Boot with a chosen scrollback ring size, leaving every other test-config +/// default in place. The suite default stays 64 KiB (`test_config`); the +/// large-session resume tests use this to size the ring around the flood they +/// are exercising (a tiny ring an early cursor ages out of, or the 4 MiB ring +/// prod runs). A per-session `scrollback_bytes` override on session-create also +/// works, but sizing the whole engine keeps each test's intent in one place. +#[allow(dead_code)] // used by the large-session resume tests +async fn boot_with(scrollback_bytes: usize) -> (String, tokio::task::JoinHandle<()>) { + boot_with_config(Config { + scrollback_bytes, + ..test_config() + }) + .await +} + async fn boot_with_config(cfg: Config) -> (String, tokio::task::JoinHandle<()>) { let (base, _state, handle) = boot_with_state(cfg).await; (base, handle) @@ -3128,18 +3143,29 @@ async fn ws_attach_with_auth( } /// Read one snapshot sequence (`snapshot-start` → binary frames → -/// `snapshot-done`) and report whether it was a reset and how many snapshot -/// bytes were streamed. +/// `snapshot-done`) and report whether it was a reset, how many snapshot bytes +/// were streamed, and the leading bytes of the payload. +/// +/// The leading bytes let a caller assert the ground-state alignment invariant +/// the ring guarantees: a snapshot never begins on a UTF-8 continuation byte, +/// and never in the tail of a chopped escape sequence. Up to +/// [`SNAPSHOT_HEAD_BYTES`] are captured — enough to recognise a `\x1b[` CSI +/// introducer at the start (a *complete* sequence, not a fragment) versus a +/// bare `[`/`m`/digit left over from a mid-sequence cut. +const SNAPSHOT_HEAD_BYTES: usize = 64; + +#[allow(dead_code)] // `first_bytes` is only read by the large-session tests. async fn read_snapshot( ws: &mut tokio_tungstenite::WebSocketStream< tokio_tungstenite::MaybeTlsStream, >, -) -> (bool, usize) { +) -> (bool, usize, Vec) { let mut reset = false; let mut started = false; let mut total = 0usize; + let mut first_bytes: Vec = Vec::new(); loop { - let m = tokio::time::timeout(Duration::from_secs(3), ws.next()) + let m = tokio::time::timeout(Duration::from_secs(5), ws.next()) .await .expect("snapshot frame arrives") .unwrap() @@ -3156,11 +3182,40 @@ async fn read_snapshot( _ => {} } } - Message::Binary(b) if started => total += b.len(), + Message::Binary(b) if started => { + if first_bytes.len() < SNAPSHOT_HEAD_BYTES { + let take = (SNAPSHOT_HEAD_BYTES - first_bytes.len()).min(b.len()); + first_bytes.extend_from_slice(&b[..take]); + } + total += b.len(); + } _ => {} } } - (reset, total) + (reset, total, first_bytes) +} + +/// A snapshot begins in the terminal's ground state: its first byte is never a +/// UTF-8 continuation byte, and if it opens an escape it is a whole CSI/OSC +/// introducer (`\x1b` followed by `[` or `]`), never the tail of one chopped by +/// an overflow cut (which would begin with a bare `[`, a digit, or `m`). +fn assert_ground_state_aligned(first_bytes: &[u8]) { + if first_bytes.is_empty() { + return; + } + let b0 = first_bytes[0]; + assert!( + (b0 & 0xC0) != 0x80, + "snapshot must not begin on a UTF-8 continuation byte; got {b0:#04x}" + ); + if b0 == 0x1b { + assert!( + matches!(first_bytes.get(1), Some(b'[') | Some(b']')), + "a leading ESC must introduce a complete CSI/OSC sequence, not a \ + fragment; got {:?}", + &first_bytes[..first_bytes.len().min(4)] + ); + } } /// Drive more than `at_least` bytes of output into a `cat` session's ring, as @@ -3240,7 +3295,7 @@ async fn cold_attach_tail_hint_caps_the_snapshot() { json!({ "type": "auth", "token": TEST_TOKEN, "snapshot_tail_bytes": TAIL }), ) .await; - let (reset, len) = read_snapshot(&mut cold).await; + let (reset, len, _head) = read_snapshot(&mut cold).await; assert!(reset, "a cold attach is always a full reset"); assert!( len > 0 && len <= TAIL, @@ -3290,7 +3345,7 @@ async fn warm_attach_is_not_narrowed_by_a_tail_hint() { }), ) .await; - let (reset, len) = read_snapshot(&mut warm).await; + let (reset, len, _head) = read_snapshot(&mut warm).await; assert!(!reset, "a warm resume from 0 is a delta, not a reset"); assert!( len > TAIL, @@ -3300,6 +3355,456 @@ async fn warm_attach_is_not_narrowed_by_a_tail_hint() { kill_session(&client, &base, &id).await; } +// ---- large-session resume harness (WI-122 / H1) ---- +// +// These tests drive real bytes through the PTY reader, the 1024-slot broadcast +// channel and the outbound coalesce path via a load-generating session, then +// exercise resume across a ring the cursor has aged out of, and the in-band +// lag recovery. Two of them pin *today's* flood behaviour with a comment +// pointing at the F1 (WI-125) inversion; the ground-state-alignment, +// byte-exact-delta and no-duplicate assertions hold across that change. + +/// A load-generating session command: a bash loop emitting `count` coloured, +/// sequence-numbered lines — a monotonic `SEQNNNNNNNN` spine so a replay can be +/// checked for gaps and duplicates — with a periodic cursor-home frame so the +/// stream carries real CSI sequences, not just plain text. After the burst it +/// idles (`sleep`) so the ring is stable while a test reattaches; every caller +/// reaps it with `kill_session`. Each line is ~65 bytes. +fn load_session_command(count: u32) -> Vec { + vec![ + "/bin/bash".into(), + "-c".into(), + format!( + "i=0; while [ \"$i\" -lt {count} ]; do \ + printf '\\033[3%dmSEQ%08d the quick brown fox jumps over the lazy dog\\033[0m\\n' \ + \"$((i % 8))\" \"$i\"; \ + i=$((i + 1)); \ + if [ \"$((i % 64))\" -eq 0 ]; then printf '\\033[H'; fi; \ + done; \ + exec sleep 3600" + ), + ] +} + +/// Create a session running `command`, returning its id. +async fn create_command_session( + client: &reqwest::Client, + base: &str, + name: &str, + command: Vec, +) -> String { + client + .post(format!("{base}/api/sessions")) + .json(&json!({ "name": name, "command": command })) + .send() + .await + .unwrap() + .json::() + .await + .unwrap()["id"] + .as_str() + .unwrap() + .to_string() +} + +/// Poll `GET /api/sessions/:id` until `scrollback_pos` reaches `target`, +/// returning the observed position. Fails on a deadline so a session that never +/// produces enough fails rather than hanging. +async fn poll_scrollback_at_least( + client: &reqwest::Client, + base: &str, + id: &str, + target: u64, +) -> u64 { + let deadline = tokio::time::Instant::now() + Duration::from_secs(8); + loop { + let detail: SessionDetail = client + .get(format!("{base}/api/sessions/{id}")) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let pos = detail.scrollback_pos; + if pos >= target { + return pos; + } + assert!( + tokio::time::Instant::now() < deadline, + "session {id} produced only {pos} bytes, wanted >= {target}" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } +} + +/// Wait until `scrollback_pos` stops advancing (two equal reads 80 ms apart), +/// so a test that compares two reattaches resolves both cursors against the +/// same, stable ring. Returns the settled position. +async fn wait_until_scrollback_stable(client: &reqwest::Client, base: &str, id: &str) -> u64 { + let deadline = tokio::time::Instant::now() + Duration::from_secs(8); + let mut last = u64::MAX; + loop { + let detail: SessionDetail = client + .get(format!("{base}/api/sessions/{id}")) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let pos = detail.scrollback_pos; + if pos == last { + return pos; + } + last = pos; + assert!( + tokio::time::Instant::now() < deadline, + "session {id} never stopped producing (last {pos})" + ); + tokio::time::sleep(Duration::from_millis(80)).await; + } +} + +/// Read one whole snapshot sequence, returning `(reset, full_payload)`. Unlike +/// [`read_snapshot`] this keeps every byte, so a caller can compare two deltas +/// for byte-exactness. +async fn read_snapshot_payload( + ws: &mut tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, +) -> (bool, Vec) { + let mut reset = false; + let mut started = false; + let mut payload = Vec::new(); + loop { + let m = tokio::time::timeout(Duration::from_secs(5), ws.next()) + .await + .expect("snapshot frame arrives") + .unwrap() + .unwrap(); + match m { + Message::Text(s) => { + let v: Value = serde_json::from_str(&s).unwrap(); + match v["type"].as_str() { + Some("snapshot-start") => { + started = true; + reset = v["reset"].as_bool().unwrap_or(true); + } + Some("snapshot-done") => break, + _ => {} + } + } + Message::Binary(b) if started => payload.extend_from_slice(&b), + _ => {} + } + } + (reset, payload) +} + +/// One delivered snapshot/resync sequence: whether it reset the terminal and +/// the payload bytes that followed it. +struct SnapSegment { + reset: bool, + bytes: Vec, +} + +/// Drain frames, grouping the payload after each `snapshot-start` into its own +/// segment, until no frame arrives for `idle`. Returns the initial snapshot, +/// the live bytes that followed it, and any in-band resync the server sent — +/// each `snapshot-start` opens a new segment, so a resync is a fresh segment. +async fn read_segments_until_idle( + ws: &mut tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + idle: Duration, +) -> Vec { + let mut segs: Vec = Vec::new(); + loop { + match tokio::time::timeout(idle, ws.next()).await { + Err(_) | Ok(None) | Ok(Some(Err(_))) => break, + Ok(Some(Ok(m))) => match m { + Message::Text(s) => { + let v: Value = serde_json::from_str(&s).unwrap(); + if v["type"] == "snapshot-start" { + segs.push(SnapSegment { + reset: v["reset"].as_bool().unwrap_or(true), + bytes: Vec::new(), + }); + } + } + Message::Binary(b) => { + if let Some(seg) = segs.last_mut() { + seg.bytes.extend_from_slice(&b); + } + } + _ => {} + }, + } + } + segs +} + +/// Extract, in order, every complete `SEQ%08d` marker in `bytes`. The producer +/// emits each sequence number exactly once and monotonically, so this is the +/// spine used to detect gaps and duplicates in a replay. +fn seq_numbers(bytes: &[u8]) -> Vec { + let mut out = Vec::new(); + let mut i = 0; + while i + 11 <= bytes.len() { + if &bytes[i..i + 3] == b"SEQ" && bytes[i + 3..i + 11].iter().all(u8::is_ascii_digit) { + let n: u32 = std::str::from_utf8(&bytes[i + 3..i + 11]) + .unwrap() + .parse() + .unwrap(); + out.push(n); + i += 11; + } else { + i += 1; + } + } + out +} + +/// Assert a run of `SEQ` markers is contiguous: strictly increasing by exactly +/// one, no gap and no duplicate. Used on a delta that resumed from a retained +/// cursor, where every intervening line must be present exactly once. +fn assert_seq_spine_contiguous(bytes: &[u8]) { + let seqs = seq_numbers(bytes); + assert!( + seqs.len() > 10, + "expected a run of SEQ markers in the delta, saw {}", + seqs.len() + ); + for w in seqs.windows(2) { + assert_eq!( + w[1], + w[0] + 1, + "SEQ spine is not contiguous ({} then {}): a gap or duplicate in the delta", + w[0], + w[1] + ); + } +} + +#[tokio::test] +async fn large_stale_warm_reattach_today_floods_the_whole_ring() { + // H1(a). Ring 64 KiB. A load session pushes far past capacity, so a cursor + // recorded early has aged out of the ring by the time we reattach with it. + // Today `snapshot_for_attach` answers that stale warm reattach with the + // FULL untrimmed ring and reset:true — the flood that is 4x worse than a + // cold attach. This test pins that behaviour. + // + // WHEN F1 (WI-125) LANDS: invert the size assertion to `len <= TAIL_BUDGET` + // (the aged-out path returns a bounded tail, still reset:true). + const CAP: usize = 64 * 1024; + const TAIL_BUDGET: usize = 8 * 1024; // the future F1 budget, well under CAP + let (base, _h) = boot_with(CAP).await; + let client = reqwest::Client::builder() + .default_headers(auth()) + .build() + .unwrap(); + + let id = create_command_session(&client, &base, "flood", load_session_command(20_000)).await; + + // Record an early cursor, then wait until the ring has advanced several + // capacities past it so it is unambiguously aged out. + let early = poll_scrollback_at_least(&client, &base, &id, CAP as u64 / 2).await; + let _ = poll_scrollback_at_least(&client, &base, &id, early + 4 * CAP as u64).await; + + let mut warm = ws_attach_with_auth( + &base, + &id, + json!({ + "type": "auth", + "token": TEST_TOKEN, + "resume_from": early, + "snapshot_tail_bytes": TAIL_BUDGET, + }), + ) + .await; + let (reset, len, head) = read_snapshot(&mut warm).await; + assert!(reset, "a cursor aged out of the ring forces a full reset"); + // BEFORE F1: the whole ring is replayed, ignoring the tail hint. + assert!( + len > TAIL_BUDGET, + "today a stale warm reattach floods the whole ring; got {len} <= budget \ + {TAIL_BUDGET}. When F1 lands, invert this to `len <= TAIL_BUDGET`." + ); + assert!( + len <= CAP + CAP / 16, + "the flood is still bounded by the ring capacity; got {len}" + ); + assert_ground_state_aligned(&head); + warm.close(None).await.ok(); + kill_session(&client, &base, &id).await; +} + +#[tokio::test] +async fn four_mib_ring_stale_reattach_is_ground_state_aligned_and_bounded() { + // H1(b). The dimensions prod runs: a 4 MiB ring with ~6.5 MiB pushed. The + // stale warm reattach today returns the full ring (reset:true), aligned to + // the terminal's ground state. F1 flips the size to the tail budget; the + // alignment assertion holds either way. + const CAP: usize = 4 * 1024 * 1024; + const TAIL_BUDGET: usize = 1024 * 1024; + let (base, _h) = boot_with(CAP).await; + let client = reqwest::Client::builder() + .default_headers(auth()) + .build() + .unwrap(); + + let id = + create_command_session(&client, &base, "flood-4m", load_session_command(100_000)).await; + + // Push well past the ring, then reattach from position 1 (long aged out). + let _ = poll_scrollback_at_least(&client, &base, &id, CAP as u64 + CAP as u64 / 2).await; + let mut warm = ws_attach_with_auth( + &base, + &id, + json!({ + "type": "auth", + "token": TEST_TOKEN, + "resume_from": 1, + "snapshot_tail_bytes": TAIL_BUDGET, + }), + ) + .await; + let (reset, len, head) = read_snapshot(&mut warm).await; + assert!(reset, "position 1 has long since aged out of a 4 MiB ring"); + // BEFORE F1: the 4 MiB ring is replayed whole. Invert to `<= TAIL_BUDGET`. + assert!( + len > TAIL_BUDGET, + "today the 4 MiB ring is replayed whole; got {len}. Invert to \ + `len <= TAIL_BUDGET` when F1 lands." + ); + assert!( + len <= CAP + CAP / 16, + "bounded by the ring capacity; got {len}" + ); + assert_ground_state_aligned(&head); + warm.close(None).await.ok(); + kill_session(&client, &base, &id).await; +} + +#[tokio::test] +async fn retained_cursor_deltas_are_byte_exact_under_load() { + // H1(b) — byte-exactness half. Two warm reattaches from retained cursors + // Ca < Cb into the same idle 4 MiB ring: the later delta must equal the + // earlier delta with its first (Cb-Ca) bytes removed, and its SEQ spine + // must be contiguous. Proves `snapshot_since` is byte-exact against bytes + // that really flowed through the PTY reader and broadcast path, with no gap + // or duplicate at the resume boundary. This assertion survives F1. + const CAP: usize = 4 * 1024 * 1024; + let (base, _h) = boot_with(CAP).await; + let client = reqwest::Client::builder() + .default_headers(auth()) + .build() + .unwrap(); + + let id = create_command_session(&client, &base, "delta", load_session_command(80_000)).await; + // Let it settle so both cursors resolve against the same stable ring. + let _ = poll_scrollback_at_least(&client, &base, &id, CAP as u64 + 256 * 1024).await; + let total = wait_until_scrollback_stable(&client, &base, &id).await; + + // Both cursors sit inside the retained 4 MiB window. + let ca = total - 2 * 1024 * 1024; + let cb = ca + 512 * 1024; + + let mut a = ws_attach_with_token_and_cursor(&base, &id, TEST_TOKEN, Some(ca)).await; + let (reset_a, delta_a) = read_snapshot_payload(&mut a).await; + let mut b = ws_attach_with_token_and_cursor(&base, &id, TEST_TOKEN, Some(cb)).await; + let (reset_b, delta_b) = read_snapshot_payload(&mut b).await; + + assert!(!reset_a && !reset_b, "retained cursors resume as deltas"); + let skip = (cb - ca) as usize; + assert!( + delta_a.len() >= skip && delta_a.len() == skip + delta_b.len(), + "delta lengths inconsistent: |A|={} skip={} |B|={}", + delta_a.len(), + skip, + delta_b.len() + ); + assert_eq!( + &delta_a[skip..], + &delta_b[..], + "the later delta must equal the earlier delta past the (Cb-Ca) boundary" + ); + assert_seq_spine_contiguous(&delta_b); + + a.close(None).await.ok(); + b.close(None).await.ok(); + kill_session(&client, &base, &id).await; +} + +#[tokio::test] +async fn lagging_subscriber_recovers_in_band_bounded_and_without_duplicates() { + // H1(c) — lag recovery. A client stops reading while a load session floods + // far past the 1024-slot broadcast channel, so the server must recover the + // socket in-band (`send_resync`) rather than drop it. The ring is sized + // above the ~8 MiB broadcast overflow so the resync stays a reset:false + // delta. Assert: a resync segment appears, every segment's payload is + // bounded by the ring, and the SEQ spine across the whole delivered stream + // is strictly increasing — no duplicate and no reordering across the + // resync boundary. + const CAP: usize = 12 * 1024 * 1024; + let (base, _h) = boot_with(CAP).await; + let client = reqwest::Client::builder() + .default_headers(auth()) + .build() + .unwrap(); + + // ~11.7 MiB of output, comfortably past the broadcast channel capacity. + let id = create_command_session(&client, &base, "lag", load_session_command(180_000)).await; + + let mut ws = ws_attach(&base, &id).await; + // Do not read at all: the initial snapshot and the live stream both queue + // in the socket buffer, the outbound task blocks on a full socket, and the + // broadcast channel overflows behind it. + tokio::time::sleep(Duration::from_millis(1500)).await; + // Resume and drain everything, including the in-band resync. + let segs = read_segments_until_idle(&mut ws, Duration::from_millis(1500)).await; + + assert!( + segs.len() >= 2, + "expected an in-band resync segment after the flood; saw {} segment(s)", + segs.len() + ); + assert!( + segs[0].reset, + "the initial cold attach is always a full reset snapshot" + ); + for s in &segs { + assert!( + s.bytes.len() <= CAP + CAP / 16, + "a resync/snapshot payload must be bounded by the ring; got {}", + s.bytes.len() + ); + } + // The resync snapshots from the client's last sent position, so it never + // re-sends bytes already delivered: the spine is strictly increasing. + let mut all = Vec::new(); + for s in &segs { + all.extend(seq_numbers(&s.bytes)); + } + assert!( + all.len() > 100, + "expected a long SEQ spine across the delivered stream, saw {}", + all.len() + ); + for w in all.windows(2) { + assert!( + w[1] > w[0], + "SEQ spine went backward or duplicated across a resync: {} then {}", + w[0], + w[1] + ); + } + + ws.close(None).await.ok(); + kill_session(&client, &base, &id).await; +} + #[tokio::test] async fn ws_attach_echoes_input_and_replays_on_reattach() { let (base, _h) = boot().await; From 488a3206fe3ac2da05cb67f51ac2afae2e7646d0 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Wed, 9 Sep 2026 08:25:20 +0000 Subject: [PATCH 2/8] fix(engine,web): F1 bound every terminal replay to the tail budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stale warm reattach flood: when a client's resume cursor had aged out of the scrollback ring, snapshot_for_attach returned the FULL untrimmed ring with reset:true — up to 4 MiB parsed into an xterm that keeps 5000 lines, 4x worse than a cold attach, and above a screen the client already had stale. An oversized in-window delta had the same problem. Engine (pty.rs): snapshot_tail_bytes is now the replay budget on every path. A warm reattach whose cursor is retained and whose delta fits the budget is still returned byte-exact with reset:false; a delta that aged out OR exceeds the budget returns a ground-state-aligned tail of at most the budget with reset:true. The in-band resync path (tail_bytes=None) is unchanged. Web (api.ts): openAttach always sends snapshot_tail_bytes: REPLAY_TAIL_MAX_BYTES alongside resume_from, so warm reattaches are bounded too. Terminal.tsx already resets and clears its cache on any reset:true; the snapshot-start position arithmetic is extracted into snapshotStartPosition() so the re-anchor after a reset-following-a-resume is unit-tested (the client discards its stale cursor, re-anchors to scrollback_pos - scrollback_bytes, and resumes gap-free). Tests: - H1 (a)/(b) "before" flood assertions flipped to bounded (len <= budget, reset:true, ground-state aligned). - warm_attach_is_not_narrowed_by_a_tail_hint inverted to warm_attach_over_budget_is_a_bounded_reset; new warm_attach_within_budget_stays_a_byte_exact_delta keeps the reset:false byte-exact case. - web: snapshotStartPosition.test.ts; attachAuthFrame.test.ts updated for the always-sent budget. Verification: cargo test -p vogt-engine-server --test integration 104 passed; fmt + clippy clean; web typecheck clean; pnpm vitest 906 passed; scripts/check_docs.py clean. docs/ENGINE.md §5 documents the budget contract. WI-125. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UtEFLJAhLiq4NfZCN2vuBb --- docs/ENGINE.md | 27 +++- engine/server/src/pty.rs | 49 ++++-- engine/server/tests/integration.rs | 152 +++++++++++++----- web/src/Terminal.tsx | 11 +- web/src/__tests__/attachAuthFrame.test.ts | 8 +- .../__tests__/snapshotStartPosition.test.ts | 38 +++++ web/src/api.ts | 16 +- web/src/terminalReplay.ts | 23 +++ 8 files changed, 254 insertions(+), 70 deletions(-) create mode 100644 web/src/__tests__/snapshotStartPosition.test.ts diff --git a/docs/ENGINE.md b/docs/ENGINE.md index 7a39614a..f4423d68 100644 --- a/docs/ENGINE.md +++ b/docs/ENGINE.md @@ -623,8 +623,9 @@ token can list sessions and read a scrollback snapshot over The attach sequence is ordered: -1. client sends auth control frame, optionally with its last applied cursor: - `{"type":"auth","token":"...","resume_from":123}` +1. client sends auth control frame, optionally with its last applied cursor + and always with its replay budget: + `{"type":"auth","token":"...","resume_from":123,"snapshot_tail_bytes":1048576}` 2. server sends `snapshot-start` 3. server sends zero or more binary scrollback chunks 4. server sends `snapshot-done` @@ -645,9 +646,25 @@ Server text control frames: {"type":"lag","note":"client too slow; reattach"} ``` -When `resume_from` is still retained, `snapshot-start.reset` is false and the -binary snapshot contains only newer bytes. Otherwise the server returns a full -snapshot with `reset` true. +`snapshot_tail_bytes` is the replay **budget**, and it bounds every reply — a +client's terminal keeps only a fixed scrollback, so the server never ships more +than the client can hold: + +- **Cold attach** (no `resume_from`): a ground-state-aligned tail of at most the + budget, `reset` true. +- **Warm reattach whose cursor is retained and whose delta fits the budget:** + `reset` false and the binary snapshot contains only the newer bytes, + byte-for-byte — an ordinary switch-away/switch-back appends without a clear. +- **Warm reattach whose cursor aged out of the ring, or whose delta exceeds the + budget:** a ground-state-aligned tail of at most the budget, `reset` true. + `reset: true` may therefore follow a `resume_from`. The client discards its + stale cursor and re-anchors to `scrollback_pos - scrollback_bytes` (the start + of the tail), so after replaying the tail its position is `scrollback_pos` + again and live traffic resumes with no gap. + +The returned `scrollback_pos` is always the absolute end position, unaffected by +trimming the front. Omitting `snapshot_tail_bytes` (the in-band lag resync, +which carries its own cursor) leaves a retained delta unbounded and byte-exact. A text frame that does not parse as a control message is treated as raw input, because some tools send keystrokes as text. Snapshot chunks are capped at diff --git a/engine/server/src/pty.rs b/engine/server/src/pty.rs index 8d28cbcf..e0c0133a 100644 --- a/engine/server/src/pty.rs +++ b/engine/server/src/pty.rs @@ -130,13 +130,25 @@ impl Session { /// Snapshot only output newer than a client cursor. The boolean tells the /// client whether its existing terminal state must be reset. /// - /// `tail_bytes` caps a **cold** attach only: a fresh client with no - /// cache sends no `resume_from`, and without a cap the full snapshot is the - /// entire scrollback ring. When present it bounds that full snapshot to at - /// most that many trailing bytes. A warm reattach (`resume_from` present) - /// ignores it entirely, so its `reset: false` delta stays byte-for-byte - /// unchanged; the returned position (`total_written`) is unaffected by - /// trimming the front, so the live stream still resumes with no gap. + /// `tail_bytes` is the replay **budget**, and it now bounds *every* path, + /// not just a cold attach (F1, WI-125). A client's xterm keeps only a fixed + /// scrollback (5000 lines); shipping more than the budget is wasted parse + /// on the client's single message thread, and on a stale reattach it is + /// parsed *above* a screen the client already has out of date. So: + /// + /// - Cold attach (no `resume_from`): bound the full snapshot to the budget. + /// - Warm reattach whose cursor is still retained and whose delta fits the + /// budget: the delta is sent byte-for-byte with `reset: false`, so a + /// switch-away/switch-back that produced little still appends cleanly. + /// - Warm reattach whose cursor aged out of the ring, **or** whose delta + /// exceeds the budget: a ground-state-aligned tail of at most the budget, + /// with `reset: true`. Never the whole untrimmed ring — that was the + /// 4 MiB flood, 4x worse than a cold attach. + /// + /// The returned position is always `total_written`, unaffected by trimming + /// the front, so the live stream resumes with no gap. When `tail_bytes` is + /// `None` (the in-band resync path, which carries its own cursor) nothing + /// is bounded and a retained delta stays byte-exact. pub fn snapshot_for_attach( &self, resume_from: Option, @@ -146,12 +158,25 @@ impl Session { let pos = sb.total_written(); if let Some(cursor) = resume_from { if let Some(delta) = sb.snapshot_since(cursor) { - return (delta, pos, false); + // A retained delta within budget is byte-exact and appends + // (reset:false). An oversized delta is capped to a bounded tail + // and reset:true — the client's xterm cannot keep more than its + // scrollback anyway, so an over-budget delta would only waste + // parse above an already-stale screen. + match tail_bytes { + Some(limit) if delta.len() > limit => { + return (sb.snapshot_tail(limit), pos, true); + } + _ => return (delta, pos, false), + } } - // Cursor aged out of the ring: a full reset snapshot, untrimmed — - // the tail cap is a cold-attach affordance and a warm reattach is - // never quietly narrowed. - return (sb.snapshot(), pos, true); + // Cursor aged out of the ring: a bounded, ground-state-aligned tail + // and a reset — never the whole untrimmed ring. + let snapshot = match tail_bytes { + Some(limit) => sb.snapshot_tail(limit), + None => sb.snapshot(), + }; + return (snapshot, pos, true); } // Cold attach: bound the snapshot to the client's tail hint so first // open never ships the whole ring buffer. diff --git a/engine/server/tests/integration.rs b/engine/server/tests/integration.rs index 9b0dfb15..cc569707 100644 --- a/engine/server/tests/integration.rs +++ b/engine/server/tests/integration.rs @@ -3306,10 +3306,12 @@ async fn cold_attach_tail_hint_caps_the_snapshot() { } #[tokio::test] -async fn warm_attach_is_not_narrowed_by_a_tail_hint() { - // The tail cap is a cold-attach affordance. A warm reattach carries - // `resume_from`; even if a tail hint is also present it must be ignored and - // the resume delta returned in full (`reset: false`). +async fn warm_attach_over_budget_is_a_bounded_reset() { + // F1 (WI-125) inverts the old `warm_attach_is_not_narrowed_by_a_tail_hint`. + // The tail hint is now the replay budget on *every* path. A warm reattach + // whose delta exceeds the budget no longer floods the whole ring: it is + // capped to a ground-state tail and reset:true, because the client's xterm + // cannot keep more than its scrollback anyway. let (base, _h) = boot().await; let client = reqwest::Client::builder() .default_headers(auth()) @@ -3332,8 +3334,8 @@ async fn warm_attach_is_not_narrowed_by_a_tail_hint() { const TAIL: usize = 512; fill_scrollback(&base, &id, 4 * TAIL).await; - // resume_from = 0 resolves to the whole retained ring as a delta. With a - // tail hint also set, the warm path must still return it untrimmed. + // resume_from = 0 resolves to the whole retained ring as a delta — far more + // than the 512-byte budget — so F1 caps it to a bounded reset. let mut warm = ws_attach_with_auth( &base, &id, @@ -3345,11 +3347,96 @@ async fn warm_attach_is_not_narrowed_by_a_tail_hint() { }), ) .await; - let (reset, len, _head) = read_snapshot(&mut warm).await; - assert!(!reset, "a warm resume from 0 is a delta, not a reset"); + let (reset, len, head) = read_snapshot(&mut warm).await; + assert!( + reset, + "an over-budget delta is a bounded reset, not an append" + ); + assert!( + len > 0 && len <= TAIL, + "the over-budget delta must be capped to the tail budget ({TAIL}); got {len}" + ); + assert_ground_state_aligned(&head); + warm.close(None).await.ok(); + kill_session(&client, &base, &id).await; +} + +#[tokio::test] +async fn warm_attach_within_budget_stays_a_byte_exact_delta() { + // The companion to the bounded-reset case: a warm reattach whose delta fits + // the budget is still sent byte-for-byte with reset:false, so an ordinary + // switch-away/switch-back appends without clearing the terminal. + let (base, _h) = boot().await; + let client = reqwest::Client::builder() + .default_headers(auth()) + .build() + .unwrap(); + + let id: String = client + .post(format!("{base}/api/sessions")) + .json(&json!({ + "name": "warm-small", + "command": ["/bin/sh", "-c", "stty -echo; exec /bin/cat"] + })) + .send() + .await + .unwrap() + .json::() + .await + .unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + + // Prime the ring, record the cursor, then produce a small delta. + let mut ws = ws_attach(&base, &id).await; + read_snapshot(&mut ws).await; + ws.send(Message::Binary(b"before-cursor\n".to_vec().into())) + .await + .unwrap(); + let _ = collect_binary_until(&mut ws, b"before-cursor", Duration::from_secs(2)).await; + let cursor = { + let detail: Value = client + .get(format!("{base}/api/sessions/{id}")) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + detail["scrollback_pos"].as_u64().unwrap() + }; + ws.send(Message::Binary(b"after-cursor\n".to_vec().into())) + .await + .unwrap(); + let _ = collect_binary_until(&mut ws, b"after-cursor", Duration::from_secs(2)).await; + ws.close(None).await.ok(); + + // A generous budget the tiny delta fits inside: the resume stays a delta. + let mut warm = ws_attach_with_auth( + &base, + &id, + json!({ + "type": "auth", + "token": TEST_TOKEN, + "resume_from": cursor, + "snapshot_tail_bytes": 1024 * 1024, + }), + ) + .await; + let (reset, delta) = read_snapshot_payload(&mut warm).await; + assert!(!reset, "a delta within budget must not reset the terminal"); + assert!( + delta + .windows(b"after-cursor".len()) + .any(|w| w == b"after-cursor"), + "the delta must carry the post-cursor output" + ); assert!( - len > TAIL, - "warm reattach must ignore the tail hint; got {len} <= {TAIL}" + !delta + .windows(b"before-cursor".len()) + .any(|w| w == b"before-cursor"), + "the delta must exclude pre-cursor output (byte-exact from the cursor)" ); warm.close(None).await.ok(); kill_session(&client, &base, &id).await; @@ -3588,17 +3675,15 @@ fn assert_seq_spine_contiguous(bytes: &[u8]) { } #[tokio::test] -async fn large_stale_warm_reattach_today_floods_the_whole_ring() { - // H1(a). Ring 64 KiB. A load session pushes far past capacity, so a cursor - // recorded early has aged out of the ring by the time we reattach with it. - // Today `snapshot_for_attach` answers that stale warm reattach with the - // FULL untrimmed ring and reset:true — the flood that is 4x worse than a - // cold attach. This test pins that behaviour. - // - // WHEN F1 (WI-125) LANDS: invert the size assertion to `len <= TAIL_BUDGET` - // (the aged-out path returns a bounded tail, still reset:true). +async fn large_stale_warm_reattach_is_a_bounded_reset() { + // H1(a) / F1. Ring 64 KiB. A load session pushes far past capacity, so a + // cursor recorded early has aged out of the ring by the time we reattach + // with it. Before F1 `snapshot_for_attach` answered that stale warm + // reattach with the FULL untrimmed ring (the flood, 4x worse than a cold + // attach). F1 (WI-125) bounds it: a ground-state-aligned tail of at most + // the budget, still reset:true. const CAP: usize = 64 * 1024; - const TAIL_BUDGET: usize = 8 * 1024; // the future F1 budget, well under CAP + const TAIL_BUDGET: usize = 8 * 1024; // the F1 budget, well under CAP let (base, _h) = boot_with(CAP).await; let client = reqwest::Client::builder() .default_headers(auth()) @@ -3625,15 +3710,9 @@ async fn large_stale_warm_reattach_today_floods_the_whole_ring() { .await; let (reset, len, head) = read_snapshot(&mut warm).await; assert!(reset, "a cursor aged out of the ring forces a full reset"); - // BEFORE F1: the whole ring is replayed, ignoring the tail hint. assert!( - len > TAIL_BUDGET, - "today a stale warm reattach floods the whole ring; got {len} <= budget \ - {TAIL_BUDGET}. When F1 lands, invert this to `len <= TAIL_BUDGET`." - ); - assert!( - len <= CAP + CAP / 16, - "the flood is still bounded by the ring capacity; got {len}" + len > 0 && len <= TAIL_BUDGET, + "F1 bounds the aged-out reattach to the tail budget ({TAIL_BUDGET}); got {len}" ); assert_ground_state_aligned(&head); warm.close(None).await.ok(); @@ -3642,10 +3721,9 @@ async fn large_stale_warm_reattach_today_floods_the_whole_ring() { #[tokio::test] async fn four_mib_ring_stale_reattach_is_ground_state_aligned_and_bounded() { - // H1(b). The dimensions prod runs: a 4 MiB ring with ~6.5 MiB pushed. The - // stale warm reattach today returns the full ring (reset:true), aligned to - // the terminal's ground state. F1 flips the size to the tail budget; the - // alignment assertion holds either way. + // H1(b) / F1. The dimensions prod runs: a 4 MiB ring with ~6.5 MiB pushed. + // The stale warm reattach is bounded to the tail budget and reset:true, + // aligned to the terminal's ground state — not the whole 4 MiB ring. const CAP: usize = 4 * 1024 * 1024; const TAIL_BUDGET: usize = 1024 * 1024; let (base, _h) = boot_with(CAP).await; @@ -3672,15 +3750,9 @@ async fn four_mib_ring_stale_reattach_is_ground_state_aligned_and_bounded() { .await; let (reset, len, head) = read_snapshot(&mut warm).await; assert!(reset, "position 1 has long since aged out of a 4 MiB ring"); - // BEFORE F1: the 4 MiB ring is replayed whole. Invert to `<= TAIL_BUDGET`. - assert!( - len > TAIL_BUDGET, - "today the 4 MiB ring is replayed whole; got {len}. Invert to \ - `len <= TAIL_BUDGET` when F1 lands." - ); assert!( - len <= CAP + CAP / 16, - "bounded by the ring capacity; got {len}" + len > 0 && len <= TAIL_BUDGET, + "F1 bounds the aged-out 4 MiB reattach to the tail budget ({TAIL_BUDGET}); got {len}" ); assert_ground_state_aligned(&head); warm.close(None).await.ok(); diff --git a/web/src/Terminal.tsx b/web/src/Terminal.tsx index 533b3e71..dd6b1c14 100644 --- a/web/src/Terminal.tsx +++ b/web/src/Terminal.tsx @@ -25,6 +25,7 @@ import { createReplayQueue, prepareReplayTail, scheduleReplay, + snapshotStartPosition, type ReplayHandle, } from "./terminalReplay"; import { beginForegroundReplay } from "./terminalPrewarm"; @@ -965,9 +966,13 @@ const TerminalView: Component = (props) => { } if (typeof ctrl.scrollback_pos === "number") { snapshotEndPosition = ctrl.scrollback_pos; - outputPosition = Math.max( - 0, - ctrl.scrollback_pos - (ctrl.scrollback_bytes ?? 0), + // Re-anchor to the start of the payload we are about to replay. + // On a reset:true that followed a resume_from (F1's aged-out or + // over-budget path) this discards the now-stale cursor; the live + // stream after snapshot-done resumes at scrollback_pos with no gap. + outputPosition = snapshotStartPosition( + ctrl.scrollback_pos, + ctrl.scrollback_bytes ?? 0, ); } inSnapshot = true; diff --git a/web/src/__tests__/attachAuthFrame.test.ts b/web/src/__tests__/attachAuthFrame.test.ts index 211a2fc9..32a72ef9 100644 --- a/web/src/__tests__/attachAuthFrame.test.ts +++ b/web/src/__tests__/attachAuthFrame.test.ts @@ -71,7 +71,7 @@ describe("attach auth frame", () => { expect(frame.resume_from).toBeUndefined(); }); - it("omits the tail hint and sends the cursor on a warm reattach", () => { + it("carries both the cursor and the tail budget on a warm reattach (F1)", () => { installRuntimeTransport(networkless); setBase("http://engine.test"); setToken("test-token-1234567890abcdef"); @@ -79,7 +79,9 @@ describe("attach auth frame", () => { const frame = firstSent(); expect(frame.type).toBe("auth"); expect(frame.resume_from).toBe(4096); - // The tail cap is a cold-attach affordance; a warm reattach must not narrow. - expect(frame.snapshot_tail_bytes).toBeUndefined(); + // F1: the tail budget is sent on every attach. A retained delta within + // budget is still returned byte-exact (reset:false); an aged-out or + // over-budget delta is capped to a bounded reset instead of the whole ring. + expect(frame.snapshot_tail_bytes).toBe(REPLAY_TAIL_MAX_BYTES); }); }); diff --git a/web/src/__tests__/snapshotStartPosition.test.ts b/web/src/__tests__/snapshotStartPosition.test.ts new file mode 100644 index 00000000..fbafa53d --- /dev/null +++ b/web/src/__tests__/snapshotStartPosition.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; + +import { snapshotStartPosition } from "../terminalReplay"; + +describe("snapshotStartPosition", () => { + it("is the payload start: end position minus payload length", () => { + // A cold or warm snapshot ending at absolute position 1000 that carries + // 400 bytes started at 600. + expect(snapshotStartPosition(1000, 400)).toBe(600); + }); + + it("clamps at zero when the payload spans the whole stream so far", () => { + // The whole ring is the payload (nothing has aged out): the start is 0, not + // negative. + expect(snapshotStartPosition(400, 400)).toBe(0); + expect(snapshotStartPosition(400, 1000)).toBe(0); + }); + + it("re-anchors a reset that followed a stale resume cursor (F1)", () => { + // The client had rendered up to 2_000_000 and reattached with that cursor. + // The cursor aged out of a 4 MiB ring, so the server sent a bounded tail: + // a 1 MiB payload ending at 5_000_000, reset:true. The client must discard + // its stale cursor and re-anchor to the start of the tail it will replay. + const scrollbackPos = 5_000_000; + const scrollbackBytes = 1_048_576; + const start = snapshotStartPosition(scrollbackPos, scrollbackBytes); + expect(start).toBe(scrollbackPos - scrollbackBytes); + expect(start).toBeGreaterThan(2_000_000); // well past the stale cursor + + // After replaying exactly the payload's bytes, the client's position is the + // snapshot end again, so the live stream resumes with no gap or duplicate. + expect(start + scrollbackBytes).toBe(scrollbackPos); + }); + + it("treats a zero-length snapshot as a no-op at the end position", () => { + expect(snapshotStartPosition(1234, 0)).toBe(1234); + }); +}); diff --git a/web/src/api.ts b/web/src/api.ts index 06aebe9e..c0a257db 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -1429,13 +1429,15 @@ export function openAttach(id: string, resumeFrom?: number): RuntimeSocket { ws.send(JSON.stringify({ type: "auth", token: tok, - // Cold attach (no cached cursor): bound the server's full snapshot to - // the same tail budget the cache-restore path already enforces, so a - // first open never ships and replays the whole scrollback ring. - // A warm reattach sends its cursor instead and its delta is untouched. - ...(resumeFrom === undefined - ? { snapshot_tail_bytes: REPLAY_TAIL_MAX_BYTES } - : { resume_from: resumeFrom }), + // The tail budget is sent on EVERY attach, warm or cold (F1). It bounds + // the server's reply on all three paths: a cold snapshot, a warm delta + // whose cursor aged out of the ring, and a warm delta that is simply + // larger than the budget — all capped to a ground-state tail the same + // size the cache-restore path enforces, so no attach ever ships and + // replays the whole scrollback ring. A warm delta that fits the budget + // is still returned byte-exact (reset:false). + snapshot_tail_bytes: REPLAY_TAIL_MAX_BYTES, + ...(resumeFrom === undefined ? {} : { resume_from: resumeFrom }), })); }, { once: true }, diff --git a/web/src/terminalReplay.ts b/web/src/terminalReplay.ts index 388dd01f..31552963 100644 --- a/web/src/terminalReplay.ts +++ b/web/src/terminalReplay.ts @@ -11,6 +11,29 @@ export const REPLAY_TAIL_MAX_BYTES = 1 * 1024 * 1024; /** Keep each xterm parser turn bounded and aligned with the server frame size. */ export const REPLAY_SLICE_BYTES = 64 * 1024; +/** + * The absolute output position at the START of a snapshot payload: the byte + * offset the first snapshot byte sits at. The server reports the position at + * the END of the snapshot (`scrollback_pos`) and the payload's byte length + * (`scrollback_bytes`); the start is their difference, clamped at zero. + * + * This is the cursor a client adopts when a `snapshot-start` arrives, and it is + * what makes F1's bounded reset safe. When a `reset:true` snapshot follows a + * `resume_from` — the cursor aged out of the ring, or the delta exceeded the + * budget — the server sends a ground-state tail whose start is + * `scrollback_pos - scrollback_bytes`, well after the client's now-stale + * cursor. The client discards that stale cursor and re-anchors here, so once it + * has replayed the `scrollback_bytes` of the tail its position is exactly + * `scrollback_pos` again and the live stream resumes with no gap and no + * duplicate. + */ +export function snapshotStartPosition( + scrollbackPos: number, + scrollbackBytes: number, +): number { + return Math.max(0, scrollbackPos - scrollbackBytes); +} + export interface ReplayTail { /** The ground-state-safe tail to send to xterm. */ data: Uint8Array; From 402b52363fd56c177c1d433fc5ed566e8bf157a5 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Wed, 9 Sep 2026 08:35:21 +0000 Subject: [PATCH 3/8] fix(web): F3 defer parked tabs' cache replay to first activation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On reload every retained terminal tab replayed its cached tail (up to 1 MiB) immediately, through the single global replay FIFO, round-robin with the active pane. With N retained tabs the pane the user is actually looking at got 1/N of the parser — the reload-is-slow half of the terminal budget bug. A parked pane (a retained tab that is not active, or the unfocused half of a split) now keeps its prepared cache tail in memory and replays it lazily on first activation in resumeSocket(), before connect(), instead of scheduling it into the shared FIFO on load. The active pane still replays immediately, so its restore is no longer time-sliced against tabs the user cannot see. The cache position is adopted at load time so a warm reattach resumes correctly even if the deferred replay is later cancelled by a re-park. The decision is a pure helper, shouldDeferCacheReplay(parked, hasCachedBytes), unit-tested here; the six-cached-tabs end-to-end assertion lands with H3's mocked Playwright spec (WI-124). Verification: pnpm typecheck clean; pnpm vitest 909 passed. WI-127. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UtEFLJAhLiq4NfZCN2vuBb --- web/src/Terminal.tsx | 69 ++++++++++++++----- .../__tests__/shouldDeferCacheReplay.test.ts | 23 +++++++ web/src/terminalReplay.ts | 17 +++++ 3 files changed, 93 insertions(+), 16 deletions(-) create mode 100644 web/src/__tests__/shouldDeferCacheReplay.test.ts diff --git a/web/src/Terminal.tsx b/web/src/Terminal.tsx index dd6b1c14..90da21bf 100644 --- a/web/src/Terminal.tsx +++ b/web/src/Terminal.tsx @@ -25,8 +25,10 @@ import { createReplayQueue, prepareReplayTail, scheduleReplay, + shouldDeferCacheReplay, snapshotStartPosition, type ReplayHandle, + type ReplayTail, } from "./terminalReplay"; import { beginForegroundReplay } from "./terminalPrewarm"; import { @@ -142,6 +144,10 @@ const TerminalView: Component = (props) => { let snapshotEndPosition: number | undefined; let cacheChunks: Uint8Array[] = []; let cacheBytes = 0; + // A parked pane's cached tail, prepared but not yet replayed. Kept in memory + // on reload and replayed lazily on first activation (F3), so retained tabs do + // not time-slice the shared replay parser with the active pane. + let deferredCacheReplay: ReplayTail | null = null; let cacheTimer: ReturnType | null = null; // The outputPosition at the last successful persist, so an unchanged ring is // not re-copied and re-written to IndexedDB. @@ -817,23 +823,18 @@ const TerminalView: Component = (props) => { const prepared = prepareReplayTail(bytes, cached.outputPosition); cacheChunks = [bytes]; cacheBytes = bytes.byteLength; + // Adopt the cache's position now so a warm reattach resumes from it even + // if the visible replay is deferred (or later cancelled by a re-park). + outputPosition = prepared.outputPosition; + if (shouldDeferCacheReplay(isParked(), true)) { + // Parked on reload: keep the tail in memory and replay it on the first + // activation, not into the shared FIFO with the active pane (F3). + deferredCacheReplay = prepared; + setReadyToConnect(true); + return; + } setStatusText("Restoring terminal..."); - replay = scheduleReplay( - props.sessionId, - [prepared.data], - (chunk, done) => { - if (!term) { - done(); - return; - } - term.write(chunk, done); - }, - { - kind: "cache", - droppedBytes: prepared.droppedBytes, - droppedLines: prepared.droppedLines, - }, - ); + replay = replayCacheTail(prepared); void replay.done.then(() => { if (destroyed) return; outputPosition = prepared.outputPosition; @@ -896,6 +897,26 @@ const TerminalView: Component = (props) => { connect(); } + /** Replay a prepared cache tail into xterm through the shared replay queue. */ + function replayCacheTail(prepared: ReplayTail): ReplayHandle { + return scheduleReplay( + props.sessionId, + [prepared.data], + (chunk, done) => { + if (!term) { + done(); + return; + } + term.write(chunk, done); + }, + { + kind: "cache", + droppedBytes: prepared.droppedBytes, + droppedLines: prepared.droppedLines, + }, + ); + } + function connect() { if (isParked()) return; if (isSessionGone()) { markSessionGone(); return; } @@ -1083,6 +1104,22 @@ const TerminalView: Component = (props) => { if (destroyed || !readyToConnect() || isParked()) return; // Resuming to the foreground: hold the pre-warm gate through this attach. enterForegroundReplay(); + // A tab parked on reload deferred its cache replay (F3); run it now, before + // attaching, so the restored scrollback is on screen when the delta arrives. + const pending = deferredCacheReplay; + if (pending) { + deferredCacheReplay = null; + setStatusText("Restoring terminal..."); + replay?.cancel(); + replay = replayCacheTail(pending); + void replay.done.then(() => { + if (destroyed || isParked()) return; + outputPosition = pending.outputPosition; + term?.scrollToBottom(); + connect(); + }); + return; + } setStatusText("Loading terminal..."); connect(); } diff --git a/web/src/__tests__/shouldDeferCacheReplay.test.ts b/web/src/__tests__/shouldDeferCacheReplay.test.ts new file mode 100644 index 00000000..1e5a1a90 --- /dev/null +++ b/web/src/__tests__/shouldDeferCacheReplay.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { shouldDeferCacheReplay } from "../terminalReplay"; + +describe("shouldDeferCacheReplay", () => { + it("defers only a parked pane that has cached bytes", () => { + // A retained-but-parked tab on reload keeps its cache in memory and replays + // it lazily on activation, not into the shared FIFO with the active pane. + expect(shouldDeferCacheReplay(true, true)).toBe(true); + }); + + it("does not defer the active pane", () => { + // The pane the user is looking at replays its cache immediately. + expect(shouldDeferCacheReplay(false, true)).toBe(false); + }); + + it("does not defer when there is nothing cached", () => { + // No cache means nothing to replay: a parked pane with an empty cache just + // waits to connect, it does not enter the deferred path. + expect(shouldDeferCacheReplay(true, false)).toBe(false); + expect(shouldDeferCacheReplay(false, false)).toBe(false); + }); +}); diff --git a/web/src/terminalReplay.ts b/web/src/terminalReplay.ts index 31552963..fe1c56e5 100644 --- a/web/src/terminalReplay.ts +++ b/web/src/terminalReplay.ts @@ -11,6 +11,23 @@ export const REPLAY_TAIL_MAX_BYTES = 1 * 1024 * 1024; /** Keep each xterm parser turn bounded and aligned with the server frame size. */ export const REPLAY_SLICE_BYTES = 64 * 1024; +/** + * On reload, should a pane defer its cached-scrollback replay? + * + * A parked pane — a retained tab that is not the active one, or the unfocused + * half of a split — must NOT replay its cache into the shared replay FIFO on + * reload: N retained tabs replaying at once time-slice the single parser N ways + * and starve the pane the user is actually looking at. A parked pane keeps its + * cache in memory and replays lazily on first activation (`resumeSocket`) + * instead. An active pane with a cache replays immediately. + */ +export function shouldDeferCacheReplay( + parked: boolean, + hasCachedBytes: boolean, +): boolean { + return parked && hasCachedBytes; +} + /** * The absolute output position at the START of a snapshot payload: the byte * offset the first snapshot byte sits at. The server reports the position at From e7320e05d47db1030e0de5918cc4aa90e9047766 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Wed, 9 Sep 2026 08:44:04 +0000 Subject: [PATCH 4/8] fix(engine,web): F2 pong reports sent position, watchdog two-strike MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The liveness probe could force a spurious reconnect on a bursty session. The inbound task answered a ping with scrollback_position() (total_written) and handed the pong to the outbound task on a separate channel; select! could send that pong before the broadcast chunks it referenced, so the client briefly saw serverPos > localPos, printed [disconnected], and reattached. Engine (ws.rs): the ping id is forwarded to the outbound task, which answers with sent_pos — the offset actually written to this socket — after flushing anything already queued (new flush_available helper). The pong is therefore ordered after those chunks on the wire and its pos can never exceed what the client has received. ENGINE.md documents the pong pos semantics. Web (terminalWatchdog.ts): a single behind pong is a suspect, not a verdict — it arms a prompt confirm probe rather than recycling. Only a second behind pong within WATCHDOG_TIMEOUT_MS, with no output in between, is treated as a real stall; any output clears the suspicion. (terminalWatchdog.test.ts already existed, contrary to the plan note; extended with the two-strike cases.) Tests: - engine pong_never_reports_the_server_ahead_of_this_socket: under load, ping mid-stream and assert pong.pos <= snapshot_end + live bytes received on the socket. - web: five two-strike watchdog cases (suspect, confirm-recycle, output clears, caught-up clears, stale-suspect drop). Verification: cargo test -p vogt-engine-server --test integration 105 passed; fmt + clippy clean; web typecheck clean; pnpm vitest 913 passed; scripts/check_docs.py clean. WI-126. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UtEFLJAhLiq4NfZCN2vuBb --- docs/ENGINE.md | 11 ++- engine/server/src/ws.rs | 74 ++++++++++++++++-- engine/server/tests/integration.rs | 88 ++++++++++++++++++++++ web/src/Terminal.tsx | 9 ++- web/src/__tests__/terminalWatchdog.test.ts | 50 +++++++++++- web/src/terminalWatchdog.ts | 45 ++++++++++- 6 files changed, 263 insertions(+), 14 deletions(-) diff --git a/docs/ENGINE.md b/docs/ENGINE.md index f4423d68..068be6b1 100644 --- a/docs/ENGINE.md +++ b/docs/ENGINE.md @@ -635,7 +635,7 @@ Client text control frames: ```json {"type":"resize","cols":120,"rows":40} -{"type":"ping"} +{"type":"ping","id":1} ``` Server text control frames: @@ -643,9 +643,18 @@ Server text control frames: ```json {"type":"snapshot-start","session_id":"uuid","scrollback_bytes":0,"scrollback_pos":0,"reset":true} {"type":"snapshot-done"} +{"type":"pong","id":1,"pos":123} {"type":"lag","note":"client too slow; reattach"} ``` +A `pong` echoes the `ping.id` and carries `pos`: the absolute byte offset the +server has **actually streamed to that socket**, not `total_written`. The pong +is formed and sent by the same outbound task that streams output, after +flushing anything already queued, so it is ordered on the wire after those +chunks and its `pos` can never exceed what the client has received. A client +uses it purely as a liveness probe — a `pos` ahead of what it has rendered is a +suspect to confirm with a second probe, not an immediate reconnect. + `snapshot_tail_bytes` is the replay **budget**, and it bounds every reply — a client's terminal keeps only a fixed scrollback, so the server never ships more than the client can hold: diff --git a/engine/server/src/ws.rs b/engine/server/src/ws.rs index 86c1f49a..89b16f4a 100644 --- a/engine/server/src/ws.rs +++ b/engine/server/src/ws.rs @@ -99,6 +99,47 @@ fn coalesce(snap_pos: u64, chunks: &[OutputChunk]) -> (Vec, u64) { (out, end) } +/// Send whatever broadcast chunks are already queued for this socket, without +/// blocking, advancing `sent_pos` past them. Used before answering a liveness +/// probe so the pong's position reflects output the client has actually been +/// sent. A `Lagged`/`Closed` on the non-blocking drain is left for the main +/// `rx.recv()` loop to handle (resync or teardown); flushing what was already +/// pulled is still correct and gap-free. +async fn flush_available( + sink: &mut S, + rx: &mut tokio::sync::broadcast::Receiver, + snap_pos: u64, + sent_pos: &mut u64, +) -> Result<(), ()> +where + S: SinkExt + Unpin, +{ + let mut drained: Vec = Vec::new(); + let mut queued = 0usize; + while queued < OUTBOUND_COALESCE_CAP { + match rx.try_recv() { + Ok(next) => { + queued += next.data.len(); + drained.push(next); + } + Err(_) => break, + } + } + if drained.is_empty() { + return Ok(()); + } + let (frame, end) = coalesce(snap_pos, &drained); + if end > *sent_pos { + *sent_pos = end; + } + if !frame.is_empty() { + sink.send(Message::Binary(frame.into())) + .await + .map_err(|_| ())?; + } + Ok(()) +} + /// Re-synchronise a lagging client in-band, on the same socket, instead of /// dropping it and forcing a reconnect + replay (a cascade under load). /// @@ -315,7 +356,11 @@ async fn handle_socket( }; let (mut sink, mut stream) = socket.split(); - let (control_tx, mut control_rx) = mpsc::unbounded_channel::(); + // Carries liveness-probe ids from the inbound task to the outbound task. + // The pong is *formed* by the outbound task with the position it has + // actually streamed, not by the inbound task with `total_written` — see the + // ping branch of the outbound select (WI-126). + let (ping_tx, mut ping_rx) = mpsc::unbounded_channel::(); // Subscribe BEFORE snapshotting so no broadcast chunks are missed in the gap. let mut rx = session.subscribe(); @@ -386,10 +431,11 @@ async fn handle_socket( let _ = writer_session.resize(cols, rows); } Ok(ClientControl::Ping { id }) => { - let _ = control_tx.send(ServerControl::Pong { - id, - pos: writer_session.scrollback_position(), - }); + // Hand the probe to the outbound task; it answers + // with the position it has actually sent this + // socket, after flushing queued output, so the pong + // can never report the server ahead of the client. + let _ = ping_tx.send(id); } Ok(ClientControl::Auth { .. }) => { // Already authenticated; ignore further auth frames. @@ -427,9 +473,23 @@ async fn handle_socket( loop { tokio::select! { - Some(control) = control_rx.recv() => { + Some(ping_id) = ping_rx.recv() => { + // Flush anything already queued for this socket, then answer + // the probe with `sent_pos` — the byte offset actually + // written here. Because the pong is ordered AFTER those + // chunks on the wire and carries only what has been sent, it + // can never report the server ahead of what the client has + // received, so a bursty session no longer trips a spurious + // recycle (WI-126). + if flush_available(&mut sink, &mut rx, snap_pos, &mut sent_pos) + .await + .is_err() + { + break; + } + let pong = ServerControl::Pong { id: ping_id, pos: sent_pos }; if sink - .send(Message::Text(serde_json::to_string(&control).unwrap().into())) + .send(Message::Text(serde_json::to_string(&pong).unwrap().into())) .await .is_err() { diff --git a/engine/server/tests/integration.rs b/engine/server/tests/integration.rs index cc569707..535e4507 100644 --- a/engine/server/tests/integration.rs +++ b/engine/server/tests/integration.rs @@ -3877,6 +3877,94 @@ async fn lagging_subscriber_recovers_in_band_bounded_and_without_duplicates() { kill_session(&client, &base, &id).await; } +#[tokio::test] +async fn pong_never_reports_the_server_ahead_of_this_socket() { + // F2 (WI-126). Under load the liveness pong must carry the position actually + // streamed to THIS socket, never `total_written` (which includes queued but + // unsent output). Before the fix the inbound task answered with + // scrollback_position() and the pong could be delivered ahead of the chunks + // it referenced, so the client saw the server "ahead" and recycled the + // socket. Now the outbound task answers with sent_pos after flushing, so the + // pong's pos is never greater than what this socket has received. + let (base, _h) = boot_with(4 * 1024 * 1024).await; + let client = reqwest::Client::builder() + .default_headers(auth()) + .build() + .unwrap(); + let id = create_command_session(&client, &base, "pong", load_session_command(50_000)).await; + + let mut ws = ws_attach(&base, &id).await; + + // Drain the initial snapshot, recording the absolute position it ended at. + let mut snap_end: u64 = 0; + let mut in_snapshot = false; + loop { + let m = tokio::time::timeout(Duration::from_secs(5), ws.next()) + .await + .expect("snapshot frame arrives") + .unwrap() + .unwrap(); + match m { + Message::Text(s) => { + let v: Value = serde_json::from_str(&s).unwrap(); + match v["type"].as_str() { + Some("snapshot-start") => { + in_snapshot = true; + snap_end = v["scrollback_pos"].as_u64().unwrap(); + } + Some("snapshot-done") => break, + _ => {} + } + } + Message::Binary(_) if in_snapshot => {} + _ => {} + } + } + + // Probe while output is still flowing. + ws.send(Message::Text( + json!({ "type": "ping", "id": 1 }).to_string().into(), + )) + .await + .unwrap(); + + // Count live bytes received on this socket until the pong arrives. The pong + // is ordered after any flushed chunks, so by the time we read it we have + // received every byte up to its position. + let mut live: u64 = 0; + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + let m = tokio::time::timeout(remaining, ws.next()) + .await + .expect("a pong should arrive within the deadline") + .unwrap() + .unwrap(); + match m { + Message::Binary(b) => live += b.len() as u64, + Message::Text(s) => { + let v: Value = serde_json::from_str(&s).unwrap(); + if v["type"] == "pong" { + assert_eq!(v["id"].as_u64(), Some(1)); + let pos = v["pos"].as_u64().unwrap(); + let received = snap_end + live; + assert!( + pos <= received, + "pong pos {pos} is ahead of what this socket received \ + ({received} = snap_end {snap_end} + live {live})" + ); + break; + } + // A resync snapshot-start would only add to `received`; keep going. + } + _ => {} + } + } + + ws.close(None).await.ok(); + kill_session(&client, &base, &id).await; +} + #[tokio::test] async fn ws_attach_echoes_input_and_replays_on_reattach() { let (base, _h) = boot().await; diff --git a/web/src/Terminal.tsx b/web/src/Terminal.tsx index 90da21bf..fa1baa19 100644 --- a/web/src/Terminal.tsx +++ b/web/src/Terminal.tsx @@ -1025,7 +1025,14 @@ const TerminalView: Component = (props) => { ws?.close(); scheduleReconnect(100); } else if (ctrl.type === "pong") { - if (watchdog.notePong(ctrl.id, ctrl.pos, outputPosition ?? 0) === "recycle") { + if ( + watchdog.notePong( + ctrl.id, + ctrl.pos, + outputPosition ?? 0, + Date.now(), + ) === "recycle" + ) { recycleSocket("server output is ahead of the rendered cursor"); } } diff --git a/web/src/__tests__/terminalWatchdog.test.ts b/web/src/__tests__/terminalWatchdog.test.ts index 4ef00c1f..da05490b 100644 --- a/web/src/__tests__/terminalWatchdog.test.ts +++ b/web/src/__tests__/terminalWatchdog.test.ts @@ -21,16 +21,60 @@ describe("terminal socket watchdog", () => { expect(watchdog.check(100 + WATCHDOG_TIMEOUT_MS)).toBe("healthy"); }); - it("recycles when pong proves that output was missed", () => { + it("does not recycle on a single behind pong; it arms a confirm probe", () => { + // F2: one behind pong is a suspect, not a verdict — the pong can momentarily + // precede the very chunks it is ahead of. No recycle, and check() issues a + // prompt confirm probe rather than waiting a full interval. const watchdog = new SocketWatchdog(); watchdog.notePingSent(7, 1_000); - expect(watchdog.notePong(7, 42, 41)).toBe("recycle"); + expect(watchdog.notePong(7, 42, 41, 1_010)).toBe("healthy"); + expect(watchdog.check(1_020, false)).toBe("probe"); + }); + + it("recycles on a second behind pong within the window with no output", () => { + const watchdog = new SocketWatchdog(); + watchdog.notePingSent(1, 1_000); + expect(watchdog.notePong(1, 42, 41, 1_010)).toBe("healthy"); // first: suspect + watchdog.notePingSent(2, 1_020); // the confirm probe + // Second behind pong, within WATCHDOG_TIMEOUT_MS of the first, nothing in + // between: a real stall. + expect(watchdog.notePong(2, 43, 41, 1_030)).toBe("recycle"); + }); + + it("clears the suspicion when output arrives between the two probes", () => { + const watchdog = new SocketWatchdog(); + watchdog.notePingSent(1, 1_000); + expect(watchdog.notePong(1, 42, 41, 1_010)).toBe("healthy"); // suspect + watchdog.noteOutput(1_015); // the stream is live after all + watchdog.notePingSent(2, 1_020); + // The next behind pong starts a fresh suspicion, it does not recycle. + expect(watchdog.notePong(2, 43, 41, 1_030)).toBe("healthy"); + }); + + it("clears the suspicion when the confirm pong shows the client caught up", () => { + const watchdog = new SocketWatchdog(); + watchdog.notePingSent(1, 1_000); + expect(watchdog.notePong(1, 42, 41, 1_010)).toBe("healthy"); // suspect + watchdog.notePingSent(2, 1_020); + expect(watchdog.notePong(2, 42, 42, 1_030)).toBe("healthy"); // caught up + // Suspicion cleared: the next lone behind pong is again only a suspect. + watchdog.notePingSent(3, 1_040); + expect(watchdog.notePong(3, 50, 41, 1_050)).toBe("healthy"); + }); + + it("drops a stale suspect the confirm never resolved in time", () => { + const watchdog = new SocketWatchdog(); + watchdog.notePingSent(1, 1_000); + expect(watchdog.notePong(1, 42, 41, 1_010)).toBe("healthy"); // suspect at 1010 + // No confirm pong arrives; past the timeout the suspicion is dropped and the + // watchdog falls back to its ordinary interval cadence. + expect(watchdog.check(1_010 + WATCHDOG_TIMEOUT_MS, false)).toBe("healthy"); }); it("does not probe again before the periodic interval", () => { const watchdog = new SocketWatchdog(); watchdog.notePingSent(1, 0); - watchdog.notePong(1, 0, 0); + watchdog.notePong(1, 0, 0, 0); expect(watchdog.check(WATCHDOG_INTERVAL_MS - 1, false)).toBe("healthy"); }); }); diff --git a/web/src/terminalWatchdog.ts b/web/src/terminalWatchdog.ts index 993c6b3d..75deed0a 100644 --- a/web/src/terminalWatchdog.ts +++ b/web/src/terminalWatchdog.ts @@ -14,16 +14,43 @@ export class SocketWatchdog { private pending: PendingPing | null = null; private lastProbeAt = Number.NEGATIVE_INFINITY; private lastOutputAt = Number.NEGATIVE_INFINITY; + // A single "behind" pong (server position ahead of what the client has + // rendered) is a suspect, not a verdict: even with the engine answering a + // probe with its actual sent position, a pong can momentarily precede the + // very chunks it is ahead of. So the first behind pong arms a prompt confirm + // probe instead of recycling; only a second behind pong within the timeout, + // with no output in between, is treated as a real stall. `suspectAt` is when + // the first behind pong was seen, or null when there is no live suspicion. + private suspectAt: number | null = null; notePingSent(id: number, at: number): void { this.pending = { id, at }; this.lastProbeAt = at; } - notePong(id: number, serverPos: number, localPos: number): WatchdogResult { + notePong( + id: number, + serverPos: number, + localPos: number, + at: number, + ): WatchdogResult { if (!this.pending || this.pending.id !== id) return "healthy"; this.pending = null; - return serverPos > localPos ? "recycle" : "healthy"; + if (serverPos <= localPos) { + // Caught up (or the engine reported its true sent position): any prior + // suspicion is cleared. + this.suspectAt = null; + return "healthy"; + } + // Behind. Recycle only on a second behind pong within the timeout window + // that no output cleared in between (a genuine stall); otherwise arm a + // suspect and let `check` issue a prompt confirm probe. + if (this.suspectAt !== null && at - this.suspectAt < WATCHDOG_TIMEOUT_MS) { + this.suspectAt = null; + return "recycle"; + } + this.suspectAt = at; + return "healthy"; } noteOutput(at: number): void { @@ -31,6 +58,9 @@ export class SocketWatchdog { // Old engines do not answer the probe. Output after it is still proof of // life, so an idle shell does not reconnect in a loop against one. if (this.pending && at >= this.pending.at) this.pending = null; + // Output flowing is proof the stream is live, so a behind-pong suspicion is + // no longer credible. + this.suspectAt = null; } check(now: number, forceProbe = false): WatchdogResult { @@ -42,6 +72,16 @@ export class SocketWatchdog { } return "recycle"; } + // A live suspect confirms promptly — one more probe now, not a full + // interval later — so a real stall is caught within the timeout. A suspect + // the confirm never resolved in time is dropped as stale. + if (this.suspectAt !== null) { + if (now - this.suspectAt >= WATCHDOG_TIMEOUT_MS) { + this.suspectAt = null; + } else { + return "probe"; + } + } return forceProbe || now - this.lastProbeAt >= WATCHDOG_INTERVAL_MS ? "probe" : "healthy"; @@ -51,5 +91,6 @@ export class SocketWatchdog { this.pending = null; this.lastProbeAt = Number.NEGATIVE_INFINITY; this.lastOutputAt = Number.NEGATIVE_INFINITY; + this.suspectAt = null; } } From b2936326823316c87f5cc3c8d560423a77cd1eb6 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Wed, 9 Sep 2026 08:57:08 +0000 Subject: [PATCH 5/8] test(web): H2 real transcript corpus + parser-fidelity tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ground-state trimming that both the server ring and the client cache rely on was only ever tested against synthetic `line\n` data. Add a corpus of real PTY captures and a fidelity suite that exercises it against genuine escape sequences, SGR colour, cursor moves, alt-screen switches and UTF-8. - web/tests/fixtures/transcripts/: claude-code-tui (curses alt-screen redraws: cursor addressing, SGR, OSC title, box/emoji UTF-8 — a seamless, newline-free stream), shell-plain (coloured recursive ls + multibyte chars), cargo-build (real `cargo build -v` colour output with \r). Gzipped; a README documents the intended production source (GET /api/history/:id/download from a dev stack) and how these local stand-ins were captured. - scripts/capture_transcript.py: a pty.fork capture helper. - scripts/sanitise_transcript.py: rewrites home paths/tokens/JWTs/emails to inert placeholders and ASSERTS no secret pattern survives before a fixture is committed. - src/__tests__/transcriptFidelity.test.ts: over each corpus at 4 KiB cut offsets, groundStateReplayStart / prepareReplayTail / sliceForReplay pick a start that is never inside an escape or on a UTF-8 continuation byte (just past a line feed, or start-of-stream), and the kept tail is byte-for-byte the source suffix. The alt-screen corpus documents the no-newline-seam case where raw-byte trimming can only fall back — the gap F5's serialized-state restore closes. The xterm serialize()-compare fidelity and the parse-rate baseline (both need @xterm/addon-serialize) fold into F5 (WI-129), where that addon becomes a real dependency, rather than adding a test-only copy here. Verification: pnpm vitest 928 passed (15 new); ruff check + format clean on the scripts; scripts/check_docs.py clean. WI-123. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UtEFLJAhLiq4NfZCN2vuBb --- scripts/capture_transcript.py | 93 +++++++++++++ scripts/sanitise_transcript.py | 123 ++++++++++++++++++ web/src/__tests__/transcriptFidelity.test.ts | 119 +++++++++++++++++ web/src/gzbytes.d.ts | 6 + web/tests/fixtures/transcripts/README.md | 57 ++++++++ .../fixtures/transcripts/cargo-build.bin.gz | Bin 0 -> 4577 bytes .../transcripts/claude-code-tui.bin.gz | Bin 0 -> 6620 bytes .../fixtures/transcripts/shell-plain.bin.gz | Bin 0 -> 38352 bytes web/vitest.config.ts | 33 ++++- 9 files changed, 430 insertions(+), 1 deletion(-) create mode 100755 scripts/capture_transcript.py create mode 100755 scripts/sanitise_transcript.py create mode 100644 web/src/__tests__/transcriptFidelity.test.ts create mode 100644 web/src/gzbytes.d.ts create mode 100644 web/tests/fixtures/transcripts/README.md create mode 100644 web/tests/fixtures/transcripts/cargo-build.bin.gz create mode 100644 web/tests/fixtures/transcripts/claude-code-tui.bin.gz create mode 100644 web/tests/fixtures/transcripts/shell-plain.bin.gz diff --git a/scripts/capture_transcript.py b/scripts/capture_transcript.py new file mode 100755 index 00000000..24eff752 --- /dev/null +++ b/scripts/capture_transcript.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Capture the raw PTY output of a command to a file. + +The fidelity fixtures under web/tests/fixtures/transcripts/ are ideally captured +from a real dev-stack session via `GET /api/history/:id/download`. When no stack +is reachable (an agent session cannot attach to prod), this runs a real program +under a PTY on this box instead, so the bytes carry genuine escape sequences, +SGR colour, cursor moves, alt-screen switches and UTF-8 — exactly what the +parser-fidelity tests exercise — rather than anything synthetic. + +Run the result through scripts/sanitise_transcript.py before committing it. + +Usage: + capture_transcript.py OUTPUT.bin [--max-bytes N] -- CMD [ARG ...] +""" + +from __future__ import annotations + +import argparse +import contextlib +import os +import pty +import select +import sys +from pathlib import Path + + +def capture(cmd: list[str], max_bytes: int, cols: int = 120, rows: int = 40) -> bytes: + pid, fd = pty.fork() + if pid == 0: # child + os.environ["TERM"] = "xterm-256color" + os.environ["COLUMNS"] = str(cols) + os.environ["LINES"] = str(rows) + try: + os.execvp(cmd[0], cmd) + except FileNotFoundError: + os._exit(127) + # parent: set window size then drain until EOF or the byte cap. + try: + import fcntl + import struct + import termios + + fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0)) + except Exception: + pass + + chunks: list[bytes] = [] + total = 0 + while total < max_bytes: + try: + readable, _, _ = select.select([fd], [], [], 10.0) + except OSError: + break + if not readable: + break + try: + data = os.read(fd, 65536) + except OSError: + break + if not data: + break + chunks.append(data) + total += len(data) + with contextlib.suppress(OSError): + os.close(fd) + with contextlib.suppress(OSError): + os.waitpid(pid, 0) + return b"".join(chunks)[:max_bytes] + + +def main(argv: list[str]) -> int: + # Split on the first "--" ourselves: argparse.REMAINDER would greedily + # swallow --max-bytes into the command. + if "--" in argv: + sep = argv.index("--") + pre, cmd = argv[:sep], argv[sep + 1 :] + else: + pre, cmd = argv, [] + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("output") + ap.add_argument("--max-bytes", type=int, default=2 * 1024 * 1024) + args = ap.parse_args(pre) + if not cmd: + ap.error("a command is required after --") + data = capture(cmd, args.max_bytes) + Path(args.output).write_bytes(data) + print(f"capture_transcript: {len(data)} bytes -> {args.output}") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/sanitise_transcript.py b/scripts/sanitise_transcript.py new file mode 100755 index 00000000..b3dddb98 --- /dev/null +++ b/scripts/sanitise_transcript.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""Sanitise a captured terminal transcript before it is checked in as a fixture. + +A transcript is raw PTY bytes -- escape sequences, colour, UTF-8, whatever a +program wrote. Captured from a real session it can carry things a fixture must +not: absolute home paths, a bearer token echoed onto a command line, an email +address, an API key in an environment dump. This pass rewrites those to inert +placeholders in place in the byte stream and then *asserts* that nothing +matching a secret pattern survives, so a fixture can never be committed with a +live credential in it. + +Usage: + sanitise_transcript.py INPUT.bin OUTPUT.bin + sanitise_transcript.py --check INPUT.bin # assert only, no write + +Exit non-zero if a secret pattern remains after rewriting. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +# (pattern, replacement) applied in order, over the raw bytes. Replacements are +# kept close to the original length so escape-sequence and line boundaries stay +# meaningful for the fidelity tests. +_REWRITES: list[tuple[re.Pattern[bytes], bytes]] = [ + # Bearer / auth tokens on a command line or in a header. + ( + re.compile(rb"(?i)(authorization:\s*bearer\s+)[A-Za-z0-9._\-]+"), + rb"\1REDACTED_TOKEN", + ), + ( + re.compile(rb"(?i)(token[=:\s\"']+)[A-Za-z0-9._\-]{16,}"), + rb"\1REDACTED_TOKEN", + ), + # Generic long secrets: sk-..., ghp_..., AKIA..., xoxb-..., JWTs. + (re.compile(rb"sk-[A-Za-z0-9]{20,}"), rb"sk-REDACTED"), + (re.compile(rb"gh[pousr]_[A-Za-z0-9]{20,}"), rb"ghx_REDACTED"), + (re.compile(rb"AKIA[0-9A-Z]{16}"), rb"AKIAREDACTEDREDACT00"), + (re.compile(rb"xox[baprs]-[A-Za-z0-9-]{10,}"), rb"xoxb-REDACTED"), + ( + re.compile( + rb"eyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}" + ), + rb"eyJ.REDACTED.JWT", + ), + # Email addresses. + ( + re.compile(rb"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}"), + rb"user@example.invalid", + ), + # Absolute home paths -> a neutral placeholder. + (re.compile(rb"/home/[A-Za-z0-9._\-]+"), rb"/home/user"), + (re.compile(rb"/Users/[A-Za-z0-9._\-]+"), rb"/Users/user"), +] + +# After rewriting, NONE of these may appear. If one does, the sanitiser failed +# to cover a case and the fixture is refused rather than committed with a leak. +_FORBIDDEN: list[re.Pattern[bytes]] = [ + re.compile(rb"(?i)bearer\s+[A-Za-z0-9._\-]{16,}"), + re.compile(rb"sk-[A-Za-z0-9]{20,}"), + re.compile(rb"gh[pousr]_[A-Za-z0-9]{20,}"), + re.compile(rb"AKIA[0-9A-Z]{16}"), + re.compile(rb"xox[baprs]-[A-Za-z0-9-]{10,}"), + re.compile(rb"eyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}"), + # A home path for a real user (the placeholder "user" is allowed). + re.compile(rb"/home/(?!user\b)[A-Za-z0-9._\-]+"), + re.compile(rb"/Users/(?!user\b)[A-Za-z0-9._\-]+"), +] + + +def sanitise(data: bytes) -> bytes: + for pattern, repl in _REWRITES: + data = pattern.sub(repl, data) + return data + + +def assert_clean(data: bytes) -> None: + leaks = [] + for pattern in _FORBIDDEN: + match = pattern.search(data) + if match: + leaks.append(f"{pattern.pattern!r} matched {match.group(0)[:32]!r}") + if leaks: + raise SystemExit( + "sanitise_transcript: secret pattern survived sanitisation:\n " + + "\n ".join(leaks) + ) + + +def main(argv: list[str]) -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("input") + ap.add_argument("output", nargs="?") + ap.add_argument("--check", action="store_true", help="assert only; do not write") + args = ap.parse_args(argv) + + data = Path(args.input).read_bytes() + + if args.check: + assert_clean(data) + print(f"sanitise_transcript: {args.input} clean ({len(data)} bytes)") + return 0 + + if not args.output: + ap.error("OUTPUT is required unless --check is given") + cleaned = sanitise(data) + assert_clean(cleaned) + out = Path(args.output) + out.resolve().parent.mkdir(parents=True, exist_ok=True) + out.write_bytes(cleaned) + print( + f"sanitise_transcript: {args.input} -> {args.output} " + f"({len(data)} -> {len(cleaned)} bytes, clean)" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/web/src/__tests__/transcriptFidelity.test.ts b/web/src/__tests__/transcriptFidelity.test.ts new file mode 100644 index 00000000..a58a54c1 --- /dev/null +++ b/web/src/__tests__/transcriptFidelity.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; + +import claudeCodeTui from "../../tests/fixtures/transcripts/claude-code-tui.bin.gz?gzbytes"; +import shellPlain from "../../tests/fixtures/transcripts/shell-plain.bin.gz?gzbytes"; +import cargoBuild from "../../tests/fixtures/transcripts/cargo-build.bin.gz?gzbytes"; + +import { groundStateReplayStart } from "../terminalCache"; +import { prepareReplayTail, sliceForReplay } from "../terminalReplay"; + +// Real PTY captures (see tests/fixtures/transcripts/README.md). These carry +// genuine escape sequences, SGR colour, cursor moves, alt-screen switches and +// UTF-8, so the ground-state trimming is proven against the byte patterns it +// actually meets — not synthetic `line\n` data. +// +// `lineOriented` corpora emit line feeds (the ground-state seam the trim aligns +// to). `claude-code-tui` is a pure alt-screen redraw stream that addresses the +// cursor directly and never emits `\n` — the case with NO seam, where the +// raw-byte trim can only fall back (and which F5's serialized-state restore, +// WI-129, is meant to cover). Keeping it in the corpus proves the trim stays +// source-exact even then. +const CORPORA = [ + { name: "claude-code-tui", bytes: claudeCodeTui, lineOriented: false }, + { name: "shell-plain", bytes: shellPlain, lineOriented: true }, + { name: "cargo-build", bytes: cargoBuild, lineOriented: true }, +] as const; +const STEP = 4096; + +const isContinuationByte = (b: number | undefined): boolean => + b !== undefined && (b & 0xc0) === 0x80; + +describe.each(CORPORA)("transcript fidelity: $name", ({ bytes: corpus, lineOriented }) => { + it("is a non-trivial real transcript with escape sequences", () => { + expect(corpus.byteLength).toBeGreaterThan(STEP); + expect(corpus.includes(0x1b)).toBe(true); + expect(corpus.includes(0x0a)).toBe(lineOriented); + }); + + it("groundStateReplayStart picks a ground-state, source-exact seam at every 4 KiB cut", () => { + let checked = 0; + // Start at STEP: off === 0 is "nothing dropped" (outputPosition equals the + // length), where a start of 0 is correct with no seam to align. + for (let off = STEP; off < corpus.byteLength; off += STEP) { + // Simulate the client ring dropping its oldest `off` bytes at an arbitrary + // offset (which may split an escape or a UTF-8 char). outputPosition > + // tail.length signals that a drop occurred. + const tail = corpus.subarray(off); + const start = groundStateReplayStart(tail, corpus.byteLength); + + if (start > 0) { + // The only position we can prove is ground state: just past a line feed. + expect(tail[start - 1]).toBe(0x0a); + expect(isContinuationByte(tail[start])).toBe(false); + } else { + // start 0 on a dropped tail means there was no line feed to align to. + expect(tail.indexOf(0x0a)).toBe(-1); + } + // The grounded tail is the exact same bytes of the source from off+start: + // a view over the same buffer at the same offset, never a re-encoding. + const grounded = tail.subarray(start); + const expected = corpus.subarray(off + start); + expect(grounded.byteOffset).toBe(expected.byteOffset); + expect(grounded.byteLength).toBe(expected.byteLength); + checked += 1; + } + expect(checked).toBeGreaterThan(4); + }); + + it("leaves a seamless (alt-screen) tail intact rather than cutting blind", () => { + // Without a line feed there is no position the raw-byte trim can prove is + // ground state, so groundStateReplayStart returns 0 (the tail is replayed + // as-is) instead of guessing a cut inside an escape. Documented limitation + // that F5 addresses by persisting serialized screen state. + if (lineOriented) return; + const tail = corpus.subarray(Math.floor(corpus.byteLength / 2)); + expect(tail.indexOf(0x0a)).toBe(-1); + expect(groundStateReplayStart(tail, corpus.byteLength)).toBe(0); + }); + + it("prepareReplayTail bounds the tail, aligns it, and keeps it source-exact", () => { + for (let off = STEP; off < corpus.byteLength; off += STEP) { + const maxBytes = corpus.byteLength - off; + const prepared = prepareReplayTail(corpus, corpus.byteLength, maxBytes); + + // A tail is a suffix of the corpus (same backing buffer, aligned at the end). + const startAbs = corpus.byteLength - prepared.data.byteLength; + expect(prepared.data.byteOffset).toBe(startAbs); + expect(prepared.data.buffer).toBe(corpus.buffer); + + // Ground state: stream start, or just past a line feed; never a + // continuation byte. + if (startAbs > 0) { + expect(corpus[startAbs - 1]).toBe(0x0a); + } + if (prepared.data.byteLength > 0) { + expect(isContinuationByte(prepared.data[0])).toBe(false); + } + + // Bounded to the budget whenever a newline seam exists at/after the cut; + // a newline-free tail is documented to be left intact. + if (corpus.indexOf(0x0a, off) !== -1) { + expect(prepared.data.byteLength).toBeLessThanOrEqual(maxBytes); + } + } + }); + + it("sliceForReplay never begins inside an escape or a UTF-8 code point", () => { + // A tighter budget forces a real cut on every corpus; the kept tail must + // still begin in ground state. + for (const budget of [16 * 1024, 64 * 1024, 128 * 1024]) { + if (budget >= corpus.byteLength) continue; + const tail = sliceForReplay(corpus, budget); + const startAbs = corpus.byteLength - tail.byteLength; + if (startAbs > 0) { + expect(corpus[startAbs - 1]).toBe(0x0a); + expect(isContinuationByte(tail[0])).toBe(false); + } + } + }); +}); diff --git a/web/src/gzbytes.d.ts b/web/src/gzbytes.d.ts new file mode 100644 index 00000000..ce469327 --- /dev/null +++ b/web/src/gzbytes.d.ts @@ -0,0 +1,6 @@ +// A `?gzbytes` import (see the `gz-fixture-bytes` plugin in vitest.config.ts) +// resolves to the decoded bytes of a gzip fixture, for tests only. +declare module "*?gzbytes" { + const bytes: Uint8Array; + export default bytes; +} diff --git a/web/tests/fixtures/transcripts/README.md b/web/tests/fixtures/transcripts/README.md new file mode 100644 index 00000000..9d43c0d4 --- /dev/null +++ b/web/tests/fixtures/transcripts/README.md @@ -0,0 +1,57 @@ +# Terminal transcript corpus + +Real PTY byte streams used by the parser-fidelity tests +(`src/__tests__/transcriptFidelity.test.ts`) and, later, the browser replay +budget spec (H3, WI-124) and the serialized-restore fidelity compare (F5, +WI-129). They exist so the ground-state trimming that both the server ring and +the client cache rely on is proven against **real** escape sequences, SGR +colour, cursor moves, alt-screen switches and UTF-8 — not synthetic `line\n` +data. + +Each file is gzip-compressed (`.bin.gz`); the tests `gunzip` them in memory. + +| Fixture | Source | Exercises | +|---|---|---| +| `claude-code-tui.bin.gz` | a curses TUI redraw loop under a PTY | alt-screen enter/leave, cursor addressing, SGR colour, OSC title, box-drawing + emoji UTF-8 — the shape of an agent TUI | +| `shell-plain.bin.gz` | `ls -la --color=always -R` + UTF-8 text under a PTY | coloured `ls` output, prompts, `\r`, multibyte box/CJK/emoji characters | +| `cargo-build.bin.gz` | a real `cargo build -v` (clean local crates) | green-bold `Compiling`/`Fresh` status lines, `\r`, long verbose rustc command lines with paths | + +## How these were captured + +The **intended production source** is a real dev-stack session: + +``` +GET /api/history/:id/download # raw recorded PTY bytes for a session +``` + +An agent session cannot reach a running stack (its token has no engine +`sessions` capability — see the terminal-attach plan), so the checked-in +fixtures were captured from real programs on a developer box with +`scripts/capture_transcript.py` (a `pty.fork` capture), for example: + +```sh +python3 scripts/capture_transcript.py tui.raw -- python3 tui.py # a curses redraw loop +python3 scripts/capture_transcript.py shell.raw -- bash -lc 'ls -la --color=always -R /usr/include; …' +python3 scripts/capture_transcript.py cargo.raw -- bash -lc 'cargo clean -p … ; cargo build -v --color always' +``` + +Every capture is then run through the sanitiser, which rewrites home paths, +tokens, JWTs and email addresses to inert placeholders **and asserts that no +secret pattern survives** before it is committed: + +```sh +python3 scripts/sanitise_transcript.py tui.raw tui.bin +gzip -9 tui.bin # -> claude-code-tui.bin.gz +``` + +To refresh from a real stack, download a transcript, run it through +`sanitise_transcript.py --check` (it must pass), sanitise, gzip, and replace the +file here — keeping the same three shapes. + +## Sizes + +These local stand-ins are smaller than a full-session download (the TUI and +`cargo` captures in particular): the fidelity tests sample every 4 KiB, so a few +hundred KB per corpus already gives dozens of independent cut offsets across +real escape/UTF-8 boundaries. Replace them with larger real-session downloads +when a stack is available. diff --git a/web/tests/fixtures/transcripts/cargo-build.bin.gz b/web/tests/fixtures/transcripts/cargo-build.bin.gz new file mode 100644 index 0000000000000000000000000000000000000000..eefcfc8f500cc6398c559b522b3c285173d604d2 GIT binary patch literal 4577 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 LKqzqvLR|m=pyAlx literal 0 HcmV?d00001 diff --git a/web/tests/fixtures/transcripts/claude-code-tui.bin.gz b/web/tests/fixtures/transcripts/claude-code-tui.bin.gz new file mode 100644 index 0000000000000000000000000000000000000000..c551310707da60835b34cd0fab6bc23a6b347e9a GIT binary patch literal 6620 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 Q#o)CGyVvAN+3~LVKm3K!cK`qY literal 0 HcmV?d00001 diff --git a/web/tests/fixtures/transcripts/shell-plain.bin.gz b/web/tests/fixtures/transcripts/shell-plain.bin.gz new file mode 100644 index 0000000000000000000000000000000000000000..cae7f0b2233517ecc767eb31637543269da02032 GIT binary patch literal 38352 zcmV(tKE%L9BMSgfpSzUC$_z09w7WG&rYk*o-I_2CH^2@o_NbBm1lV#%l<^Emvd z+OnQ^{^#$%P2E(~8hb~}cjQvl)-AD`nE0O6t_8&r=zz@UA!|)LP z;DN2x+2+6IdL!jV*q>5wimLnbk%GVw4ngXw82w1YgiI8M)m>RID60fQ4J1MuBP2!~ zdU}*JF*?Hh3K(YF7mutM`eN0gEu+Wg;ow_8R3&$KAd`5ht<69 zZ#})I0#=9iw4C00f3F0UuQjtHLZ$-k$gUcE%bdNl84~ney)WB)=eZjidY&RPMxtx) z>pgdB8QM(W?y90?P%gC?_56#$D2FFE49x9GZ5BaVPGxG4DGchkk>Z_u^<~xW>A|$N z3UEcc0nK@`2mZA$7{N$|atol`zfOK=i<-GJ+Ms&|LN(Rg`V`V$dtXfR;KzrlYJ95c zF*9q`GMpaPxHKBJeZE)q?axGkQ(#JJBmw(oYQ_V56H+z@Q!RQ2}tby?4g}?91-E zTUUMi1O4mw@N@b6tq=4?`Si^-3|;e0{OG^&f&Iwo24M}lt!;i9%< zd92VnK}^3LX$vI5pGFL&+X5XepYE0NuJVUwj#Tt7JG{QN z(W3rsdhA;Dr_k}`T5EZST~lzvv&k?49ef^QPE7DI!*1T-4_uTASfpJ5_4mpEpy#@n zx`B-$Dd+IOV^=@lJ#b&%ukrvqKC_I-B-q0mfxGSQC1GPZR_u`4pfeqPF|ah=uHUJ!n z>ev@AjH9)YDc%$=3?kDETdNTWSv7Gk1r90!s(k1Nzw?Y8a8ZmJpcTXMm6m1!#f2fW zNJs)G1o`NYqKvd50KsWx41X-i6BtIur`8d#IiVQOUnW*;{<|1JK0^X7k(+Veb$MUU z;Qq5V9}D!`2o5XfkIU%xyp8^q^~i&?L^w9opsYcoO-v?iqv6~T#1Bo?(eu+;Dv)SR z2iB<;X@XaEq7(U!iJ4hz(zj2azFEXs3gux%oAsvv`CT|!?B_$-tt`3bL`!$ zW5=k@Zr$TG5yO{I-gfxYUYc3ch5<``j%t9sSeDbXg46tr*zjZxSq77&Qcc+ z`U-TmfxrLu<5SVdkAJ|~Tnb0r)fzr8;db3dT=F3>Zczv>wIXObp&sFVK524uLR1wB zIinR*`n0Qv$V5rN32PIS-gG#j(E5o$2yFz?K5fx>@Uy95Y(#R;praF5kORU|Dr|Ra zdXgKJ7__YEyb<^?KgvVi&r~LVuS11#P7Fc`xN1K^zKi+w*_YG?h~#W#-!VYC$KDTh z7cs^tiI0H&J_q1F_s_9$M|wb@L66D&hix%>3TN!uHeKULh{lkm6#4b+oJ_7EkMqG# z^)~ly+3m;~ZY(|ma48<@D5;Kok+Z*a4PsErL_>8Yy)u=ZBOcd~krt41*5(7#m)m(y zE@+dXO@DfB^1c`fdUkjlnx<$U;Nmx3OAeQvlbu&US^y;;YCLd@(h1tUu%L!MSdAfZ zRP)+-(^&TUZmeGOr5r30T8pk!h$+oONs6m58tudA8*U#&mSD^@_srywsE&`!Fe0%D zlA*C$$|zrNUv_56pFwR=KkWRT-adP-eL_IeBqk1TUKjEov=Cv52$9Gd7BW?K1yOhl$uAy75lIZwFj%p6T(z)<7iz}_&WqV5_~ zp6avdw5~(zD$k|POi7EtsGwWOkzA4>%#p>?VJ*DtWvHHHqD&cv)LlVsf=UuF0oRzv zVhGh{lz(nRq8Xuvj#-efNcPH0r5U}b3{S8cyHuz2=4m|Ese+3hNkyPzH5IcH_-I1} zmkdaO2Z|uzVwBJ1IXNXG5O~J}H94DUy#Qha96U9#2x4+oHj>w_Eqzb!xd8SS&9L27 zQ`NQkb1_uKmfU{We}ko?8@f3Z*3d_7@l83U{^(nbm1lWkj5H`sF=*NqH2`T?<`!VX zfd-ic(6;Mpx~4*9F$&l-X<3~WF$nH6oh3;ZMyg1y0WN`#F?F@2Bj+xjUC3%Cr=VTsqt0c*Ts}KMNg*Zt#b<~k}v{Y z6j(#-yP+Ye5_ou+F9Ig8?z{$%k4);j6v^iv^pP*LDEQz1KWtLzYP?-1lYgq z=#$2*1TfrzsnlRnT(O`q6kgA?V&f$qst#|J2#C`XYj&Oy>*)3~X9}(`zQZhq-e{wd zzM!vssGheNz`aBOeUGl-=}iIY_!=peF!?3UN?j?NVmi^x6V1FcgT=1(FC;kvm5Bgz zv-QJBPE2CT44{zYqLhl3m@s!etBN+xV2AYm8r@ZKTMR{A`xSJ+A|X6h3Ew&B$iI~{ z;NV{mczn%4>#+pQaVXkdM+^flw^K=kIgswN1Quv8W}+##l32wai)87?UAmhPMd`nvDfHJ0VjFP3fm7lN(6Er0f0lpEZLh0w;5gLlLE+(tE0)E0Bwbr*B2xL zhBGmOrwoa}S9@Gxfif(-$0q}1xnW4k#*k?!O8UeER;a|MK-{~~kmK_&?0~Zp9gH}|0iZqFLr0eskd-5$A?-MSXV~~*Tyr*4v zXCoNxP=n^tW(ku&SF~AkI@>=6yD58pQoR_B2SQvqSg7%BY1D(ti# zL8RC#(32ttO(DWhF3cqhX5?ZFZpaqsn0L7X)|cF;89B-NPHtKngD=c`ZhHU1l#{E} zNsO%yE%S~=ClLtf>scjp@71+kz!noIJMK1YT!yd1yH`L0LBTHP$n%AwLCl~vra5Qm zK2ZG?t%{bgFzKiL;C;UH`>N#{WGEw-XCD#-K@%|a6}*6Cc^*78H9El>XapqpB4v|C zp6my3DoaWyXqPQQSG+`0;HDY^OO6~~ALH20w(G_zKPMpb6)h)(#vubxCCTe5eJwK? zWm>?mTy?Qjn%zjh)5_kZZZQE*>Uw2Gl;J+2a^^XkG^w$RDQjkRkr=K%5&?uqMtSI$ zHPR%qB&ts6`}`CCWMsi%W_QYg<@*j?{vFpxD%zb%;suf=X9awU zS1IX@lOnnG0oJhT496EvNhHpGr>VmP`*S9VtFypK^eZeaA|Tp0?YSTGT{Uox8gcp= zP~0j-5~XSY)%7fmT^Ot99BQQu4zit7Y}}>MU}hK6Y$q{b!bAcS^^Xi)9>^{CCLE7^ z80)HJUIp3=6ZR7-Kl*~){j})1o>}(!W>myzN?s zO&M#2=0CycEt6qAdHM+JJlYHgESq9{T5+3z+ThB9rg+WA>X*+i4{8aQBqAp$naZdx zlP%_N`Q=7&x~2pozDH*wZ~*1hCIxu$j%8vSXK@S#R=LW9 zGEv>gUvn2Ek@wz&GnrV-jFYIuWLSvKD8>p&h1KXWr=AyO>FIWB0xm2LY@PD)d5GlE zG7r*VZbEscA&gyyvrw=b%S8lv$@G>8(_c}8f)p4db$2}?efMcZTeB*UaR_mT6h4qstRu>j-a}$#}M>VlHM+C62lWAm5ZzhIDCEELHRN z-brc2GLDrw?V@LW0lPir|LLTOlX1Y#fk#Yd=DH2UiIJHq74ggo3sZ89` zMm>7k%!9ORp<>)6jKU=-mlpaSf(a*doIT67GsS&|C+#j%u~gT5VAwctv144rY+R_N zUYK<=*Hgt|L-!7S13P-wk8%q_axO(?rmXdXRQClF##Tx$?d*Hwb*hjsS(xikgmhFl z6yPp|Q|5jOV(AtSzGfrhNum~}&2F$s?Go(L4w{f-y)@IwH+|f{1Kq;Js3qv&IgJs$ zq7At9eud3kwjdH~RHW0=E)_(s)E(Msd83|^<;z)o^Ulzfg9D|oH!CdJY+<@Ea{zM@ zHK`rW1zZ(NKJ1E#`KdV-y)v90tMP1r^Rf1xQCiBSQQck@Qquh3D#`F=K4Y1@n_7T{ zd|zI*Q@-QW^Ck^HmWT16dyZ8l^o*@kb`i?aWfxarUe!0!cS>Zpiz7Mhv#}Dp!t;0x zZ>)(XZk#nF8u&KTrFN4!>|>mZENS6ZQr#%C%!~cRy3h`*>JK!Zt+EooqCjXaqav_~ zz2moj&$Yt_J&1kDv;L%qbwG*J#nR8^9yp;u@AxeEqfsJRp!ct+{M^Xs<0Vs zLOSeUvfWEkfom_0+e11cN%Z!GdGRcq!f!)P#R`l}t9Yb!uU>_TGBy_vfZo9*#WecS z*S#O|RdDGtYrgVkQ37YdS)QUCm_j*`;@CJ@F&lCfN%Z$=S2KEPzJ`;B-rP$tPG18R z!NjfDuL$L6iEAZ~xX?JOsF>iF(oxOE5xj=`fS;%og{6xXRf;=29nr@O>N@VFlIV%n z!WU@GX_CcRjNvA?OhVKoxU}vXOOKqoY-#o%_MJU=lVutCg;gRDeu6=*{0tZS#=&P& z>4M3!@!zLctX*hK1etFh#~r)aBjBEh~QP&hc)?wfnn0 z;IP(3jX{ocrZ9=f7BRQ!%;$C-AMXwf(c|x5!)lbpR%k^(Jdq0QoYU>_R8&iSex`JX zZ14@^fN)`g0uJ<^Y-uM&V!qhEOr}W|l`@A1ujGs4=usNO(o7oVST9*9x|4>uqv1+i zK(Uh9Nrg*l)^sMZiN?DDp0giXQYXnMmRxoDxi2SKQWtgo;M;1*s5q8scLB0zztXuP z71$1NO(?)Mzyxc34+;^5XUV+|uOU_R_vuzC%HYaHwwZ)XQ|xE*y(EMcY1Gr9>x)A% z`BOfFyt}FUTCWB0|8(-Twh1zN1j2Mp5Zd6d&ar6%T|S||)bSvS;EYv69A{`Sff?zs zzcD$dCYHxSSt<@PxD&`o5>--~{7$&_EPO zEo89yWFK2goRnPm)>;Y7f!Dr%R*6>kJ$@Drn_m9yvy3W#XkPl6j$OY6&78jd^9^6( zWZ!5~jIu3JZ`j2b87R0ZamI4-K?ct2XuaY+Mr0Dyn&%k!B@#}m)ca`+ih5rim`P$k zj`=wck)~p~pV4xuB1$*w$p5@miE?e0b%KppP_1Nl`!q=wv~j+jt9q*F-!9N5%BTlu zsFe4^ME|}DZ0XWe^PLCBn(THTdfP6|cwfDSdk%_|{27i+q)X_A5gtHc8gj(7p7}`# zoki_e0$c;_4$(BxKe5UqAfR}BS&g0dk)Mzg5>vw^jOB2O*siiOu~J~q zJrxrZQ5{hg(Vx(g%1ybz(8}pu#dn{$;3y?FORQy0g+Xj0RLRs_peaJpFPTCL_ypor z`g2t=k9E~Nrklf-7p{zWfNDKXSH|;D6o*d+WEn>;P+7&p=uHI3-0;tdv9Td?X{ANO z=b^HT)tNQix7#K;+37xvLX>D?)C|vR20TC|{=0Va@lfI00>RqaZr=>7hc9^JzHhx5gc49f`4*`# zJFjPKekw|ou>kdvIC+%RFiJ9f6ghd-W|=7_dI6I41MCxHFb};Z3q4d;e@)!(yR;aK zUi1{>jU*IS5ilrNQgd~(+h^0RyZ2RuexE+ubyR!{N|(Dy675{Izaq_M68gV#`CZtJ z6l^9n&s5hDLU*H>20|GWnBI%m&0jJX_BqY*aw`O;AzN4aE{_j^s;d>Qg_ zL(BJAf}DSyPxhOt6<)pP8k7D4PtDqw0`L@Q-J^a zv5W?iUGLHAQL=0*Jd&IXY#hE8=WyREY+qUDP+Cvo%SqFMaa`Z&%)ZXMkyk@!3)2(# zN=MLsM4%8kaC00nVP(Lj?{-I;V8D{a}Rm^>-t3Y@HkGNHv2w#%KBKhvqR1RXkVy^FvF; zP`4j@5#Z|_|H#S?CB(1W5~gYN`TxrnJ|&et&1sm0I(J9*o@@eE~b zpIOJH_KElD!!)u!S#Jm)3=+KJ|zzmQ|$7?}kOi|YVA?2Kj;rUE!lph7(Z zC!j5=N;SsFZE!#vKHL52Zi>-i%Wfq05NN&!PN&g^Xo+2NMHD_C5_ z1d8mtQGJP3#HdxyLjJ_Dr+3m6@4)_C>PGedZr>jKC^jWfiPQUeQzQ>#qn=_l4Ask; zVKu9^f3ga1+7$e#Gl&?f$c`sZu}fUQ`-y`PA#|WF;Q7X83D+}VfocnTGvE-F?^(h` zLzF=9hZ>>=9 zA7+vAAb{C?cK%cEUCI{a3Uw+!biTF&YScJ6pMMvhNpAVVS-tY|MQ=jQAG7`CCNbw> z>@{4|PDNmf8e9m!!IzJ$V2PVWmV6+@9D3CnQHSO8Sjz{B^{m!C`J6pXbrdzU z#6lH3o>ku0?26$nvol$~Dtq$ND%869 zJlOJ)&{Nh@uMiG7TjQ27!XTVAT721`nuo7rcY&H6Yz0%nA?5yS5-3<_wIMYKM!dkLCV!Hx;h0lJW2fqa%0NtqGp%;f{@|z6n?8T~hp9iCDDsrYzLy8JS$d`J z5b*yc-2pX~U!*&PLcdIRh?IVb?jZORemA3eF#RgQ|LBoqjo@eN1wU$NIf2(Kh@sL7 zt8@sCLvbzd@H0J7yQzPl`XMw6UZ#FX#`LSy4@hQT^+&z+7x4n^gT=u!+6U6@p3y!c zEnlR4pgh#|3dH5JUs6&1v{!2TwhG0;x$n}{F6P`N!LGu_*;Lfii5ZPQ7_H4055{8D zI;d)jv`@Oe-Coaww(5eJHaOO=2_{6Vm8fw`zGj>N<{^Hq<~u0F#n}aHxsm3!XhF7M zS6C3jaP4Uc`SqGmaMW+>0>4gyR6$UU^z_i#R$-oa+@}7FUvvJ>qbm1)2tDj=Ic;Xd-qoykh zeG(c~sB{t{;p^*;kGK@iya(DMqw4qze{RQ62%tZ+6LIi_h!&AzOZ`wPhC1Mt&tY0p0|OvI>; z`P~?oy`nBFpMUN|)9}1+H`N%vZ=3#MT9baIXy7tlA22UbEZzdqfIIvw(3&DxzH5Qj zB=i&BM1k7&(pr-a;Pc^`&b&Stiy6<|l}ITKFM87HCr~?<6Z`{|_F4RM5?| z{0M9_co;QTl-0+zoX*W5c${YxJvvWwBnv!fhA{Nwfe>LlwC1!_4{JtxXwU+Abte3! zit62ZzBk8{N^!lWZ+`K!p0h+a8@%EfHY8A2318f7E$l@hOG3fgI`c&AltOT-S>4CLYA6l5@tl<%=k>$J(tefe{&vfo47 zyDK&GP@w^+MTStbH;^jxYiK)@v}A4fq92In^f5BWY~jRe>(OjoD~7REU5t~3UyP$+ zQ^e;5l;XvoqoMoHWKzG}SQJZD7U_8G3wM8N5ymqKIL_3+cA||VX2)b~l0p3#^ib#B&gb=!7s7^*9Hg`IW0%#98q3pGSUf0TtPLa<2`I$;V${ZAxo-K)og^C%_i=j27~ zn=pjZ{Zhf)b-PgTw3jro)!X8^h8Kw;fUUbf2h&I^zF>#_u`Fw%PdMtBaaPRTI~vBg zId{@_MNhX3vVxN#FfB<`guY2_Y8XSm)|gWADvD=1KDaRvgPi=>REAWHD%rUOY#c$C z#(UfU9>yZAFC8Bq=#Im9)5qHN-11NL`-+ToiXNzDRA&jM6xWZm!G!FSVxP#OxgO2- zjVCWks_$2zAEL=`3o~(;En6E(_?|vFfl!CiJp{c23|+Blh(+6=AyH35T$&q`c&3mC z{1#6yA&Q`CKjE*vmgosE1yfS)2B2pk8H{)Hp1fInG~XkN667paxIC8cj5o-;dX=on zHZ`UKp#|HxDDK7^&8s&e*DcB6u} z>MiRw9o-qwFu}8G#!0bpeh9w2W_5LNcC3~%fcZ2;A4-OE_C5A_ro_sGakEyA5>1e35W$(7Hq4T<-JNi!CH?B~v;G#Cdpc#vu5e#moz@h-gT^ApnCwQWC=*?%Muouj}3{3B^3GV@XBIV*NX7w8LrkpxBPnH>{88p!%* z+Iz#B2&3bv7OsYQ!PMXS}=?U^J!}u^EgHoa&|5W$z%W{Yx=4~YdJ(p-ldiF7m0s$hFn19XCY01ApKpW^zu)c%e;^$PjZ^@c)~Zav!EtE zgsLI#sJqL!fCM%kjbh{s28O+EZd#nW8=OYa#JN5=JNsF)fHN18YODGX>+Xjgqi2sh zB;nP8iiRcQYW!TJX-(?P!XtnGQk))Hef;$a;;%FMB+L@9cak_LEo3|oBG{G`@H*$8 zO?s$oeEmp#6DgnfP(plg2A^#vKe+Q{6{Z2)-8y*Kb=vG6l~9l?gEtt&{a4YMBQv7( zuTZ!;iuy&1aJCxwFZM4z-&@S?!!Lg0=vXm6UXWZA#Zp_~^n?6`(}^-|RK&lGa&QJl zhs*3Odh_-lH{<;}{}@5=tFBLnH_oQtudDoZ1AMp@cCY?_JAP#!^`ra2|1J09=BD2P zbtpIgd8{`^qhirtMmXPwFn~qm{o!j7v3k6U&FXb**002txH4MI-CEK^zUz*OnKuw? zz@E@?h=3n?lTHzCwF_%xk&#CC*#3~_DfLkiY7qh8knSrACv~8}2ML5y*4+&eN!cFz zEF(U}@uzIP#lpEITOA}2RgTEG51DwE{$SggPtPrHJ=5F5<2PgOs9$R?qpAO^_8 z`>y;T`IF!hoZv@s1p&EUUVgc$uzx3^#n05>KXjBt@~y6#Zc`!ok43v+GJNVc5i_JUJd^Q2%rTM}k`xWG zv4-v<4BHpf^+y-cpsIXSiUlFDAzaN&w5hUTB>dX;fskY9m~|A_LHy0Y{-(Dy4)N#G zjv`g-eMhLCn4{nde{0*@rWO=!D*U;uu88j`8RJvPixYJKh_8)@s?CT?FAyUg4|S1I zL3bQ4?9gS@H4rgEbse!A(v~)Xs3E23N{0fYP8pGZ6`-9z=0pmUnV^Oj?l`uIIj}dg z{Ua3wE0N31qA6)@yXnrtB(*OLp26|sM4dOcbsXXtE7z!ln7O?eo} zbC(jrD9rSpeUGVszh(VrHxdGJBf=OVNlvthp@+?&x*c^7ScpL98VV#^*HYtY{@pf3 zPGq`;65_g&*h>*91W62i9f(XL>s`kmOS+;jlj2kmAqh6_n?dxSV%!%!l_(Lzvlw=y z)B@seJHBlX1d9kG=s(k9MertN zX|^xQsy@H=toeABRwXuX{m~6EX~se*Dzel~tA@xSzt7Sx^++c+fEgrZ%pr$omQ};x zk-@@MG>i0*yBh^Baz-$E9*5{_#`<$hmQYFsu6-&!;x@iCT2?dA9vif- zzQ~V`;;cV`$BA9b04~k|avWhQ2Dn|#w`t;zT-i0w#>(BbuaiBiA zLF#07Bxr4;nH0!HY!mUQPet%3KeIZ`3yST=8sk0^!))9PGz{U>Po8ZX(m;amXn96? z6}Zws3K^v}DVn0+ecH!&_a|*P!5G2osv$f>Jc{5~YvKfW2p{YW#F~y(M!I<*OR~t$ zdTI&E3}XDr>E5T!NG4oDo|Ydxj9eDlUZfCLx4Soc9R_0%5{p#=@{atFzHN`BvL4UC zY&AE^myMR$47Mt0Tc4=wt2K1J=3PyYcr2hxG5=#MnB=2etySiyEiFdj^KJglf`C%< zp>0>>IVGN;Wl&5EG#My|G%_iQx_qOU8jCo?s50+}QWq??DQ6H!T*3`>AQye#5Joi* zghU{^?1U{R6GNQ2JB>RhMQVxSdiEm~H@}HE43@aSlBO6*R>*~hHweI!o#n>qSme}8 z2IQ%ev0PF#7sHsc3D&hW`J4c+V)0R?BlS#!2=!x>lx6|dsFIUDBQ4>}ZknH}fl_)U z)R)V&t|`xO%wvRV+OaA>3D=!~@|t>hQpkcRvxX8WQwPF(5J3~6fTUFBeX*mM7kW&u zwn=}iX&niQywaxUP>KsOG|H-`wA3(A(C99!zIZqP-ZIi#Cpe65`UZPx1%@o4T35D5 zLP{#A9+%xNealNiGYbWLE~4&C6hY&t941wqED-`HI8>C962NU3>SRhhGB(s@@7JZYP}8YpWc7T^?j9g%OgksLa` zEXKoKX8XrsB%$a?^fbR44u>Ok_$c;yH&Ty%3d-Mo+T_DN{Y|P=@X}3932Y(vwdcOi z>x|UABN(}CQPgn<8e{VQM7(I)zCZv*3%vmPh4xiW-E$ct2q}Av5SIwmr|+aE39hLt zR)_HHhl+IjGsxNZL+_;sn~j0qoc(*U?ThsHR7RFCw;6mRJWCVmiC2M7Ry=zn&mxN9 z+lGM=^F!XTinnhbkqXegQfXovrGg@>$|#4U6hVM7r-;x@Vt8Sy{DUay6xg)(drV0Y zJ|y9)Owv3jl>}(pUSM^)E$XFqvBlN|D-a6aP>brbhIql>L?QGMPC(+!LMG8pCR-~G z8=@Xl5JM4eksePUOh`P@;!jPH3Dj#Wihhq}YG#^cMi%YDQ!I1pT|jbyH*;Tgbd=Q0 zAsCrhHPtpH3XFyS!`!tkM{Xn8_lcN)&<|*uAi(JEe= zAWNnB`UO}QNtq}fw8k4#&(bLpAdpC8o;(?aGzX=@4tCO~xnKjsY`iG+_(p2-LZMul zzOr+m12x4=E8h|Qwu3uDUq$#3d&mYeM?Z%l)|63ru%Wu0kGv_FSeCYdLa&|j1~g8= z@rlT?xW_7QkaH>dMjEFUre$Iqs)g1w3Apv#@A4M1kvI%dr{<(FN0mJ!>#%24=$1<} z+V$pmtiH#Ss_PDVQmmw=-hn)-@R*YUCOu|M_PUKwHiZVQ>NXp?X{=B(MX;Bv#;mOK z4dgt~Rqqg73q;7)Q`=AD{T$uAFFKUE6A`Ohn1A5ipS;T^)5rpRcYS$eHsyqb^i=w~ zUEW0KM2~GC6LLnooRKk)$hvp25hnIY(dc3w3Raa^clwfyf|*h`wEGDva@^qs9SqBBF(|VJ# zO`uFWm^C^pX|Au{kLafBdojnLd`M&jrr)6cSg(~2mnIq2AKkNQ;Dj9Sgw}0577b*w zatO2|5yNySA|dJeeMH$pc3c>?at67zG?nyN4cfll_xYx}U+<8hycwOe{QwF+(lE#o zg$a|sqzmNH=zarErwrNSo>yHyptM;^Yi2YK9&4#b?;nrr(uJ29N*abTK=rJUrn<)% z(vN#Y74SR`F>O)+-71W!7YlDy+Hs@z6@7UqF|Hv#N{VoFNL zO5UODZ$vMdJB0{jtR7+aU@ zhE!grshU+j3^k1IO1c|05?$p2kA_<{2#Xxgg}%gGG&S-Zv`0!IZGFhzPc+nyI?Q*@ z!ryP7+8iTi?|NbbAIQl;O=ASjVc9%YPH4Jv0^ODL)r^+4HilY`>^3ECH2n^i4AN+w z!8isdq@>KEriazpdO1rADC^Ow?c2(ZHc}5An*l8$AFwiJlTOe{( z#43>664zD-G=JV!^=7;3rU4QlLJAyV$$`%xSvhn)t_+O@e_Gb&P31t1*FK{KthRZN ztX;%v4-9ftV-8DsBFV>Un&zMf(zvXKVUCC&T2ZG8iPtAc;CO=QA2`w~;lahW`UU#l z)9dmBCK(QiLQ;lTo`24z|9kInw0AgbL(LHVg@6A|>V7Zbdq_$`dyD^5-Xel;!GcV0 z@xb{FQoeVvnH3?ke| zMo4|h-%JJQ*r5Uc7E5fT^hRbdnS)a(B6=}u9ZC&-U-EHfb&1L5Lh=`Nc~5;{@!8Ld z-MjbST0sdd-=l7gLAgb5;)n(#+j?hswkXo7FD||v>A=txfuR0+TkCz7Rc*1$_o#qC zM%0IHcZsKOHuw9syf+<*7QLS!O1bxQ`~j4+VBSbyG%j*l0V_jj6_!Dvg#K2+0xueD zY)AFnq7bE^WsmK&niN#epl`QtDkph?kQssf05~namr`*Tqrn^03bE!=x*rV=1*z^r znnmc)nIKqjzU!+tAAKNYz)}wewpUN}xZH)<+>_}B?Djp${T$gv%W6m|$g}>xq1U~OVe_xkt)Xp}fm!$r2%nA_A3bdP)a+oT%s3g>vY~mlsqTH8| zR7WSWnIQF&ZZudxYIHv+M%9PxoJ(Rj4EX~cG=d~6tm}IP(>LrLm7$M8t`s#Spc#~msgKtWa3cJO z4M^ptH!3QBrJh!=fcd}E->hH@HDjjANX#-PmNij{&83yly5xp7o$YDw%>Q(e( zuT2&f&xoj})z2DrDAvrHBs`%Ax}>b=FI6*T>!++(L(U`(uYPF{5(^SC%`XjeupZhQ zA#?dm%V~UKC>5+jA@ev? zEBz;QYS28=P&M6H8BMFKaIoU^z&9!<(|}fVBWBzzlVl6^+A-rU4Da z7-(ahWsD9}wk3u~eXA1p4xQdj$c{Gh=o+WE47~yl9V(ah1CGJWOV)vEK#lKt5sQX6 zW)e;39g1zBsH9IBw=>AaJ&6U4J`bw;f(|>H?Hop+{VNjc9}c5ibSb8X$#58YNTs14 zKpk62y~mEtPBSFS`c#kC!Pkxgn$^-i!`dYP;xpYtRSZ?tUW%(1BtAXC-gJZ^5E`jb zRe`y?y3|ZcHeGc7xda$c%6G2fL3&x?I!IS`6fZHaAeDlNxbTbe^ zLRl~~SbJA^a_DiSs=0@VBTLVtgcya?qC!^dsH&kx?-_cO0D44aM$!@^j1(hL`zOwO1On`)eo zzzoaWratW&H0J8@qSf@CH8q-Mj^q2+q{df`$)q6FrX--73HP~S-_-CZn@Vp&*k2-` z1jn&FI2_?3Vh+Pt!6H7IIvcB@fd_*2h3SNBH4Yv9cM;7Cj@v#C1R`><_Me8^i%cf< zNe#y3`pHYfXD0C)3Rlu1bOS?I-9%X4@_?iX*!s}c3F((4J_DgjTbT+ACUc3Qq(N$f zx=AQ8uFs4;`ZvnKH_|JL{=g3QbfMvC!^Y>#rPPrLIG8QuQdcGtn)tz+&zG#{@??aOiw5n!h(>Lva&0t+YshR3 zD6Yw|&~!}g-&8_sJ%d1zwbZACqUoaEu(&Acn9!~9E~ZzPX;+~DwhXDeH0|^f#{RnS zJR0?!n#@=}4c|l6g@9_kBM}%=oNkNhBB%n4|gO2QH zhmM{!NUyO-@PRiBGQQlAf&mKaeHOPf&~>ht0Xi*tEBMJFxM4!<1cg0;JEk`@jmnVi zAs2vteppBrFfQ!0FC2(;zDE z^oBOW=i)v@wKGbbp#`j_70eCAKI=oi>JC{o45*s{y?oEOebdaM0oXOvo?*g&z#+Ki zhm=RRFMFhel(GisC6wR2V|vp2YEU)qRYuj=_La)vW|R~&F0=dMAfd1vWqO zsDSqgxH3BNXtAUz&GeknNWAZkHGn%K5l=0qF3kz(au?AjYIF^(x*dw&Ge-Cs?b1(? zRY0A<@F-_8oF^buVK!Hi=n8Z~j$>j+B4OA&$@;aNk;sYkS$n@T5<9v<8rYZdtf+;~ zkjOq(*hERPCQ4^y13r!K`B$b11=F=JO1+U25;1zUe|P1X8i_l5CW+m`^l+LIR&RTP zViCAIKeDfh0U<-CKz>G!xL2`#npAfe37Lg4{E%MDEV-id@Q2=lhE zE;wM_%J(BYCg^$}`^GdlJUpZIPIvZGjv8MFtUBh&M9(vaQhz_7Rm(H9>>wMSCW}p@ zlFfuvtfU`Pf6M@Pm4XK^4Y@o)aY3KPI-j19PHezpCr~6-uMRRI7nV4Sr5?&2?->!W zGMvMDKor)L)}I~fUELn;-EX9pE%2{y7yy6B^XPdogU-&!U;px9Qp1P3Ep}5`eVF+r zAM&;wy1M-4uYVc4G2h*V(LzY)dPL`MEc+C^M*5yiyer7REr)_c%-X1}>b8VwHo9Mq zAji5%)>eB4udh$zrIYeyB7<9-NVC@xI;>4rwR#t{fSAVh)W&gFy~0{2#dLVS%!l-D zZ9YO4m^Uw+Iq*k>C%L+{0WVBNdzp#k=4L{&5}Xy#!qiS}%zF6&MKpcAr(KGkKouU- zWK|u~1!F~0f0%-+MNAa<(br281QDYHqXsUF7`;CfOZ%r@s~ff_Mx}@BpW3O8Ua%^Z z8Te|_?bC5=T>LU4{;kc(T}t_KO|wHrDAB%7n1szC#!~ z3V3AYlsmC5n`6thmy7XH0(eN_Wr70XAaF=?_zpTG=>2VS)(GM3-`-Z4bN14V|XRewr$eUj%_;~vt!$~-LY+S zY}>Y-9h)7aW7|f*^f~X{`@LT^>d&gGHOC%v)S9d2UOrMtzJR|H#%3v{zB)s_8sW<0 zoO*8{*$)J{{CgC($rSC;c=p5P#`k@;3zvJpLjQz&f@Qx|nxQEkOm3HTnSU&F{+d2_ z*w2f;$E&*CR|&YVNihB$9g_MRONDxKzH|_7hLzeeKTn-NuDDFKm--f_;qi|qb?H6k z!Q++?DviQ9J->Eq)m79!JG(_056fXK$scwbb4e)BX4`8A`D;>?u2qJ>n{Rk2(6D1o7kYb!=;A_+$aXu*J(XEz#7&d^2>U>hwCnoT zvR>M#7u?Dt3^VY7B&I<&$4PZ=jR7Q`0aFB7X>aTXMXm#CrJ2saiQ57J#N=C?m7Q!- z3fD&*UoHGFE?nJ@Uk=(AacL~I);=F#A!zHMk5N~lJI(qluJ~b~uvgSr)mHowHAdXc zkc!Rw)EMUZR8S+_18OQ}TVq&@;T_w)|c%_tX#B*-iL4Dlnhq04j17>*7da4xp|Kjt}#wf#{`}ul3 zUx3zMK6}5vUL1vn${z;V?w=*W9$*zyzG0L@H%OOkl{RSE;M;g z+zMiS4kpAdbR&HhjtWLyYt#&(s+w2m5UO^RTlwtWuK$d=YTjAIc1g*%)4%|``rdUN z8+^VFiRU}KKl~ri&s>#WP;Ap88ur4Q{S+MG=Af558~%Pyf8sWuUZgK8T)`@&WeN#m z2Dbcfs#xJ*C|)pd1J>7vQsUX>ZL8`5O-%ypY+a`SHPQly(W#{;RbW>~DMt19>@MWv zx;cc$VT;Wyf6O%!stm|D2)nUa+Vxc`?N4?9^rh;c$T+ z8g&&sFD}AT+6aa@H0J0q1}qZnD6 zjhRN`S|+p&{IrU)FEztz-1~rqQPl3SW*PNKJ4@mD%5f2Ibegm zzrTn(A2sCN(v7=o9fCm-SeN}K|KNEd^8Vh?);>UgLq#W=>0GKhma$;Do%XROzF!rJ zfu^T*AC_gv@Av+myc&Rg*g#gu*h2LCG@`gEZwZV#Fgf3bzO^jBrfI5J(|(;gwD~8q z1l=*Ds6!F_;zOBKTD`hC9-#7Cje|uBe*FyBu6n$(X#m&Y0I)Vtwvw!m{hh9Iu}Kiy z`81qh6M3Lx)KANNt?A7D`)n6me;h!09KmATdeuC>OWiG9R-MuSS*nLJJa;ZV7&o?vl16+zeXu+A(NV+y=BI+~mff6P(R=yDKF~(0V zPJ){0Va2#-)%~4EOP(}=2w(HG2h;C=>+PjwPX}}yi!u~eGPwpI*?h_2jfM?$&-@8e zan#j36$_7>XL4Hu%E%sRsn9xNcw#D+sj6sRQf*HS%i=gnXZSVeNp~PE1oMf00myR) zfYeqi%?SfKT)X5lOd>dygD;U3^WbfL3~()h1e6v%_7v{zO2jA#0yt%i@v%zQf` z;v4Nba8(ygAkNnlVQ}U269f|}kyq_ye=+++;d_8j-y^T=FNfe&4m_lN7U{v{ePl+e zN2(~z05Apa{`e_kJjovvoL~kNX>s8+UM@e3pD&;$3}_70PP;LZSZBZ*^8&9rt8==nJ2iHlTIrT)w8WYhNFpV zdQnqOogi@S|EVMVnS`I)a-Pvh;IZe}N)*qm?NI&tE~|^so(k;T7iQcptyu1q@XNF7 z?`6BPXtOdYK9u#yeVVdmdiY-6u6<;=FMA;|_=`Yss&30vl%{K>%0!*gak-!(Sl27te$ZgzXhmax1EsO zlZF%>JO@~d!}l#z^%eGEn`_%};@_2*iu2l@ymJMzc4J>DD0c1cMGKfWOx8qO#JuHx z=?gq~o@@=Bi1<8QrGP)LfA}S+d%&MK!SbK5QFFmQ0R!RU^J-4%w&Zclp!%*t}FsE>2{NKDRb9ipC`fWVBO}zoGQ2Xq~rbNlvW1#xjY|aMhp-t)+E}6Ew8$l_K$0S(mc*jJM zcxG8+M3-HOPwFs4AOIw7*P=S#LNwT*r$lqpH}QX%AgjwaDfxncdHOzw>-KU*`PG@-3?|L(T*a_!niShulrrYV{Zd$_BvkH)beN7(24_$YztDSY#xxxI4-#Q5ZU_donpF8z;@S z)$Kd8LH|Jd>IBPQos+`icj8QP*Kst+oH_}4c7nDJ^-@gzgS!jr^3I5X)Y8pS8izfJN1lMxxB z^xPHMzgx{y)3q#NB7O|4YM-I!%voz6J$9JV!gM2;R`Vf=aE7;^tE6O_m|o3LF>6&F zj*8y_V}6YAEQN6RZQs72P}JU; zB>yEBB)AawrRCl)O9#XDI;=`iAA)%Sq+QEy;BG-4s@ zKm4TmOY~``uU!D?Xdd3Ja9Vu#)2)tHfXiv_-w(OE$+`gLv zb#M-;YAh$rwAo1EQkC{FdG5TEcs8T#e)qq~=DgDgZJ0H6ttS0_guhjWQgb){S&k1T z;Xhs04;k1he8)C%PVdz~#~KJtfYU%tH+7A*{nM3}ZjoYD?MXD+*<|D{LO0!PYSMqI z-Ej5+SRI(=<3w@C4Q%Dowu^6%y_sdf3_BctfZ#jCzT@T+ds~d`W;f5C6xv$2{1L0$ z`qK19X#}j#;3KQdcEoLH&@?{hM`E67a=HUs`u-hGwzR6#*U#(K6hHFWSZVbLeARGe zg!bjJ;Z&a6h=Z7pm5*(GnS@J6wJD*}b+}LYvp^I>5>JY765mg8eV9P)y4WE_UeRD1 zL`(+;PDc_f@S7(15sZ&2?PqzVsAdyBNYgg`9k#R1qN9fD{3~!WVQAIJlu%>*d8_nw zF%#-evZM0UsG2umf$Nhqw5BKqYO~t1RMZwl(2se2)BLPT<*OVscCl{JeP`F*nDMOc zk+D4fvPvay63wcGS6kxY@vN&fovdK9qej!*VW!5e!;9jL?HO6v>gN?yI`c@*5V5Yi zQRYAy4;9#&xdG_3^aMF#-o>vyYl@?~db3Zs*^R<$bs8w|`)%GDBz)iws_$Ohl@fi+ zRC?DSz+>dvrFc+WeJ0|E?mCklkl(h{DepxTLFsRnSjBSDe*BZ;jpkIodokK8)>fzoB=@DSX9pJ8Y^duFmRO)L-a=He>^6!#@p0LCs`aK8FlA8g_NO0Y6Pdef>nKlgUwTcnZLktpovY*vxctMf(w2Xk9b&8i zZ1|@X%sXV6M2mrPTR-73=%%EKSc4GInYBaFnUWNL0?jVwfOX2pa{~F4ezUzt@Je-d zx!wyn^jiBy7+9wnoPT(8~9pBy;XVW?@kHY z$pu)U2XGJS2}({lmBz%lM|(puHKC}PP%e3{;qgr9TcQeW%OiO;m2VqN)8Q@Umh#S1 z06jEUaT1mlvm>If{SmIYgbbU*4VhrG7v+v{YLz^KunnFHp^F@*Q>n9ad8W=&hzPO} zZ2TPL?fXIV%%z}nv!|||EseU_wRJdCmIoFt8FRXf5XEPfZK4$uv`UIxvF=6C}yZRWRpL* z;P+BzlG~yWw^<~=+p4=dz!YNShfOW}AaggdrvNXYvT&yRd(R7N;;7vtP#h4oswV3^ znT2Az&3*ieAGf>3ShV*S)yy9YVeJg~>`UANSHO331x|m}EDiU;{*)HeJ4u0SKXp|{=I1QvxrCFtC~!41#cW?&4QkMs zE^KG3JrVwDRCP@?X#V2@4`M*>OzOn7!`U=%f$7wL!az+qVGo@()Fk$%H%#zMg`ugH zhp_b-l7fK!+RM1$TvIJFjt1}v`+a%~!7R>Z0!LNHZk)q+WD!X{>1QftTrp_aeA{c; zTn1Cx4GVQPsd&Wf)zBt>_M>H6&P}nFI>eSzpr?GbWj9SSf4)@I-Q&e)?d1j6?9MqL z=?`L?0g)^7Knn_q!S*%lP>69UB(Ap5bMHR397Yjg;1%+rVPxyzW%-Kv-jv)lOK5LPyWrY zr5>>-1?rrF$fuGxY5NZ;l0frhYjR|)g%NSJ$b><<@|mFP*raDtN4quCahO+<8Mq#} z>-*EX9k!Z4T7bm^aF{xm{c35A;wSuPAKQ@aOlA@Tvq?jy2;A4Aqhg|)BS%>C3&Xc_ zn;yD2Q4B5;JsL#`OODr{z*Ce|xzO>PUxHKg_UdAWIgJ;tgie>QDePi;mCNe=99`f;%iIhE z+|LQRf^tyLSu$eGXGpiyMNh+n^cj{FyPtV;pdnIztuNyb;(@PaN#3+%Ouq|$`d4$H zSl`8PrIL$#@@kb1Z6W%%s#8@@ZwVKBb=W9ToUMAN8aMa2c*}UTT@}+WP;KYc zn=4^*jF|TP8PdtebjQEaBc{{>eeZ3>USq&pW7jrzvIqaTs*d@FFXP>^H5YM{KWJXt z5&VpoB&Qs>KbsK!97wJWJCHB!O4?yEwh5b!pq*){SDmx0)%A;sCuJEqiRFRK_#wg2 zBzGx47L^30gjT#YRaQ$z`e_fL6HeYU&NOArEZ|Ys!18)S3@h-ZZV(Ud!sKhxqKLq# z`g92OK{(V8Pw&LWL7kf4TRwVFOyQArkfqpTAao+AHO*9`^`ItW-IjX1sTqV)hpy15 zp@!t*2|%N6%9)Ean%8*ygj1-s>CD+YiOmxqS1~pzg>M8^5%?f;Vu-0!`h6yD#zaz6wMdXEEKS<3A z{+_%pX$K-UNAJdH0KW8VY}XZ_W#~Xn0pr9}Pal4Ff`#RO>qZCfoYNszNV@m1K+6Agm6W^-B8HWc(yK z+azH0%Fn$Zj;1*j@XuoCC|_uZSPwtJ-D!cX5F=ynQtB^4DWke zvVSk^J_WC(Gl9!cj;{})TiuuV-(DW(2qkVQA0XW!-br+SXKBCWQ-d+zfEM6@?2Vi^_kol+q7e+se ze;ms7`^0VilIPVhnH9yRd_ay-!_;`0`0QpVm-XZ|eli7-LSWX-M9uT|oBL6f9?H)R zUvZ3zNPWpsHa+pR6SGxH{&A7vR%i?4!EJG9!#HYqJ4DRbDcYF3-ZEri@;9bSZ`;?_ z`a@F+W~pyFH2Nz&Qb0!Y*RVgX;9i=IhGurw?RN+07rvqeO@txWQB9;H7#LX(1|;_4 zyVMdf>>54ki_k)IF1?c6hy%Xj5eAk`xZf2YaR>zQ@=g@4^%lxVp+`Q^uvs*?^Z7xc zQY3D?M}koV)WFheR^pREE%z7!#@x#I+W|OHOwt!@`8*cjLn)6^G>T5~@L?lO)L5Z2 z#|X~g$+_S5zx0U%x7q1{wT@J*1AlCp&Z?c-=sEp!ILdQv{ z!%OaV@S`7a!zSvZ2Xe9=g z+iSt~*AX;W3w|VDeGjrRb30~OFs?3`I=5`1#N_ zDGR8qRnEC+b<`h5a;@4p%q$Th`#Zy8T{0K{j+;75mss9twq=msQdKKkeHX)MXgV&v z3dWzKKLjG4XT7fThZor4!@cEopiwT?rHYPIBo!-T0hpX?7CMt<8?N8P!D9e;$Q&g0 zBHPCoeZPG#umn&{B! zz7*7KQm*A-HxH4cuxaEk{G{f5*>)QhF^wHffSP?~_3nO*X9il}Bywb%Fs+!e?KNMx zK7*D-+eIK$VA3bw7f+>sEutKp#=YH2rG+a&^MjfcSocrBvC;R6aQ+HT?QnzZMMf0`K_5uElzJa91wb?DT`pYgw*PWH=00a2FiJSe7?v87^+0B!) z)~Y#CVPh)4qvrEvRk4F8r8eI=`v(qa-kCLfJ*SaV#*{^#fOX6Y-oF!dGpA}7b3Bcj z&apxw$EA{3kYUc|Tn;>kHr##tB=Kip(eYs;pf)^`D)btau`t&SatFQd_wg;%8?HPc z90`S(Oh_U)4nW)uCWNDWnOD3~7$jLZh3m}z?cZFqh9y&(Uw7xtU6{k^!GBi-Zcl*~ z=X$hkH`~uWJ3_o=$WetWB#220%6uJB^2vlG?Lrg8k||f#PaO=4ZVmw*8UA0_D9RL; z2*~pN*m3jGKN`wC$~1@8OQ>kIJHsVr5geT~y2zw&D*iLHY~1MT1f9J>#dUU0(QkfP zf&(vfX3J;D+W!-$&?A8n=N}6Gy$L96PR(V?Z^}uD1(dGRG2#>*nnCtk1~1woRr(Z< zYum-#0hLaw-9SC`?YbYA$lz)MuYpQ8)$1W{`sJ@n3Z&716`$f)?cmeNeM!Nx7zO-% zE+H4YkW-*9Heu1*t7(_}5J%sRON1d8}I z6SQnUVnmYL02IhFBxxdy;RbVW14TE-`=yd(BoxZQsDBX%k_SqGCj+RviB)h+DR!0}N|Akw@r3^T z_jiUQBXwIUAa^yV0dzKl|E(%v1 zeXfG)m*6Y_=SiuEb2l+DrI1{3)+QEbH1+r2YqvK$B{wA$2uxuAhG!4Xz>s`Nvny%8 zEibi@dI`5n$zA!uYDxwCKF1R6GcO|l{LUs)Ti6VKZ;NpJ@H96QcO+W7x4{!I{enhW_%@s}Wh0FrFL)kAMoD1Mb_9c10w|%yd!H)WQCJs{| zmN~?+fEHW?kldh8D3BR`f>6pON{xr=Lw7(kW${J9dT5+6@1lsn(HAS+013_q2(!uO zYhyqQBZrW{Jse_Ls%S<_tdE4{4QH-$Od|ce#0H)(%s%V)<u!XE91jlmmfqO$u3%A^%F#Rwj&W=N4dl8G3j@*B_^X&oKH)$z)a75Q zNu)0x3h2d>z;V68L6sz0$+(KMZ=-2M=z`RND&Xatpi0q<`g2R~@!u-y&1DhlAo{)s zDz^eka|#T^(i5+eIA4scrLw&V5muO=qmSf<)${!?Fi^p|bRkz(l|?|HG_7RbYuCSt z{0;k548!@&U*IS5cQfO&Sv@blz9(lmHu8Nnnvvr2$PBu`SLDMs<5dymK>T;vOq?jT z4xywX&Ozy}Qj++KRAO|Bf+7XEA$7k>a5+F3x#U$qhHYF$DicBsu8Jz%(Vr8bi`>y+ z<1BiTbcI;MC(38)Y-%kEotz8MQWQHlgZm+8Aj+8>TuD`$V2{2--LFa*QaPNivbyzI zUqCHY>C6uKZ*2D9aBMxB>`MvOY~6#+UeC2^szOFI`MI0Z zN^NVRZ%EsWAN=JR`vQPb-)|543VBmm{wB!(nqY^w-z)g3Cgy2!=u7Bl<}MYtdAdGG zd)+U^h`hz0FV$}F55#;yv|qt3;`utM4GN-0XN1yyFUR7RMP&;of<$NqX2bJP1t|mN zz_S5d-I&fe%8#T=&knn#zYlp3n!p37zA3W97EsOK=rS!(N;sM@^$-LXybsR=`hoUwd8K2_ECPI(F6F9y-A)$u1}(O=F1$hFJuXc-Er(7_lUTWoJo$Iy=KdG z_!UlfGfU|$X_R+&y%f3&i|u%9Zg2#yipvLYZIciB=t-iE%t~&McnVLzuK=5D@y5h` z+<|WYa5s9Fm!_xK(8Z)$EcEI_i!Vu@DhXhviMl3o%2aFBU%kP>7HibqzTs4|Ol$#7d&qGOTS6R~_$axx&P z<15-p*k-doDmkey6gAt=qAik47ZVgA{X z@nQp~1_RLZ$R&kJ55XsZFW{vtP_-GFfM*-Tvj6f_^-!-#zbTp}=BXJ*DpyWT#g)HOgm&OWQ=)Vk!MWl)5sh%DC z@12nu6eRV#4M^t_T_LE911DKhVfy5gX#6^IB0N;&5e|2NNU;!;O5M%PuY zl7dMY%M&}-f&Lqw9V)S9oi(m9Dgm&?5q@o8E=A@IW$9*ihReXipci{FjVt}m`up=ZI}#bNg$(Re z^%jQ?CG1V%6PzP{s)0$Py18ytppfG0`lDsMFPjuB@xLVHgXD^4K(6n(*&CE6m6vdC z`5%fh{g_!S#S`Y5H;$iA2vzCADATZ&9U`^e@KylrtF z9vSmr0vzVeE2B+*lWdQ)%j#@nXDOx!f-Q5WzYeotzMQ3RYHEicZBWgyyoG7x6lRMJ z+;FYQ!Np?fp$Z(L4v>b3f8kABrmn_jbD8&=ucFGPXw*#mF01G0vbPrdjZ~%v@F|?V-f6#;H(a2G|vC#O8{p#ogyZIhAliBG+(vIIj^GU?s^F&!G+9M(nP$Uw((hqj<`9CjA_P^ zX1w}gsHaYeJpL6#4FNs#J?K5;VJngmp?>$^8^9O#!i`$i@)iTX=~(9_2sXF*s{xXe z_&;$>kN8gn}%Ul3LvBtHnZdsT|_BmDP6({u@ZtzEbuO`Mn99bDLK{-~6Z( zrtZZ*Ih(H*{IWypqV?_be~-^YM6ZkW-phd=FnbUl!50)g$Tp}V>-L3PuKA4Ez>Id6 z){N10n*??%J7hmx>-u^Hj!yKjA>>FajVvMzaoQLSy!y6QLx+vZ*|0D?BmWzFPIlp_ zU(@i7kW5@g&g`SdOXDt_15PT2=hdUzo3koL=el9rV=JxrK9mP+65EEM0xM;2uTwA$QNaBS778Q2PsSzMS5Z{$a z?xA9RPD(XTS@vUi)kP6C8TXB~EBrLNyI-e}OkTwy>KgU`84n*MUmV>rChdQwV_4VO z+W$L15;>tmmrT@jWD`@uCj?KzVQQ38%I}?wF@l+pfqm&&0PTPqC8WSxykFs;@$dnJ z;Fwkz%l~s5RDHjv{%bafe5voKeX!&&>V=)kFIT*FT3Y(-GFt1Osk`8t()`z_zO`QF zknH{#)gl$L<;5U66q}4r!)0POvK{=hnbsbF{!?eRy7s29z%~q7EO-NtPVi53+d8~s z>)|vE_4mia-m3#CEtqOAr==&OFEOE_s0nzHize@PYYd(_CJXec3Bf^LX2F%0h>asK zbwZgVEfSZwijLp3xWUGpI{W!lCZOl{CO&v=Pl^O2^>(X#jyVnU`AT_krh{FT-zX|b z9yO^@(UCj^asM+298yK4?n);l>{%ugtUyuMy&Kvx^ta(f8^cq6Du>%)x)}C>^ zq7wxbJxTv1Zt#MC&+|%!#8v_>-u3de3A9kw5ziX-pL?^#l2a_$<1Q?pi94mLg(;z0 zy=gS~F6HL^t640pi0UBTAFY@NC60thRN&Ouf1xaapnf!b^VK!NRYz>u&=Bor^>6TB?K`EBQ4 zP8D#QSWi7(;NARNOs1MDH(?e%pGHVMxCWr>L)C#}s6=iSq$pffU#gD43fQ6T*KCbx zr22hJYOq8(+G1T2N>fNH)&8zR6nRUpZ`DvQL_6L~7QC1oFa~Xi+)olL3OMsYTEd!V zyuiR>0OI%v9TGVqb&xne5LGG-i{HHwHLS@l$-xZu{ zd!&oaa0aK!^Tq<%whHjFnjSFdJzQ(`W|&m@Bwm-J$UjZJTHvt%Fiw_9LD+iP zB&)Yuzjx(=iOL|WYO~euKIuMpH{3x)RTxeWf9sin8Ek{y54-TV z_yiBp(yVeP3nEgv>yS>`$w$vKTlO*xB=&)hQ6D7}e!Ea>gt)p|_GMy>&@3~Z0_WBv)6n;L@|9YfakMQb57vgZEz5Hs*5|dXVw1jBJ zS|l|K?Julu@~<{j9hvuW8dv-)nm%*iPKKcYJn>{rzYOA~Z?LiWnNa&sE;6)g8 zt~eW|C@A>axJ-~Ak{^74Yn{Ufu1fh=bx%H`Pmi@zs@-|^ATM#J$jan$mS>a8b2${A z=xF)K;B!>cHxC(FV?u;Hs7|--H~ZgeF!$?`-m|T*BEzPtd)FU$F^&s+Mg|dHgPK`i zb)VtiACH+nj6Zh~$y~aJ&UE#D@ZPV+3XMo2)o0IA zT&`SgwYJF>xhMA2R#{2)CK%$=2~C3K9N^2HMHCV)#*;T0hsm>ZQ!u=glb4quyic02sLmkY$-lArg;;>_wJkZb9W!DLt0{H- zqx7@ccjoN3Z*X1A>OtNC){MrCXi}5qG22B-1)T)=)1aGE-?Iuii9tGOwTV8F$jDV& zw5Ctt1ih4mFmyL4C8{JrfP7w6lEQeTWo7l(XcE(PGb$G&-ol3bMEk@dqEaO1(W&+` zrDQ9?I1U^)=b{KyCPqfMJU9}bvy>H<;F&IUL1IBcyuy!NKy`+&X97hKE-ODrvkf~~ zP)UXmq%_Pv@<#eZ)<#rL=vDMx2?qwae2`{sknDli@*KKDSAJmu<3e!NE05kFSHSke z_EGo5%~J2j`kqg_P>i69Kh~e`T;O3h2;tiP8h?nJpu*TzSQ$-Sy^iac+xGZ=B|rF` z_+q()BOro_*NtbEMK)=u`e4Jiq!BEGtwhG^i#qen?OK|>#7IBHx;48%?=fViVUF$j z7%EG!)wZCI!);l^AmfjmM3U%VZNX$bwF4P`?5fKESC$di6}9FtR(HGyeO{nj?=z6Q z6}Q?lZXZbfwz`_CKXh0b_hb?$X?V#=cr(+(U95CMjou+kvemHax1R zl9KL%E&h2J>|~g;R3>*JC|u^XNp{AQO`aK@!RX9_+>@VQY0e*K<7$;kI)J31UXA1l zm!I!u^LxI9%4#2c9C5v%7{`fzi{xwf72lY6f4Q@ap>zv1N*B$&8Ls!+1hb~OoRGnI zr*&eoXVGE!z)|g?@-^L&m5u7P4RotrWp?edAEW_~lXB{bPssWACzBhUlAJ^4wQac8 zahg=VRhA4VT|>VCqOTq=)|YrWXby~0%aY|k>S;{OL4cbsE%Z{M&Hb_c0P4l0HhTp~ z`?QLKx;2|v(h|2M2>bjkhC;bL+?IxweM|jRyGv`^Z?Hy;+S@Y`A|{p#M+R4rTkf+M z>El>ixyV=GKXu!ex%8B+2b*4uhFU{B)*`IBh3z)N;qu^ zAu8qh!?kI69|DK6{-8e^qhMuHi8jA9MA}b>2Oh)S)mp7*O0+5n}sln zvq!EFCD$t`7wtwpXoCvOPU^kdfQCY>%d*S^zLnnT#WUp_*E++)D;kvFExith3ZymG zf;x>E7)2ktH&~Fx+^VZ7iFNGTl&o{>B~>8%v80o4=g#S!k;HXL1~oAQzEj6viw~Qw z=Z=-1vl=hjd?WiM}@t~QB{MVa0hpD+BWg*VI!Xs3udNAwY7I8O4X@lxHfMKS=aK4Qb8x$_?d5CW} z^y_Ws9Xnn*v*NY#?Xig#`H@}2PoT7czW%sD+j<;Hf_47sa6(`y)>eoWk)6Q^wWIBA zQNqVrof913cSIQudr0xY8%H9j8b6WLLT9{lpPAw)&Xqr>MPm|!0w7yFUTu=gKk6o; zOrE#B^gY!UJKJqi4C1c`x3js;-=3!s7SHKrt943^dps3tXwFy$e85do8!9j#<-(() zWh;0EgNblMG|MjAgj4Dbo`?M!W`I^#JFQ>Mj5xg4ucZC1Tln{HOxHgpA`CN6CI1*g zIS1j5#vyJJ(GYTW8O*OOz~cffHbQS(RYx^Zo}%z|4}-gi6`j@$WJ%h8kk^YrP*mRq6-fTnnIY`zgNf~Ck0hkMdurQJaq zwvc*I*P>08$dH11w;l9|z5*05?r&ILB&#(VsoS|dOcMXj^73`Fo00Y^s|yo#GiP7F zSltW|QJY8``rzMT43OEa>x7!q6oy4gz;b@_Uvnkd$uBVP;>^+Wwqw70=V(i zioAjJi8GAf&BZeJ835ly?8p(GtrQVbU4WwSY~mP5b8bs#FrrM!S7X1MJ-$ZG91;!; zX?#qFgCI#9MdfAS2af_UT)rh2RAdad0g1gw&jCu*i|l>0cpMCEZV@nYckd6yi@ z9N-cE1_IG|`F94r)6Q3J@ETt0SBK3%q*6_(VLb0$;tftzH^yabn#aAh%`-KVw`}4x z{okHvsQwys)2V5ER*e#<$pw?H`otVnOk=5}sYzKw5&fWq>I+<5DJIQQZM|g)9b`@9fBa!jc z<4BcB9Tqxgt6YoDGsNSKePN_=%v|=5wo&Y?+M6~5MoX?rBy+k9(^?w~Ag{WWgkvcdCvjT#|@)v(T;Y2$kTTgW|=^Z@Y?* z_aO7VZQ_STh>PIQmtvQe^52_?p1vSX*1>C!5|OU196YV^BqU+9Jv)q%<3zo_y$7-? zwOU(68u<|84_pgx^rymAw`g9!2$nBrH0*oMJs5#e5}Ul4H3}AJ_s`8M`3TCegjb5T z=TP#*XD1gfj;W=MQwXp(3b}AspGG<}9?lgSGYtD2q$-ub^BmTVY40Rxla*4pNWtt; zf**@$M5`nx?m}3Gw@~d9dU3x3M7am1*iYUD5qj!Io!jBb*i$2OnCzdCd5F^V0lM9Qy&m>mI4PNBg0FUZDCWBR zA+=Ryo{q0q4cdd;dHRll`o1d0YM(i;jF`qEh7%6U7R|inoI6vMs@DNSj1?Q!%zSe( z%5h1&FCV0TGo;qH*-dhb%*vTu4ctg*=|&WZpW7R7K))(v%=hJAL2ba!#4h5!$o)Fc zG0tdHuQHH+QZHGHP^*u0mlww${HZ>{cq)i`)U(~9X_9kZ|9R7@(`AAYaY>=N%C)*# zs0!beGuD42z<%k)g>Xqha_rHht-+Lve5J>T>y|%R*V);=RXS{a`C#y4(eP;%b=ToR zfTEO65f6}w1s{3okWg{N76X-b^uY7ydz8 zBp+`?oJ*{2YaImkJ%@A54(#dKZO6tgj=oyk7MMu}5@3Jed{u$tfce=s7OG-VZX{Q6 zq0&!6sx4G7=I+K^v5Lvv0pmvI1^Zj+5qt@h{k1Q-{en-|Z3I zly%JS4+k!8M>Q)((7%`ycv)p4vmaSddqQP9MHY`o2QC?|k#zSk)b9>Pk>w=eev_X} zfYol^=rWLb+E_;ec(5Go<>Vl-Mw`&U+%WIlm{qr0v4a^6;o&LK1;LR=>UWl^AT1Qi znf2FU^`G$_Y+KkuD%+WlC5TUtTO8}<W+1hpL8t=5c>Yw8s}Sfj z*TZ>jV)>|J5JXtz6#ddFtk3U_J?N5axXU*fLxWpuaA^=!<>N+kG0Sd(P_xk2J`g8rSyV2iL(G+a}vIz2D@RkKl6 zt@d_`{S6AE4#Cto-#J?4*OlszmgZPZTg*aTn!RZX_8tX3LkPm{0YXdXloWfC@>_8k zG!Od}Uk(207Q`dcd}E9E0*XB=%m`FluD3NT@X(IkQZ|EhmZnZ)0=F?&s6Yg|xQz?7 zj#tmEUzvnjG_m5K8j$*%t(A1^_hQ<}LAO4ZH7m=FtlA2^S#141O2hZbS!{g^kRSS{ zPHe?^HPKm>en19@nn-$*&kXZLzSRoDS3~VLIxn$YtY^W6LeGX@N`u?HJ=J(xrFL(} zYP_0uov1d8IF;K(LA_TAJeq2*y2Q2QtF$ytqWQqhGHp`}#VR;Z6@;ZkrlyzSK2P8+ z&U(e1oPqEdH@N&#*CQ6d_YXRo5*SWQN6_JzT8&bm939(XV}=aWjGIIiDR*%7@5sQ9 z-4QAP7hB6w9a7Y(Wq0f!K_0x#uX`!x7dJ2~Ymq1bUq)WWPoOmZQ8RbCj~(0>dY&0N z^C?Z1O&u1yjE%2Ox*^wTOdjA>jo#?t!mhn`9ljO=sTXVCJvG$fOa;A6zDY;(E@iSJ z1jWz!*pYL*z0#wDwNfrpH5j;efWuJhwY-CStpDD?!wE&)L*7NPO9i4GY|1rJg-mJb zb_OlnC~^sjJQ??gn6ud)vzjcEUP-OI*x6wX)?Z~Dk=}ArbU#2a1rJPF+Nzui!&AYW z*Z1~gk4@kxBKu<(4mqq&nnim}xzYnXBwEg#y0K#Jgr%~7JX&La#K-bJ1Ka2B8xg^( z=SY{`Bv7VL(;{7~*2;ju1a+colHd+SvJoNR)Yq^_T!P?=&f@!0YO1!VWiR7_KA`;Q|jfY}KumofdnVwDBMlqiNRE zY8f4T?oUYVH#lNsskH>@xU4CHzutX^F$>75sGhvE@N8Y}zPFy` z+ll4B#f|HSdP*hhp>}Pl#G|zmZ3fzZW*)h??BK+GKIpIG9-p9ajUBF-_7#=)@d!iJ zUHjDa=-9f+F8Q|0*Wg+cvO%8L;tasM=a{D7hS7Vj?HEnKE`KP`j{o{l;&tOjxUPII z5Sy}?j;uj@N6{}`Iw4j+^4{U~#qX}S9>HyvOL#DH)nBz@t8@!ST;t^>O7(+v=PTzg zv{5tS{P~&yO+<@SVS~rmlO;ry|H>$3+L=|(t~1>bY8`Y?EPUaVjZW^kQGCB`;i;@r zFnR8>>5Y{WC;CTNNLMh_lxE+^)bQ);@JypLlXP3l^J$?v&K|$4sl}T8^_EfOAlRSn zZzHU$w}y6Mb3z|k2$F0Hjj~6bDkvQ96+L(6CGBH2bdcS!bDZirS>Tl5VeZNmsThX zxsWVRv+*&9eHVz+4g2;KK{m2aFQGYm1B$K)tmN?=jZuA$l;9!p;5>=$& z0v%`Ajmq(uo3?`@?zKbq^#F#JxBUdyBXIo@c;514`zKn4ysSn9k-ct;tLlT2fYR1e zrgX%&sM9LzmY@Oa64kJ&Nf*#;mv&@{4kzp~P`vFd}U7WXtX&%z;E275f zQ(6=xH$s!qV0tVbS`Q8J02jX4qI~y$>Ism5HAw<1CL=FNUVAyJH84%e+d{X*_t`ND zs}p@jtS~N#@y-k9=Ct)lkxS{ioQX&1hu9Y(b%H;JhU7Z0+wHPU(<6hvX~QCa)QY)a zb)WQ1|4#sj6nE>reJSF=Fq+P6kit3Ahqxr&?xs~%JH^$$h!%N?Y(*Il{VvT6nFw@n zIMA(hZLG!2<*FMF-*csQf5Rpz0^K|FynFcRM*^{?h7O%VrY@=bcxi`R_g{gM62R%ZjVKQ_oeR(63$Yj zD1z`r)G5R!$Ox`(2VJwtM2hZ!_BF7|y6@N*dOoM?*nL6_{=J^}(|TH}-Bf88M}ZH; zS$+7}@@z3(WGQktl<;gnVE1&n@o-Isk@oJdyKbopWNOgGL&4Y3(7Y+?S~CwWJ2(bp zp@f=kI^8PQ-`4W&;2MNtBaejRpx=uNt|&i8y{JsUBKWLzvcWwIc1#Iu>(^o?usw)ybj$AD^$I&p>i%ObG1`JNA96wQ%hK+%WA4V7W>JX zx#2q~k%BEAL>&j&w$uke8FC*WbhY+k#GwaY!0C3kB>gs9=bP!`d6OWwtxsYYxnul{ zeWijv`$67#I@6>~@9mQXbimK$c5DOPjl(GoKJf;}Q_vTw)1eE8LJ)GKU6T8g6$f{1}SX!*;Ds74nov8bVq9XXL8#&@_V9 zaHJ*)Y|#B0aa#jJu$m|KlEuP)PT<3h(*DGA-|dN=>gsFK<1V$e z8+vgFsM|l_+Hqzy9XX{;Uul>NnNOmgA zNA%)z&b&@3HbL>gaMMo0rhIK6XM31t`575!v z$vOzPNd0!s49~O?BKKJfLxb}~F5x$Jh_{)}lN~Z}Ar(I=(^-lw0kT)Ams0y0CxSk4 zj*0m(JGw)>Ld6m1ec}Rr?GOUL7cpk&NgVVTdYxBxL;HTu8}C0mWbpQA>r~%dXZOkC z?m=Y_2ZmqE-m^Wx1N_`c2Ro z)FuI6%41P3uvtfaDM0n4&t#4ti%Xp5m*b&|OUnJlIrpy!*JLs_!iv=RDl>#>z**~Q zA0q2ylV)N(8RSak7_U-)Npku0d?9-mf8=P7Z1hh|-i42Sb5pWE5VdKf zFi`uRIUo}jlg1guUN@WldA*&@cgtCA?Q-?9-LNAsko0eEAm)Rx*=kfDF4rp636`behq@$d}G zGc`AOM|rk(Fgj-(QR7B8gCWm<1bG3*y6sloyS%>e1)_F%{Wy;Le*}4f-p<31;{Bf77d9|c{kJuiRnz$sT0FPM(uci5WPW%_7wpnL*aKR_pqu;FYb27`#2Cnm z;&<>8#BT^=xR1+^%N^#Rr+@v|*Dl@`Us}A)&7)_&TL~7`vM^%)&={nS3*&O$g4&XY=Cx|^?%BAaTr z)zT?5HJ4Z=_UP!q(}!SCiQh8JV&NGw`>Ch!{m78u0pm;v0c6{qzL7iMAkqK2c5++T zY)6zuGlXNwE?vG+gAnP37(VgoDVgW9T3^*>uTJj4R_-@kE-@Pu9!T)U>T6QQwXL3k zAA%9115&2SuE}myU!X+ugF>$M0H&_WH+M+nzrFy^5Ta?QErnWLwIf;0*4cWFREiH+ z44$X+pUPyjDC{@e_zX0#s9XlBG(P&OUf8=&a{*>(>XIQcZkV!J($9?IIQ^;tcJ2D2 zS0Q3Dgp*9W@37{b*95#$xzYF`yLq)w)pLw^_XHp(RJYrop!S@aoI(KD)#j5&8=yX&b+NpoQ z#Vn^%fu{V1~dhtDQX)Cor<)?vP}B?nA_(6*Hu$iikZn zIMZ2u`7kROsUHRLCR!SiNeHgdOl61!zb%FZseMBN#{2-DvmmLCj5CO<(95}$uO7i1 z!aD>+!tBo)*$*-ps*4vdjg35|G)IbazzjPiHJyjXBb3uqY?2ic-l7(JD9ji8sp74Y z8-*vlVabim$V^Ae|4AXD!Rd4{#DGepW+!_DFFeaeEB>j+z~V+|My<~va(1`}4Z5fJ zUQkW79BLlzvpLL-Ei-!MKf=$X!cH6>LrYtRwue z?5)U99pI}S6o=54Lt}l_@&$u-9nxL{<5+H&BfBMd5WL$An^^H+Ti-a6JYEKE$rfaT zBVcvGm9_|*KqBb8Jgo9{jwB?)KpL!ar#_ni3VRnacw($0a_vqm^eCQ^x-K1&j2S+9 zrC`acTs_4lTp1jqK-ar7xafmx6s^E0c>7DNnLJz>%Z~S`B=%2Ky;uwc%^9IR7@0Ak zDTi!oPIgGjZGu!|l{^B^s?_&gU-rDyX>@-nbq}=7rKGCRl@VGheO2SizD%gW10I!b zk@+-)y_;gG(aP@x55X&`P9jeL4N}!K^7S=5EeV3_n&Rhe0bbBlh8$>+O9piODHPZN zpVaZjEeU>`M-+h0cZfjOcUs0!wRu3&xICc1l4mE!o7+#4b#2Es&Bg6Nvsov#J-=7^ zAh0z^6^y`_Q1FO+E#L|JDwK9O1V84xRWV1fGw92^a*J3C2y*2~*-5=0gJGO6bugs; zL^u%Od&xI7ej$b0LIYJ*1TsJn0vKkiWP=vs*E*=GYGhN=I8|*?KEKQClRy-injHB= z)lN8uqBT~&KIX=Qkb;ZGD&SE3Py}EwteXYco@@{^sIg}uH;#2%k*$l;e*O>%Fm~2z zhk)Hq6axz!YvCaoD9{Ek&~Eq|ubzioBkRFg;ib0SMNi9Lo_LXbn`pp-cqKY;sr||; zhwEr!Xjh%Bj!0rKEo1yR7I2E$;$>_m`VMX&LIz_DKH%*ma)`pgYn9oZ-5(QvWJs#Q ze#5X7@V#DYkPROURa)hUdoMyT%i^UsCc1nx&^Qzbo5YcXGOR$@B!p9Db)PKk3Fe{i z!Pi4!DvufPHk*1s$klzYqv}PnQjfBaQ|O{=Rf#QXp`n=Ew&){vL$fLH3gE&sMp9F<1u5bn zyEI7XsWDO1rE*`Z3#W=_Y#%#;j;sTXp6H8epeTDn93>t~X{a#I&lrL=a>5V?P=JHz z$cLNE`VMoIl&1&`w_nce-noQxEJnO10UWVAGzVP5H*>n@h(%z8?=TL@gdcnzp@=OBr18XJ}@}51HbSF(@PNyhJKS+dPAX~ zzPWV}zEjh8JQUweCg8o$`%~0k!Oy}Ta^*n|=WsVLe5*z>TOyaiFdwogd)!kaFg$_Nni|qX*=8}0Hffcc3Pp;+J8z*k6G5@t7g=#Jb$)K{$h$3f*N47n zV{v;4J2=7R>3SR3T_LzDQ8q^(#5_m=@;mFa$T)*%GWJ_7CQQQj+RW7VZLd8b1zddc zEfPKoN1WYUGJprH`F5s_Y~A@ucraI|$i@XO3A@GCbxiLM3oZ$!dHYF3z%#b}%Q`hp zqwh;H_TIczB8F#r`caV~j zh~~Ws2*A@Mz0c4+jO50~5-E`k<5h6~8U|9}8&O?Oxh=ud%Y-i=I&n%bGr~O3Juf$6 zW025#<&=YE4oxp(0y=&UX#L!)D;Io0-S4gywohXBB4@>(pV`y~Neo~gX zeSsG~G%}+Pik2MhQl+SCIUKOcmh*Xj&uku;GBiSX#o=^ zEkN({G5{7v7=+Hk12=}B;0D6PCV1=~_XZ!}ir9RROFmJ|2NN33;W~$& zEDAp`6>}silbI4EpGe0Km?C>)SkQ4)j2vM?2hTAxh(*xH!%ticw&ilj%mf2Hgk%Pp zAQj0BGWG>1q!Y#y9I|{SaFrW87d#*ZpW!hWcCu`Sq4wtAWSf_rpa`y0+T}`VDasMp zr>Loc%!2QfGnqZq>HP-5H*s5Yx2f^iSnrS!b*@@u5BA*TL%!aNW?SYD>3Xp@@=yQt zzfFGnr+@kBpZ-_%AOEEPBTbh7&wrcz^e_Ke{p-I>e){+SN&V|TPJa65|NWp%V7TfB5wu|M>4_|M2y9zy6!Q z`qNyw&pXw|U#Sh>Z~yp*zxw4b&i@bfKmYO9Km6f0-~YqxH-G;(f7Sfi>7O0`f!0m_ zryq2T;;zmnKd8NRQEdL_Gwt7f|M$OcezrXP(Jz0Yes0%1LjTvC-PiTD`tHk@$H&KC?f?2$X|evIe)$i7`u*>}|KX4AKhp(a z!QcJupZ@jihhLk2I^W$@zgi``O?tmg7QfnL^_RU+i+=yZZ~pP?-~GGU@4x@;Z`6O- zUS*ZtC95xb?Wm^851lG!JWuni`l5R-NuT%s;933XpZ@kw|M15@b(!L~-~a3H55}0P S)voyEFaCcOMJ69=rw0I%=WFx; literal 0 HcmV?d00001 diff --git a/web/vitest.config.ts b/web/vitest.config.ts index 365c2e2e..77eb685c 100644 --- a/web/vitest.config.ts +++ b/web/vitest.config.ts @@ -10,11 +10,42 @@ // and required for the tests, and one file that means two things depending on // who loaded it is how a build starts differing from what was tested. +import { gunzipSync } from "node:zlib"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; + import { defineConfig } from "vitest/config"; import solid from "vite-plugin-solid"; +// Import a gzip fixture as a decoded `Uint8Array` from a browser-typed test: +// `import bytes from "./x.bin.gz?gzbytes"`. The gunzip happens here in Node +// (this config is not part of the app's browser-only tsconfig), so the tests +// need no `@types/node` and stay free of Node built-ins. The bytes are emitted +// as base64 and decoded with `atob` (a DOM global jsdom provides). +function gzFixtureBytes() { + const SUFFIX = "?gzbytes"; + return { + name: "gz-fixture-bytes", + resolveId(id: string, importer: string | undefined) { + if (!id.endsWith(SUFFIX)) return null; + const file = id.slice(0, -SUFFIX.length); + const base = importer ? dirname(importer) : process.cwd(); + return resolve(base, file) + SUFFIX; + }, + load(id: string) { + if (!id.endsWith(SUFFIX)) return null; + const file = id.slice(0, -SUFFIX.length); + const base64 = gunzipSync(readFileSync(file)).toString("base64"); + return ( + `export default Uint8Array.from(atob(${JSON.stringify(base64)}), ` + + `(c) => c.charCodeAt(0));` + ); + }, + }; +} + export default defineConfig({ - plugins: [solid()], + plugins: [solid(), gzFixtureBytes()], resolve: { // Solid ships two builds. `browser` is the one with a real DOM renderer, // and `development` is the one that keeps the reactive graph's dev From 968d8da4bf75d2a6b0b13a7c2f0dabf4dcb0e9f6 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Wed, 9 Sep 2026 09:17:05 +0000 Subject: [PATCH 6/8] feat(web): F5 persist serialized xterm state for one-write reload restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On reload a pane re-parsed its cached raw scrollback tail into xterm — the per-pane parse cost F1 already bounds, but still a full re-parse. F5 persists the pane's xterm screen + a capped scrollback via @xterm/addon-serialize and restores it in a single term.write, no raw re-parse, then reattaches with the F1-bounded resume_from delta. Cache format (terminalCache.ts): an entry now holds EITHER a serialized screen (a live pane, the fast path) OR raw bytes. Raw is still written by the headless pre-warm path (terminalPrewarm has no xterm to serialize) and as a live-pane fallback when serialization is unavailable, and it still reads a pre-F5 entry — so the format change needs no DB version bump or migration (a deviation from the plan's "bump + drop v1", forced by the pre-warm path the plan did not account for; keeping raw as a first-class shape is strictly more compatible). The ArrayBuffer check is now realm-safe. Restore (Terminal.tsx): restoreCache() writes a serialized entry in one go, or re-parses a ground-state raw tail through the shared queue, and it composes with F3 — a parked tab defers the whole entry and restores on first activation. The serialized scrollback is capped at 2000 lines (SERIALIZE_MAX_SCROLLBACK), well under the 5000-line buffer, to bound the cache entry and the restore write. Tests: - serializeRestore.test.ts: over the real corpus (H2), serialize→restore→ reserialize is a fixed point (screen + scrollback preserved) through the same core VT parser (@xterm/headless); the cap holds. This also lands H2's deferred serialize()-compare fidelity. - terminalCacheRoundtrip.test.ts (fake-indexeddb): serialized and raw entries round-trip, raw is ground-state trimmed, a malformed entry reads as cold. - terminalPrewarmAttach updated for the { data } payload shape. Verification: pnpm typecheck clean; pnpm vitest 938 passed (10 new); pnpm build bundles addon-serialize. WI-129. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UtEFLJAhLiq4NfZCN2vuBb --- web/package.json | 3 + web/pnpm-lock.yaml | 25 ++++ web/src/Terminal.tsx | 116 +++++++++++++----- web/src/__tests__/serializeRestore.test.ts | 71 +++++++++++ .../__tests__/terminalCacheRoundtrip.test.ts | 76 ++++++++++++ .../__tests__/terminalPrewarmAttach.test.ts | 5 +- web/src/terminalCache.ts | 70 +++++++---- web/src/terminalPrewarm.ts | 4 +- 8 files changed, 318 insertions(+), 52 deletions(-) create mode 100644 web/src/__tests__/serializeRestore.test.ts create mode 100644 web/src/__tests__/terminalCacheRoundtrip.test.ts diff --git a/web/package.json b/web/package.json index c50329c1..6cdc30fc 100644 --- a/web/package.json +++ b/web/package.json @@ -22,6 +22,7 @@ "@solidjs/router": "^0.16.1", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-search": "^0.16.0", + "@xterm/addon-serialize": "^0.14.0", "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", "monaco-editor": "^0.55.1", @@ -32,6 +33,8 @@ "@solidjs/testing-library": "^0.8.10", "@testing-library/jest-dom": "^7.0.1", "@testing-library/user-event": "^14.6.4", + "@xterm/headless": "^6.0.0", + "fake-indexeddb": "^6.2.5", "jsdom": "^30.0.1", "typescript": "^6.0.3", "vite": "^8.2.2", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 08d3ad28..ff9e1c55 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -34,6 +34,9 @@ importers: '@xterm/addon-search': specifier: ^0.16.0 version: 0.16.0 + '@xterm/addon-serialize': + specifier: ^0.14.0 + version: 0.14.0 '@xterm/addon-web-links': specifier: ^0.12.0 version: 0.12.0 @@ -59,6 +62,12 @@ importers: '@testing-library/user-event': specifier: ^14.6.4 version: 14.6.4(@testing-library/dom@10.4.1) + '@xterm/headless': + specifier: ^6.0.0 + version: 6.0.0 + fake-indexeddb: + specifier: ^6.2.5 + version: 6.2.5 jsdom: specifier: ^30.0.1 version: 30.0.1 @@ -507,9 +516,15 @@ packages: '@xterm/addon-search@0.16.0': resolution: {integrity: sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==} + '@xterm/addon-serialize@0.14.0': + resolution: {integrity: sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA==} + '@xterm/addon-web-links@0.12.0': resolution: {integrity: sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==} + '@xterm/headless@6.0.0': + resolution: {integrity: sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==} + '@xterm/xterm@6.0.0': resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==} @@ -637,6 +652,10 @@ packages: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} + fake-indexeddb@6.2.5: + resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} + engines: {node: '>=18'} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1538,8 +1557,12 @@ snapshots: '@xterm/addon-search@0.16.0': {} + '@xterm/addon-serialize@0.14.0': {} + '@xterm/addon-web-links@0.12.0': {} + '@xterm/headless@6.0.0': {} + '@xterm/xterm@6.0.0': {} ansi-regex@5.0.1: {} @@ -1640,6 +1663,8 @@ snapshots: expect-type@1.4.0: {} + fake-indexeddb@6.2.5: {} + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 diff --git a/web/src/Terminal.tsx b/web/src/Terminal.tsx index fa1baa19..5a997c18 100644 --- a/web/src/Terminal.tsx +++ b/web/src/Terminal.tsx @@ -4,6 +4,7 @@ import { Terminal as XTerm } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import { WebLinksAddon } from "@xterm/addon-web-links"; import { SearchAddon, type ISearchResultChangeEvent } from "@xterm/addon-search"; +import { SerializeAddon } from "@xterm/addon-serialize"; import "@xterm/xterm/css/xterm.css"; import { openAttach } from "./api"; import type { RuntimeSocket } from "./runtimeTransport"; @@ -20,6 +21,7 @@ import { loadTerminalCache, MAX_TERMINAL_CACHE_BYTES, saveTerminalCache, + type TerminalCacheEntry, } from "./terminalCache"; import { createReplayQueue, @@ -30,6 +32,12 @@ import { type ReplayHandle, type ReplayTail, } from "./terminalReplay"; + +// Cap on the scrollback the serialized cache persists. A 5000-line scrollback +// full of wide chars and colour serializes large; 2000 lines keeps the cache +// entry and the one-write restore bounded while still filling the viewport and +// a deep scroll on reload (WI-129). +const SERIALIZE_MAX_SCROLLBACK = 2000; import { beginForegroundReplay } from "./terminalPrewarm"; import { clampTerminalFontSize, @@ -138,16 +146,17 @@ const TerminalView: Component = (props) => { let ws: RuntimeSocket | null = null; let fit: FitAddon | null = null; let search: SearchAddon | null = null; + let serializeAddon: SerializeAddon | null = null; let resizeObserver: ResizeObserver | null = null; let inSnapshot = true; let outputPosition: number | undefined; let snapshotEndPosition: number | undefined; let cacheChunks: Uint8Array[] = []; let cacheBytes = 0; - // A parked pane's cached tail, prepared but not yet replayed. Kept in memory - // on reload and replayed lazily on first activation (F3), so retained tabs do - // not time-slice the shared replay parser with the active pane. - let deferredCacheReplay: ReplayTail | null = null; + // A parked pane's cache entry, loaded but not yet restored. Kept in memory on + // reload and restored lazily on first activation (F3), so retained tabs do not + // time-slice the shared replay parser with the active pane. + let deferredCache: TerminalCacheEntry | null = null; let cacheTimer: ReturnType | null = null; // The outputPosition at the last successful persist, so an unchanged ring is // not re-copied and re-written to IndexedDB. @@ -322,6 +331,19 @@ const TerminalView: Component = (props) => { return combined; }; + // Serialize the live terminal's screen + a capped scrollback, so a reload + // restores it in one write instead of re-parsing raw bytes (F5). Returns null + // when serialization is unavailable or throws, so persistCache can fall back + // to the raw ring. + const trySerialize = (): string | null => { + if (!serializeAddon) return null; + try { + return serializeAddon.serialize({ scrollback: SERIALIZE_MAX_SCROLLBACK }); + } catch { + return null; + } + }; + const persistCache = () => { if (outputPosition === undefined) return; // Skip the write when nothing new has arrived since the last persist @@ -329,7 +351,12 @@ const TerminalView: Component = (props) => { // value means the ring is identical and copying+writing it is wasted work. if (outputPosition === lastPersistedPosition) return; lastPersistedPosition = outputPosition; - void saveTerminalCache(props.sessionId, outputPosition, cachedBytes()); + const serialized = trySerialize(); + void saveTerminalCache( + props.sessionId, + outputPosition, + serialized !== null ? { serialized } : { data: cachedBytes() }, + ); }; const scheduleCachePersist = () => { @@ -547,6 +574,8 @@ const TerminalView: Component = (props) => { fit = new FitAddon(); term.loadAddon(fit); term.loadAddon(new WebLinksAddon()); + serializeAddon = new SerializeAddon(); + term.loadAddon(serializeAddon); search = new SearchAddon(); term.loadAddon(search); search.onDidChangeResults((info) => props.onSearchResults?.(info)); @@ -814,31 +843,23 @@ const TerminalView: Component = (props) => { if (destroyed) return; // A live pane's initial load holds the pre-warm gate until snapshot-done. enterForegroundReplay(); - if (!cached || cached.data.byteLength === 0) { + if (!cached) { setReadyToConnect(true); if (!isParked()) connect(); return; } - const bytes = new Uint8Array(cached.data); - const prepared = prepareReplayTail(bytes, cached.outputPosition); - cacheChunks = [bytes]; - cacheBytes = bytes.byteLength; // Adopt the cache's position now so a warm reattach resumes from it even - // if the visible replay is deferred (or later cancelled by a re-park). - outputPosition = prepared.outputPosition; + // if the visible restore is deferred (or later cancelled by a re-park). + outputPosition = cached.outputPosition; if (shouldDeferCacheReplay(isParked(), true)) { - // Parked on reload: keep the tail in memory and replay it on the first + // Parked on reload: keep the entry in memory and restore it on the first // activation, not into the shared FIFO with the active pane (F3). - deferredCacheReplay = prepared; + deferredCache = cached; setReadyToConnect(true); return; } - setStatusText("Restoring terminal..."); - replay = replayCacheTail(prepared); - void replay.done.then(() => { + restoreCache(cached, () => { if (destroyed) return; - outputPosition = prepared.outputPosition; - term?.scrollToBottom(); setReadyToConnect(true); if (!isParked()) connect(); }); @@ -917,6 +938,49 @@ const TerminalView: Component = (props) => { ); } + /** + * Restore a cached terminal, then run `onDone`. A serialized entry (F5) is a + * single `term.write` of the screen + capped scrollback — no raw re-parse. A + * raw entry (pre-warm, fallback, or a pre-F5 cache) re-parses a ground-state + * tail through the shared replay queue, exactly as before F5. + */ + function restoreCache(cached: TerminalCacheEntry, onDone: () => void): void { + setStatusText("Restoring terminal..."); + if (typeof cached.serialized === "string") { + term?.reset(); + cacheChunks = []; + cacheBytes = 0; + outputPosition = cached.outputPosition; + const restored = cached.serialized; + if (!term) { + onDone(); + return; + } + term.write(restored, () => { + if (destroyed) return; + term?.scrollToBottom(); + onDone(); + }); + return; + } + if (cached.data) { + const bytes = new Uint8Array(cached.data); + const prepared = prepareReplayTail(bytes, cached.outputPosition); + cacheChunks = [bytes]; + cacheBytes = bytes.byteLength; + outputPosition = prepared.outputPosition; + replay = replayCacheTail(prepared); + void replay.done.then(() => { + if (destroyed) return; + outputPosition = prepared.outputPosition; + term?.scrollToBottom(); + onDone(); + }); + return; + } + onDone(); + } + function connect() { if (isParked()) return; if (isSessionGone()) { markSessionGone(); return; } @@ -1111,18 +1175,14 @@ const TerminalView: Component = (props) => { if (destroyed || !readyToConnect() || isParked()) return; // Resuming to the foreground: hold the pre-warm gate through this attach. enterForegroundReplay(); - // A tab parked on reload deferred its cache replay (F3); run it now, before - // attaching, so the restored scrollback is on screen when the delta arrives. - const pending = deferredCacheReplay; + // A tab parked on reload deferred its cache restore (F3); run it now, before + // attaching, so the restored screen is up when the delta arrives. + const pending = deferredCache; if (pending) { - deferredCacheReplay = null; - setStatusText("Restoring terminal..."); + deferredCache = null; replay?.cancel(); - replay = replayCacheTail(pending); - void replay.done.then(() => { + restoreCache(pending, () => { if (destroyed || isParked()) return; - outputPosition = pending.outputPosition; - term?.scrollToBottom(); connect(); }); return; diff --git a/web/src/__tests__/serializeRestore.test.ts b/web/src/__tests__/serializeRestore.test.ts new file mode 100644 index 00000000..628e9487 --- /dev/null +++ b/web/src/__tests__/serializeRestore.test.ts @@ -0,0 +1,71 @@ +import { Terminal } from "@xterm/headless"; +import { SerializeAddon } from "@xterm/addon-serialize"; +import { describe, expect, it } from "vitest"; + +import claudeCodeTui from "../../tests/fixtures/transcripts/claude-code-tui.bin.gz?gzbytes"; +import shellPlain from "../../tests/fixtures/transcripts/shell-plain.bin.gz?gzbytes"; +import cargoBuild from "../../tests/fixtures/transcripts/cargo-build.bin.gz?gzbytes"; + +// F5 (WI-129) persists a live pane's xterm state via @xterm/addon-serialize and +// restores it in a single `term.write` on reload. The fidelity contract is: a +// serialized screen, written into a fresh terminal, reproduces the same screen +// and scrollback — so restore-then-reserialize is a fixed point. Proven here +// against the real transcript corpus (H2), through the same core VT parser the +// browser build uses (@xterm/headless shares it). + +const SERIALIZE_MAX_SCROLLBACK = 2000; + +const CORPORA = [ + { name: "claude-code-tui", bytes: claudeCodeTui }, + { name: "shell-plain", bytes: shellPlain }, + { name: "cargo-build", bytes: cargoBuild }, +] as const; + +function makeTerminal() { + const term = new Terminal({ + cols: 120, + rows: 40, + scrollback: 5000, + allowProposedApi: true, + }); + const serialize = new SerializeAddon(); + term.loadAddon(serialize); + return { term, serialize }; +} + +function write(term: Terminal, data: Uint8Array | string): Promise { + return new Promise((r) => term.write(data, r)); +} + +describe.each(CORPORA)("serialized restore fidelity: $name", ({ bytes }) => { + it("restore-then-reserialize is a fixed point (screen + scrollback preserved)", async () => { + const a = makeTerminal(); + await write(a.term, bytes); + const serialized = a.serialize.serialize({ scrollback: SERIALIZE_MAX_SCROLLBACK }); + // Note: an alt-screen program that has since exited leaves an empty normal + // buffer, so `serialized` can legitimately be "" — the fixed-point below + // still holds, and that is the property F5 relies on. + + // Restore into a fresh terminal in ONE write, then re-serialize. + const b = makeTerminal(); + await write(b.term, serialized); + const reserialized = b.serialize.serialize({ scrollback: SERIALIZE_MAX_SCROLLBACK }); + + expect(reserialized).toBe(serialized); + // The live screen (the viewport) matches too, independent of scrollback. + expect(b.serialize.serialize({ scrollback: 0 })).toBe( + a.serialize.serialize({ scrollback: 0 }), + ); + }); + + it("caps the serialized scrollback to the documented bound", async () => { + const { term, serialize } = makeTerminal(); + await write(term, bytes); + const capped = serialize.serialize({ scrollback: SERIALIZE_MAX_SCROLLBACK }); + // Never more scrollback lines than the cap (rows of viewport aside): the + // serialized string's newline count stays bounded regardless of a + // 5000-line buffer. + const lines = capped.split("\n").length; + expect(lines).toBeLessThanOrEqual(SERIALIZE_MAX_SCROLLBACK + term.rows + 2); + }); +}); diff --git a/web/src/__tests__/terminalCacheRoundtrip.test.ts b/web/src/__tests__/terminalCacheRoundtrip.test.ts new file mode 100644 index 00000000..e29954e9 --- /dev/null +++ b/web/src/__tests__/terminalCacheRoundtrip.test.ts @@ -0,0 +1,76 @@ +import "fake-indexeddb/auto"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { loadTerminalCache, saveTerminalCache } from "../terminalCache"; + +// F5 (WI-129) makes the cache hold either a serialized xterm screen (a live +// pane, the fast restore path) or raw bytes (the headless pre-warm path, or a +// pre-F5 entry). loadTerminalCache must return whichever shape was stored, and +// keep reading raw entries so the format change needs no DB version bump. + +async function clearDb(): Promise { + await new Promise((resolve) => { + const req = indexedDB.deleteDatabase("vogt-terminal-cache"); + req.onsuccess = req.onerror = req.onblocked = () => resolve(); + }); +} + +describe("terminal cache round-trip", () => { + beforeEach(clearDb); + + it("stores and returns a serialized entry (the F5 fast path)", async () => { + await saveTerminalCache("s-serialized", 4242, { serialized: "\x1b[32mrestored\x1b[0m" }); + const entry = await loadTerminalCache("s-serialized"); + expect(entry).not.toBeNull(); + expect(entry?.serialized).toBe("\x1b[32mrestored\x1b[0m"); + expect(entry?.data).toBeUndefined(); + expect(entry?.outputPosition).toBe(4242); + }); + + it("stores and returns a raw entry (pre-warm / fallback / pre-F5)", async () => { + const bytes = new TextEncoder().encode("line-a\nline-b\n"); + await saveTerminalCache("s-raw", bytes.byteLength, { data: bytes }); + const entry = await loadTerminalCache("s-raw"); + expect(entry).not.toBeNull(); + expect(entry?.serialized).toBeUndefined(); + expect(Array.from(new Uint8Array(entry!.data!))).toEqual(Array.from(bytes)); + }); + + it("ground-state-trims a raw entry whose cursor is past its length", async () => { + // outputPosition beyond the byte length marks a ring that dropped its head: + // the load path advances past the first newline so replay starts in ground + // state. + const bytes = new TextEncoder().encode("partial-escape\nclean-line\n"); + await saveTerminalCache("s-trim", 100_000, { data: bytes }); + const entry = await loadTerminalCache("s-trim"); + expect(Array.from(new Uint8Array(entry!.data!))).toEqual( + Array.from(new TextEncoder().encode("clean-line\n")), + ); + }); + + it("ignores an entry with neither serialized nor data", async () => { + // A malformed / foreign entry never crashes the restore; it reads as a cold + // start. + const db = await new Promise((resolve, reject) => { + const req = indexedDB.open("vogt-terminal-cache", 1); + req.onupgradeneeded = () => { + req.result.createObjectStore("sessions", { keyPath: "sessionId" }) + .createIndex("updatedAt", "updatedAt"); + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + await new Promise((resolve, reject) => { + const tx = db.transaction("sessions", "readwrite"); + tx.objectStore("sessions").put({ + sessionId: "s-bad", + outputPosition: 10, + updatedAt: Date.now(), + }); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + db.close(); + expect(await loadTerminalCache("s-bad")).toBeNull(); + }); +}); diff --git a/web/src/__tests__/terminalPrewarmAttach.test.ts b/web/src/__tests__/terminalPrewarmAttach.test.ts index e4ad4c20..7fe6878a 100644 --- a/web/src/__tests__/terminalPrewarmAttach.test.ts +++ b/web/src/__tests__/terminalPrewarmAttach.test.ts @@ -98,12 +98,13 @@ describe("warmAttachOnce", () => { await expect(promise).resolves.toBe(true); expect(saveTerminalCache).toHaveBeenCalledTimes(1); - const [id, outputPosition, data] = saveTerminalCache.mock.calls[0]!; + const [id, outputPosition, cachePayload] = saveTerminalCache.mock.calls[0]!; expect(id).toBe("11111111-1111-1111-1111-111111111111"); // outputPosition is the absolute stream end, so a later open sends it as // resume_from and gets a delta instead of a cold snapshot. expect(outputPosition).toBe(1000); - expect(data.byteLength).toBe(payload.byteLength); + // Pre-warm has no xterm to serialize, so it caches raw bytes (F5). + expect(cachePayload.data.byteLength).toBe(payload.byteLength); expect(socket.closed).toBe(true); }); diff --git a/web/src/terminalCache.ts b/web/src/terminalCache.ts index 680aa9ea..0cbbe526 100644 --- a/web/src/terminalCache.ts +++ b/web/src/terminalCache.ts @@ -5,13 +5,34 @@ const MAX_CACHED_SESSIONS = 8; export const MAX_TERMINAL_CACHE_BYTES = 4 * 1024 * 1024; +/** + * A cached terminal, in one of two shapes (F5, WI-129): + * + * - `serialized`: a live pane persists its xterm screen + capped scrollback via + * `@xterm/addon-serialize`. On reload it restores in a single `term.write`, + * with no raw-byte re-parse — the fast path. + * - `data`: raw scrollback bytes. Written by the headless pre-warm path + * (`terminalPrewarm`), which has no xterm to serialize, and by a live pane as + * a fallback when serialization is unavailable. Restored by re-parsing a + * ground-state-aligned tail (the pre-F5 path), which also reads any entry a + * pre-F5 client left behind — so the format change needs no DB version bump + * or migration. + * + * Exactly one of `serialized` / `data` is present. + */ export interface TerminalCacheEntry { sessionId: string; outputPosition: number; - data: ArrayBuffer; updatedAt: number; + serialized?: string; + data?: ArrayBuffer; } +/** What a caller hands `saveTerminalCache`: a serialized screen or raw bytes. */ +export type TerminalCachePayload = + | { serialized: string } + | { data: Uint8Array }; + /** * The client cache is a byte-oriented ring (see `appendToCache` in * Terminal.tsx): once it overflows, its oldest bytes are dropped at an @@ -73,22 +94,30 @@ export async function loadTerminalCache( >, ); db.close(); + if (!result || !Number.isSafeInteger(result.outputPosition)) return null; + + // Serialized fast path: a live pane's screen, restored in one write. + if (typeof result.serialized === "string") { + return result.serialized.length > 0 ? result : null; + } + // Raw path (pre-warm entries, live-pane fallback, pre-F5 entries). Detect + // the ArrayBuffer realm-safely (`instanceof` misses a cross-realm buffer, as + // a structured clone can produce). if ( - !result || - !(result.data instanceof ArrayBuffer) || - !Number.isSafeInteger(result.outputPosition) || - result.outputPosition < result.data.byteLength + result.data != null && + Object.prototype.toString.call(result.data) === "[object ArrayBuffer]" && + result.outputPosition >= result.data.byteLength ) { - return null; - } - // Drop any partial leading escape sequence / UTF-8 char left by the ring - // trim so the tail replays from a terminal ground state. - const bytes = new Uint8Array(result.data); - const start = groundStateReplayStart(bytes, result.outputPosition); - if (start > 0) { - result.data = bytes.slice(start).buffer; + // Drop any partial leading escape sequence / UTF-8 char left by the ring + // trim so the tail replays from a terminal ground state. + const bytes = new Uint8Array(result.data); + const start = groundStateReplayStart(bytes, result.outputPosition); + if (start > 0) { + result.data = bytes.slice(start).buffer; + } + return result; } - return result; + return null; } catch { return null; } @@ -97,7 +126,7 @@ export async function loadTerminalCache( export async function saveTerminalCache( sessionId: string, outputPosition: number, - data: Uint8Array, + payload: TerminalCachePayload, ): Promise { if (typeof indexedDB === "undefined" || !Number.isSafeInteger(outputPosition)) { return; @@ -106,12 +135,11 @@ export async function saveTerminalCache( const db = await openCache(); const tx = db.transaction(STORE_NAME, "readwrite"); const store = tx.objectStore(STORE_NAME); - store.put({ - sessionId, - outputPosition, - data: data.slice().buffer, - updatedAt: Date.now(), - } satisfies TerminalCacheEntry); + const stored: TerminalCacheEntry = + "serialized" in payload + ? { sessionId, outputPosition, serialized: payload.serialized, updatedAt: Date.now() } + : { sessionId, outputPosition, data: payload.data.slice().buffer, updatedAt: Date.now() }; + store.put(stored); const entries = await requestResult( store.index("updatedAt").getAllKeys() as IDBRequest, diff --git a/web/src/terminalPrewarm.ts b/web/src/terminalPrewarm.ts index b72c72e4..049a4dc6 100644 --- a/web/src/terminalPrewarm.ts +++ b/web/src/terminalPrewarm.ts @@ -212,7 +212,9 @@ export function warmAttachOnce( return; } const data = concatChunks(chunks, bytes); - void saveTerminalCache(sessionId, endPosition, data).finally(() => + // Pre-warm has no xterm to serialize; it caches raw bytes, restored + // via the ground-state-aligned raw path (F5). + void saveTerminalCache(sessionId, endPosition, { data }).finally(() => finish(true), ); } From 249cb6dbc267373580dd702f8fd2b16ef4dd41a5 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Wed, 9 Sep 2026 09:21:03 +0000 Subject: [PATCH 7/8] =?UTF-8?q?docs:=20D6=20spike=20=E2=80=94=20engine-sid?= =?UTF-8?q?e=20headless=20VT=20per=20session,=20recommend=20"not=20now"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answer the D6 design question (WI-130) in a new docs/ROADMAP.md: should the engine run a headless terminal emulator per session so a cold attach ships screen state instead of byte history? - Crate comparison: vt100 (best fit — minimal grid, smallest surface), alacritty_terminal and termwiz (heavier, renderer/feature surface a snapshot server does not need). - Grounded per-session cost at 8 sessions: ~1.7–3.4 MB grid each (~14–27 MB total) ON TOP of the raw ring, plus continuous VT parse CPU on the PTY hot path — a cost the raw-ring design pays only per attach. No live number taken (no stack reachable); H1's load session is the measurement path if revisited. - Wire shape (snapshot-start kind:"screen" + server escape stream), the server-VT-vs-xterm fidelity risk and how the H2 harness proves it, and the modest mobile win (F5 already gives a bounded one-write restore). Recommendation: not now. F1–F5 (+F4) address both operator symptoms by bounding and client-side serialization without doubling engine memory or adding hot-path CPU. Revisit only if, once shipped and measured, mobile cold attach is still bottlenecked on the tail, the client cache proves untenable, or a new server-authoritative-screen requirement appears. No new work items filed. WI-130. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UtEFLJAhLiq4NfZCN2vuBb --- docs/ROADMAP.md | 111 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 docs/ROADMAP.md diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 00000000..022d40cc --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,111 @@ +# Roadmap and design notes + +Forward-looking design decisions that are not yet work in flight. Each entry is +a recommendation with enough reasoning and numbers to act on — or to decide +"not now" without re-deriving the analysis. + +## Engine-side headless VT per session (D6, WI-130) + +*Spike, 2026-09-09. Part of the terminal-attach-budget initiative (WI-121).* + +### Question + +The engine keeps only **raw PTY bytes** in a per-session ring +(`scrollback.rs`), and every client rebuilds a terminal by re-parsing those +bytes into xterm.js on attach. Should the engine instead run a **headless +terminal emulator per session**, so a cold attach ships *screen state* +(tmux-style) — a serialized screen plus N lines of scrollback — instead of byte +history? + +### What the budget work already fixed + +Before committing to a VT, weigh it against what shipped in this initiative: + +- **F1 (WI-125)** bounds *every* replay to the tail budget — the 4 MiB aged-out + flood is gone; a cold or stale attach now ships at most the budget + (default 1 MiB), ground-state aligned. +- **F3 (WI-127)** stops retained tabs from time-slicing the active pane's + parser on reload. +- **F5 (WI-129)** persists a serialized xterm screen client-side, so a *reload* + restores in one write with no raw re-parse, and (once F4 lands) a *switch* + keeps the socket open and never re-streams. + +So the two operator symptoms (switch re-stream, slow fresh open) are addressed +by bounding and by client-side serialization, **without** an engine VT. The VT +is only worth its cost if a residual problem remains after these ship and are +measured on prod — see "When to revisit". + +### Crate options + +| Crate | What it is | Fit for a screen-snapshot server VT | +|---|---|---| +| `vt100` | A minimal pure-Rust parser that maintains a screen grid + scrollback, no rendering. | **Best fit.** Smallest surface and memory; exposes the cell grid directly, which is all a snapshot needs. Serializing its screen to an xterm-compatible escape stream is a bounded amount of new code. | +| `alacritty_terminal` | The terminal model behind Alacritty. | Heavier per-cell model and an API shaped around a GPU renderer's needs; more memory and more moving parts than a snapshot server wants, and version churn tied to Alacritty. | +| `termwiz` | WezTerm's terminal library. | Full-featured (its own line/cell/attribute model, image protocols); the largest surface of the three. Overkill for "hold a screen and emit a snapshot". | + +### Cost estimate (per session, at 8 busy sessions) + +No live measurement was taken (an agent session cannot reach a prod stack; use +H1's load session — `load_session_command` in the engine integration tests — to +measure before adopting). Grounded estimate for `vt100`: + +- **Memory.** A cell is a codepoint + attributes ≈ 8–16 B. A 200-col grid with + 50 visible rows + 1000 scrollback lines ≈ 210 000 cells ≈ **1.7–3.4 MB per + session**, i.e. **~14–27 MB across 8 sessions** — *on top of* the existing + 4 MiB raw ring per session unless the ring is then shrunk. The ring cannot be + dropped entirely: warm `resume_from` deltas and the history archive still read + raw bytes. +- **CPU.** The VT must parse every byte the PTY produces, on the hot reader + path. The client already parses at ~5–6 MB/s into xterm; a Rust grid VT is + faster (tens of MB/s) but it is now paid **once per session, always**, not + once per attach. For a chatty agent session this is continuous cost the + raw-ring design does not have. + +### Wire shape (if adopted) + +Add a snapshot kind rather than changing the existing one: + +```json +{"type":"snapshot-start","kind":"screen","session_id":"…","scrollback_pos":N,"reset":true} +``` + +followed by a server-produced, xterm-compatible escape stream that redraws the +screen and the last N scrollback lines, then live bytes as today. `resume_from` +still selects the byte-delta path (unchanged); `kind:"screen"` is the cold / +aged-out path only. The history archive is untouched (it keeps raw bytes). + +### Fidelity + +A server VT and the client xterm are two independent emulators; they can +disagree (edge cases in wide chars, unusual SGR, DEC private modes). The +snapshot is only correct if the server's redraw reproduces on xterm what the +program intended. The H2 corpus + fidelity harness is the right place to prove +this: parse each corpus in the candidate crate, emit the screen escape stream, +write it into headless xterm, and compare `serialize()` against xterm fed the +raw bytes. Adopt only if that comparison is clean across the corpus. + +### Mobile + +The prize on mobile would be dropping the IndexedDB cache entirely: if a cold +attach always ships a small screen snapshot, the phone need not persist raw +scrollback. But F5 already gives mobile a one-write restore from a *bounded* +serialized cache, so the marginal win is "no client cache at all" versus "a +small, capped client cache" — modest. + +### Recommendation: **not now** + +Ship and measure F1–F5 (and F4) on prod first. The engine VT doubles per-session +memory and adds continuous CPU on the PTY hot path to solve a problem the +bounding + client serialization work already targets. Revisit **only** if, with +those shipped and measured: + +1. a first cold attach on mobile is still too slow *and* the ≤1 MiB tail is the + bottleneck (not network), or +2. keeping any client-side scrollback cache proves untenable (storage, privacy), + or +3. a new requirement wants server-authoritative screen state (e.g. server-side + search over live screens, or thumbnails). + +If revisited, the first step is a `vt100`-based measurement against H1's load +session at 8 sessions, then the H2 fidelity comparison above — no new work items +are filed now. From e9a640b1aa06d305bc374ea71fc7f0ca14b1db4b Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Wed, 9 Sep 2026 09:45:26 +0000 Subject: [PATCH 8/8] test(web): H3 terminal replay budget spec + live acceptance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add web/tests/browser/terminalReplay.spec.ts, the acceptance harness the terminal-attach-budget fixes are measured against (WI-124). Mocked project (desktop/phone, Vite dev server) — a new routeWebSocket harness streams a 1 MiB corpus (built from the H2 transcript) into a REAL terminal as a cold snapshot, then asserts the per-pane `vogt-terminal-replay.snapshot` performance measure exists and is under the CI budget (2500 ms), and that the `[vogt] terminal replay` telemetry the live spec reads was logged. This is the first WebSocket-mocked terminal test in the suite; it is stable and box- independent (the measure's existence proves the bounded snapshot rendered; the budget has generous headroom over the ~5-6 MB/s parse rate). Live project (PLAYWRIGHT_LIVE_BASE_URL only) — drives a real load session via POST /api/sessions and asserts the two operator symptoms directly: switching away past the ring and back replays <= budget with no reset and no `[disconnected]` (F1/F4/F2), and a reload keeps the active pane under budget (F3/F5). Gated to the `live` project, so mocked runs skip it. e2e wiring: the playwright container already runs `playwright test --project=live` over the whole testDir, so this spec is picked up automatically; the live-step comment is updated to name it. Not added to demo gating. Verified: the mocked test passes and is stable across repeated runs; the live tests skip in the mocked projects. The live half runs against a real stack in e2e.yml (advisory/continue-on-error there) — an agent session cannot reach one, so its green/red-before-F1 confirmation is owed to a stack run. WI-124. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UtEFLJAhLiq4NfZCN2vuBb --- .github/workflows/e2e.yml | 8 +- web/tests/browser/terminalReplay.spec.ts | 290 +++++++++++++++++++++++ 2 files changed, 295 insertions(+), 3 deletions(-) create mode 100644 web/tests/browser/terminalReplay.spec.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 112ba67f..b16f6472 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -177,10 +177,12 @@ jobs: engine bash /tmp/e2e/scripts/e2e_stack_smoke.sh http://localhost:8910 # The live Playwright project, in-network. A Playwright - # container on the compose network runs the SAME gui.spec.ts specs against + # container on the compose network runs the `live`-project specs against # http://engine:8910 with the real API (no installFixtures), so mock/truth - # drift fails a test. The runner cannot reach the stack over host loopback, - # so this runs via `docker compose run` on the network like the smoke walk. + # drift fails a test. This includes gui.spec.ts and terminalReplay.spec.ts + # (H3, WI-124: the terminal-attach-budget symptoms against a real load + # session). The runner cannot reach the stack over host loopback, so this + # runs via `docker compose run` on the network like the smoke walk. # # First landing is advisory (continue-on-error): the in-network smoke walk # above is the hard end-to-end gate, and this project is being brought up diff --git a/web/tests/browser/terminalReplay.spec.ts b/web/tests/browser/terminalReplay.spec.ts new file mode 100644 index 00000000..eae3005f --- /dev/null +++ b/web/tests/browser/terminalReplay.spec.ts @@ -0,0 +1,290 @@ +// Terminal replay budget + live acceptance (H3, WI-124). +// +// Two projects, one file: +// +// - The default mocked projects (`desktop`/`phone`, Vite dev server) stream a +// bounded corpus into a real terminal over a routed WebSocket and assert the +// per-pane replay stays under budget and actually renders. No live stack. +// +// - The `live` project (only registered when PLAYWRIGHT_LIVE_BASE_URL is set, +// selected by e2e.yml) drives a real load session on a real stack and asserts +// the two operator symptoms directly: switching away and back replays only a +// bounded tail (F1; a no-reset resume once F4 lands), and a reload does not +// slow the active pane (F3/F5), with no spurious `[disconnected]` (F2). See +// docs/local/TERMINAL_ATTACH_BUDGET_PLAN.md. + +import { readFileSync } from "node:fs"; +import { gunzipSync } from "node:zlib"; + +import { expect, test, type Page } from "@playwright/test"; + +const isLive = (project: string) => project === "live"; + +// ─── Mocked project ──────────────────────────────────────────────────────── + +// ~1 MiB — the client's per-pane replay budget (REPLAY_TAIL_MAX_BYTES), i.e. the +// most a cold attach delivers once F1 bounds it. Built from a real transcript. +function buildCorpus(target: number): Uint8Array { + const seed = gunzipSync( + readFileSync(new URL("../fixtures/transcripts/shell-plain.bin.gz", import.meta.url)), + ); + const out = new Uint8Array(target); + for (let off = 0; off < target; off += seed.byteLength) { + out.set(seed.subarray(0, Math.min(seed.byteLength, target - off)), off); + } + return out; +} +const CORPUS = buildCorpus(1024 * 1024); + +// The CI per-pane replay budget. Generous headroom over the measured parse rate +// (~5–6 MB/s ⇒ ~0.2 s for 1 MiB) so it is a real ceiling, not a flaky stopwatch; +// tune down once the self-hosted runner's number is known. +const REPLAY_BUDGET_MS = 2500; + +const SESSION = { + id: "sess-load", + name: "load", + cwd: "/workspace", + activity: "idle", + exit_code: null, + scrollback_bytes: 0, + created_at: "2026-09-09T00:00:00Z", +}; + +async function mockedFixtures(page: Page): Promise { + await page.addInitScript(() => { + localStorage.setItem("vogt.token", "browser-test-token"); + localStorage.setItem("vogt.appTheme.v1", "dark"); + }); + const json = (body: unknown) => (route: { fulfill: (o: unknown) => Promise }) => + route.fulfill({ json: body }); + await page.route("**/api/install/status", json({ install_mode: false })); + await page.route( + "**/api/auth/check", + json({ + ok: true, + version: "test", + product_version: "test", + storage: { state_dir: "/tmp", workspace_root: "/workspace" }, + }), + ); + await page.route( + "**/api/status**", + json({ + version: "test", + session_count: 1, + push_subscription_count: 0, + gui_process_count: 0, + gui_stream_configured: false, + fcm_enabled: false, + history: { + enabled: true, + archived_session_count: 0, + log_file_count: 0, + log_bytes: 0, + db_bytes: 0, + }, + agent_tasks: { + task_count: 0, + prompt_task_dir_count: 0, + prompt_file_count: 0, + context_file_count: 0, + prompt_bytes: 0, + orphan_task_dir_count: 0, + }, + auth_broker: { auto_agent_auth: false, helper: "disabled" }, + storage: { state_dir: "/tmp", workspace_root: "/workspace" }, + }), + ); + await page.route( + "**/api/config**", + json({ + assistant_enabled: false, + gui_stream_url: null, + session_templates: [], + gui_stream_available: false, + vogt: { configured: true }, + }), + ); + await page.route("**/api/sessions", (route) => route.fulfill({ json: [SESSION] })); + await page.route("**/api/sessions/*", (route) => { + if (route.request().method() !== "GET") return route.fulfill({ json: { ok: true } }); + return route.fulfill({ json: { ...SESSION, scrollback_base64: "" } }); + }); + await page.route("**/api/events", (route) => + route.fulfill({ + status: 200, + contentType: "text/event-stream", + body: `data: ${JSON.stringify({ type: "activity", id: "s", state: "idle" })}\n\n`, + }), + ); +} + +// Route the attach WebSocket and stream `corpus` as one cold snapshot, exactly +// as the engine would (snapshot-start → binary chunks → snapshot-done). +async function streamSnapshot(page: Page, corpus: Uint8Array): Promise { + await page.routeWebSocket(/\/api\/sessions\/.*\/attach/, (ws) => { + ws.onMessage(() => { + ws.send( + JSON.stringify({ + type: "snapshot-start", + session_id: SESSION.id, + scrollback_bytes: corpus.byteLength, + scrollback_pos: corpus.byteLength, + reset: true, + }), + ); + const CHUNK = 64 * 1024; + for (let i = 0; i < corpus.byteLength; i += CHUNK) { + ws.send(Buffer.from(corpus.subarray(i, Math.min(i + CHUNK, corpus.byteLength)))); + } + ws.send(JSON.stringify({ type: "snapshot-done" })); + }); + }); +} + +test.describe("terminal replay budget (mocked)", () => { + test.beforeEach(({}, testInfo) => { + test.skip(isLive(testInfo.project.name), "mocked only"); + }); + + test("a bounded snapshot renders and replays under the per-pane budget", async ({ page }) => { + const replayLines: string[] = []; + page.on("console", (msg) => { + if (msg.text().includes("[vogt] terminal replay")) replayLines.push(msg.text()); + }); + + await mockedFixtures(page); + await streamSnapshot(page, CORPUS); + await page.goto("/#/t/sess-load"); + + // The snapshot replay emits a `vogt-terminal-replay.snapshot` measure. + const handle = await page.waitForFunction( + () => { + const m = performance + .getEntriesByType("measure") + .find((e) => e.name === "vogt-terminal-replay.snapshot"); + return m ? { duration: m.duration } : null; + }, + undefined, + { timeout: 20_000 }, + ); + const measure = (await handle.jsonValue()) as { duration: number }; + + // The measure only exists once the replay has drained into xterm, so its + // presence proves the 1 MiB snapshot rendered; its duration is the per-pane + // budget the plan bounds. + expect(measure.duration).toBeGreaterThan(0); + expect(measure.duration).toBeLessThan(REPLAY_BUDGET_MS); + + // The client logged the `[vogt] terminal replay` telemetry the live spec + // reads (kind, snapshotBytes, replayDurationMs). + await expect.poll(() => replayLines.length, { timeout: 5_000 }).toBeGreaterThan(0); + }); +}); + +// ─── Live project ────────────────────────────────────────────────────────── +// +// Runs only under PLAYWRIGHT_LIVE_BASE_URL (the `live` project), against a real +// stack booted by e2e.yml with a load-generating session. It asserts the two +// operator symptoms as the plan's H3 requires. Not exercised by mocked runs. + +const LIVE_TOKEN = process.env.PLAYWRIGHT_LIVE_TOKEN ?? ""; +// ~3 MiB/min of coloured, sequence-numbered output, then idle — the same shape +// as the engine harness's load session, so the ring wraps within the test. +const LIVE_LOAD_COMMAND = [ + "/bin/bash", + "-c", + "i=0; while [ \"$i\" -lt 400000 ]; do " + + "printf '\\033[3%dmSEQ%08d the quick brown fox jumps over the lazy dog\\033[0m\\n' " + + '"$((i % 8))" "$i"; i=$((i + 1)); ' + + "if [ \"$((i % 64))\" -eq 0 ]; then printf '\\033[H'; fi; " + + "done; exec sleep 3600", +]; + +async function liveCreateLoadSession(page: Page): Promise { + const res = await page.request.post("/api/sessions", { + headers: { authorization: `Bearer ${LIVE_TOKEN}` }, + data: { name: "h3-load", command: LIVE_LOAD_COMMAND }, + }); + expect(res.ok()).toBeTruthy(); + return ((await res.json()) as { id: string }).id; +} + +async function liveScrollbackPos(page: Page, id: string): Promise { + const res = await page.request.get(`/api/sessions/${id}`, { + headers: { authorization: `Bearer ${LIVE_TOKEN}` }, + }); + return ((await res.json()) as { scrollback_pos?: number }).scrollback_pos ?? 0; +} + +test.describe("terminal replay budget (live acceptance)", () => { + test.beforeEach(({}, testInfo) => { + test.skip(!isLive(testInfo.project.name), "live only"); + }); + + test("symptom 1: switch away past the ring and back — bounded reattach, no disconnect", async ({ + page, + }) => { + const replays: { kind?: string; snapshotBytes?: number; reset?: boolean }[] = []; + page.on("console", (msg) => { + const text = msg.text(); + if (!text.includes("[vogt] terminal replay")) return; + const m = text.match(/\{.*\}/); + if (m) { + try { + replays.push(JSON.parse(m[0])); + } catch { + /* not the structured arg */ + } + } + }); + + const id = await liveCreateLoadSession(page); + await page.goto(`/#/t/${id}`); + await expect(page.locator(".xterm-rows")).toContainText("SEQ", { timeout: 20_000 }); + const openPos = await liveScrollbackPos(page, id); + + // Switch away to another place, wait until the ring has wrapped well past + // where we were, then switch back. + await page.goto("/#/sessions"); + await expect + .poll(() => liveScrollbackPos(page, id), { timeout: 60_000, intervals: [500] }) + .toBeGreaterThan(openPos + 4 * 1024 * 1024); + replays.length = 0; // only measure the reattach + await page.goto(`/#/t/${id}`); + await expect(page.locator(".xterm-rows")).toContainText("SEQ", { timeout: 20_000 }); + + // No [disconnected] marker in the buffer. + await expect(page.locator(".xterm-rows")).not.toContainText("[disconnected]"); + // The reattach replayed at most the budget (F1). It may be a bounded reset + // until F4 lands, when it becomes a no-reset resume; either way it is <= budget. + const reattach = replays.filter((r) => r.kind === "snapshot" || r.kind === "cache"); + for (const r of reattach) { + expect(r.snapshotBytes ?? 0).toBeLessThanOrEqual(1024 * 1024); + } + }); + + test("symptom 2: reload with the session cached — active pane under budget", async ({ page }) => { + const id = await liveCreateLoadSession(page); + await page.goto(`/#/t/${id}`); + await expect(page.locator(".xterm-rows")).toContainText("SEQ", { timeout: 20_000 }); + // Let the client persist its cache, then reload. + await page.waitForTimeout(6000); + await page.reload(); + + const handle = await page.waitForFunction( + () => { + const m = performance + .getEntriesByType("measure") + .find((e) => e.name.startsWith("vogt-terminal-replay.")); + return m ? { duration: m.duration } : null; + }, + undefined, + { timeout: 20_000 }, + ); + const measure = (await handle.jsonValue()) as { duration: number }; + expect(measure.duration).toBeLessThan(REPLAY_BUDGET_MS); + await expect(page.locator(".xterm-rows")).not.toContainText("[disconnected]"); + }); +});