Skip to content

perf: end-to-end terminal output compression (deflate-raw-v1) - #301

Merged
chareice merged 3 commits into
mainfrom
perf-ws-compression
Aug 30, 2026
Merged

perf: end-to-end terminal output compression (deflate-raw-v1)#301
chareice merged 3 commits into
mainfrom
perf-ws-compression

Conversation

@chareice

Copy link
Copy Markdown
Collaborator

What

Terminal output now travels compressed machine→browser: raw DEFLATE per attach stream (context takeover, sync flush per WS message). Measured 20-30x on realistic ANSI output at ~365 MB/s/core — the dominant win for remote/mobile links where output bursts (builds, tmux redraws) saturate the pipe.

Why in-protocol instead of permessage-deflate: tungstenite has never shipped a deflate codec (issue #2 open since 2017, PR #426 unmerged) and axum exposes no negotiation — full evidence and measurements in docs/plans/2026-08-30-permessage-deflate-REPORT.md. In-protocol also lets the hub relay compressed bytes zero-copy with no per-connection deflate state.

  • Negotiation, version-skew safe both directions (desktop ships its own frontend bundle): machine advertises deflate-raw-v1 in Register → browser opts in via query param (escape hatch localStorage webmux:compress=off) → hub acks with a compression_enabled text frame strictly before any binary frame, only when both ends opted in. All protocol fields serde(default) + old-peer compat tests.
  • Input/keystrokes, CLI and preview attaches stay uncompressed — the latency direction is never touched.
  • Browser inflates with fflate's synchronous streaming Inflate feeding the existing output path (immediate-first-write preserved); inflate errors fail closed (frames swallowed, socket closes, reconnect renegotiates fresh).
  • Machine-side compressor lifecycle rides the ordered send channel (created before first chunk, dropped on CloseAttach/AttachDied); hub/browser chunk-merging stays valid because each attach is one deflate stream.
  • Includes the tokio-tungstenite 0.26→0.28 workspace bump as its own commit (zero source changes, dedupes the lockfile's double tungstenite).

Verification

  • cargo workspace tests: codec round-trip incl. arbitrary split/merge, sync-flush tail on wire, context takeover, serde old-peer compat (machine 39 / protocol 21 / hub 98 / cli 49).
  • vitest incl. new attachCompression.test.ts (ack gating, unknown-algo refusal, fail-closed); typecheck; build.
  • Full e2e suite 83/83 with compression ON by default + dedicated terminal-compression.spec.ts (ack + seq 1 2000 stream integrity). One mobile-IME flake A/B-tested at 30 runs each with compression on/off: ~8-9% both ways → pre-existing perf: end-to-end input latency, scroll feel, and mobile IME fixes #295 spec race, compression-independent (details in docs/plans/2026-08-30-ws-compression-e2e-REPORT.md).

🤖 Generated with Claude Code

https://claude.ai/code/session_01R2ewppMpeyasebTgcwDCNL

chareice and others added 3 commits August 30, 2026 07:08
Dedups the lockfile (axum 0.8 already pulled 0.28; cli/machine legs were on
0.26.2). Prerequisite for the deflate-raw-v1 compression work; no source
changes were needed.
Terminal output now travels compressed from machine to browser: raw
DEFLATE, one long-lived compressor per attach (context takeover), sync
flush per WS message. Measured 20-30x on realistic ANSI streams at
~365 MB/s/core (research report in docs/plans). Real permessage-deflate is
unavailable in our stack — tungstenite has no codec and axum exposes no
negotiation — so this is in-protocol, which also lets the hub relay
compressed bytes zero-copy without per-connection deflate state.

Negotiation is version-skew safe in both directions (desktop bundles its
own frontend): the machine advertises the capability in Register, the
browser opts in via a `compress` query param (escape hatch: localStorage
webmux:compress=off), and the hub acks with a CompressionEnabled text
frame strictly before any binary frame, only when both sides opted in. All
new protocol fields are serde(default) with old-peer compat tests. Input,
CLI, and preview attaches stay uncompressed — keystroke latency direction
is never touched.

Browser side inflates with fflate's synchronous streaming Inflate feeding
the existing output path (immediate-first-write preserved); inflate errors
fail closed (frames swallowed, socket closed, reconnect renegotiates).

Verification: cargo workspace tests (codec round-trip incl. arbitrary
split/merge, context takeover, serde compat), vitest, full e2e suite 83/83
with compression on by default, plus a dedicated terminal-compression spec.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2ewppMpeyasebTgcwDCNL
…watermark

Under a starved main thread (slow phones, 2-core CI) CompositionHelper's
setTimeout(0) finalize timers queue behind input tasks. A stale timer then
computes its send region from LIVE state — the newest composition's
position, the live textarea value, a _dataAlreadySent token the next
compositionstart destroys — while cancellation is one shared boolean every
compositionend re-arms. Result: the same committed CJK text reaches onData
twice (upstream xterm.js#5023, unfixed in 6.1.0-beta.303; event traces in
docs/plans/2026-08-30-ime-dup-diagnosis-REPORT.md pin the arithmetic, and
the sequence is bit-identical to real mobile IME events).

Instance-patch the helper next to patchScaledMouseCoordinates: track how
much of the textarea prefix has already been emitted and clamp every
deferred send region to start at or after it. Purely positional — content
is never compared, legitimate repeated text still flows. No-ops gracefully
if xterm's internals change.

Repro at 10x CPU throttle went 14/20 failing to 0/20; the IME e2e spec
passes 50/50 un-throttled; full suite 83/83 with compression on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2ewppMpeyasebTgcwDCNL
@chareice

Copy link
Copy Markdown
Collaborator Author

Added 8dfe22f: the CI-only mobile-ime-composition:225 failures turned out to be a real product bug, not flake — xterm's CompositionHelper deferred-send race (upstream xterm.js#5023, still unfixed in 6.1.0-beta.303). On a starved main thread, stale setTimeout(0) finalize timers compute over-wide send regions from live state and duplicate committed CJK text; this branch's main-thread inflate work widened the window enough to make CI fail 3/3. Event traces pinning the exact arithmetic (and proving the sequence is bit-identical to real mobile IME events) are in docs/plans/2026-08-30-ime-dup-diagnosis-REPORT.md.

Fix: positional emitted-prefix watermark clamped onto the helper's send paths (instance patch beside patchScaledMouseCoordinates, graceful no-op if internals change). Repro at 10x CPU throttle: 14/20 failing → 0/20; IME spec 50/50 un-throttled; full suite 83/83 with compression on.

@chareice
chareice merged commit 91c5dbd into main Aug 30, 2026
5 of 6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant