Skip to content

fix: chunk large scrollback replays to avoid WS backpressure lockout (#84) - #100

Merged
saucam merged 2 commits into
mainfrom
fix/scrollback-replay-chunking
Jul 4, 2026
Merged

fix: chunk large scrollback replays to avoid WS backpressure lockout (#84)#100
saucam merged 2 commits into
mainfrom
fix/scrollback-replay-chunking

Conversation

@saucam

@saucam saucam commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator

Fixes #84.

Problem

Session.attach() replayed the entire scrollback as a single scrollback.replay WS frame. A session near its 20 MB buffer cap produced a frame larger than the server's 16 MB outbound backpressure limit (closeOnBackpressureLimit: true), so Bun force-closed every client that attached — a permanent reconnect loop that made the session un-attachable. Separately, the one-shot JSON.stringify of the whole buffer stalled the daemon event loop on every attach.

Fix

  • ScrollbackBuffer.readChunked(maxBytes) — partitions buffered messages into ordered chunks (oldest→newest) using the byte sizes already accounted per entry (no re-serialization). A lone message larger than the budget gets its own chunk.
  • Session.attach() — sessions that fit one frame still send the legacy single-frame shape unchanged (no seq/final, fully synchronous). Larger sessions stream chunks paced on socket drain, so no single frame approaches the backpressure limit and the event loop isn't blocked.
  • Live-message buffering — because the paced replay is async, live broadcasts to the attaching client are buffered until the replay completes, so a newer live message can't interleave ahead of older replayed ones. Only engaged on the multi-chunk path.
  • Transport backpressureAttachedClient.flush?() resolves on socket drain (Bun getBufferedAmount + a drain handler; also released on close so a mid-replay detach can't hang).
  • ProtocolScrollbackReplayMsg gains optional seq/final (additive; ignore-unknown-fields discipline). Web client resets scrollback on seq === 0/absent and appends on seq > 0. Clients that predate the fields replace on each frame and end on the newest chunk (graceful degradation).

Bonus: the web UI and TUI get faster first paint on large sessions — chunks render incrementally instead of blocking on one large frame + parse.

Testing

  • ScrollbackBuffer.readChunked: empty → [], single-chunk-fits, ordered multi-chunk partition covering every message once within budget, lone-oversized-message gets its own chunk.
  • Session-level (real Session + restoreScrollback with 3×~3 MB messages): ordered seq/final frames covering every message; flush pacing (only the first chunk emitted while parked on drain); detach mid-replay stops streaming; live broadcast during replay is buffered and delivered after the final replay frame.
  • Web appendScrollback: extends a reset buffer in order, epoch bump + empty-chunk no-op, redelivered-messageId upsert keeps position (uses the O(1) indexBySession introduced in fix: O(1) positional lookup in the web streaming reducers #99).
  • Full suites green: daemon 780 pass, web 166 pass; both typecheck + biome lint clean.

Note

The WS-level flush/drain glue in server.ts (~15 lines over Bun's documented API) is not unit-tested — there's no real-socket daemon harness in the repo. The pacing logic it feeds is fully covered at the Session level via an injectable flush.

Follow-ups (not in this PR)

  • Rust TUI (codeoid-ui) can adopt the same append-on-seq>0 logic to show full history on large sessions; today it degrades gracefully to the newest chunk.
  • ScrollbackBuffer.readSince() exists but is still unwired — an incremental catch-up protocol message would further cut reconnect bandwidth on flaky links (relevant to the mobile client).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Large session scrollback now replays incrementally in ordered chunks to improve reliability for big histories.
    • Replay pacing is now coordinated with connection backpressure, with chunk updates gated by client readiness.
    • The web client now appends subsequent replay chunks instead of replacing prior scrollback.
  • Bug Fixes

    • Preserves chronological ordering by buffering live updates that arrive during an in-progress replay.
    • Detaching/disconnecting mid-replay stops further chunk delivery cleanly, preventing stale or duplicate updates.
  • Tests

    • Added coverage for chunk sizing, chunk replay ordering, backpressure gating, and append/replacement behavior in the web message store.

…84)

attach() replayed the entire scrollback as one scrollback.replay frame. A
session near its 20MB buffer cap produced a frame larger than the 16MB WS
outbound backpressure limit, so Bun force-closed every client that attached
(a permanent reconnect loop that made the session un-attachable), and the
one-shot JSON.stringify of the whole buffer stalled the event loop.

Partition the replay by byte budget (ScrollbackBuffer.readChunked) and stream
ordered chunks oldest->newest (seq/final), pacing on socket drain so no single
frame approaches the backpressure limit. Sessions small enough to fit one frame
still send the legacy single-frame shape unchanged. Because the pacing is async,
live broadcasts to the attaching client are buffered until the replay completes
so newer messages can't interleave ahead of older replayed ones. Also speeds
first paint for the web UI and TUI: chunks render incrementally instead of
blocking on one large frame + parse.

The seq/final fields are additive and optional; clients that predate them
replace on each frame and end on the newest chunk (graceful degradation).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6ad836c0-4e65-4a20-8382-74d362a9998c

📥 Commits

Reviewing files that changed from the base of the PR and between d6d2313 and ffd204b.

📒 Files selected for processing (1)
  • src/daemon/server.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/daemon/server.ts

📝 Walkthrough

Walkthrough

This PR splits large scrollback replays into bounded chunks, adds replay sequencing and completion fields, waits on WebSocket backpressure between chunks, and updates the web client to append multi-frame replay data in order.

Changes

Chunked Scrollback Replay

Layer / File(s) Summary
Scrollback chunking
src/daemon/scrollback.ts, src/tests/scrollback.test.ts
readChunked(maxBytes) partitions buffered messages into ordered byte-budgeted chunks, with unit tests for empty, single-chunk, multi-chunk, and oversized-message cases.
Replay protocol fields
src/protocol/types.ts, web/src/protocol/types.ts
ScrollbackReplayMsg gains optional seq and final fields to support multi-frame replay, documented in both server and web type definitions.
Server backpressure flush/drain
src/daemon/server.ts
ws.data gains drainWaiters; client.flush() waits for buffered amount to drop below a threshold; a new drain handler resolves waiters; close releases pending waiters.
Session replay streaming
src/daemon/session.ts, src/tests/session-integration.test.ts
Adds REPLAY_CHUNK_BYTES and optional flush() on AttachedClient; #streamReplay streams sequenced/final chunks, awaits flush between chunks, and buffers live broadcasts during replay; new T5b integration tests cover chunk ordering, pacing/detach, and buffering.
Web chunked replay consumption
web/src/state/messages.ts, web/src/state/connection.ts, web/src/state/messages.test.ts
appendScrollback upserts/appends chunked messages and bumps session epoch; connection.ts replaces scrollback on seq 0/undefined and appends otherwise; tests validate ordering, epoch bumps, and upsert behavior.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Session
    participant ScrollbackBuffer
    participant WebSocket
    participant WebStore

    Client->>Session: attach()
    Session->>ScrollbackBuffer: readChunked(REPLAY_CHUNK_BYTES)
    ScrollbackBuffer-->>Session: chunks[]

    loop each chunk
        Session->>WebSocket: send scrollback.replay(seq, final)
        WebSocket-->>Client: replay frame
        Session->>WebSocket: flush()
        WebSocket-->>Session: resolve on drain
        WebStore->>WebStore: replaceScrollback or appendScrollback
    end
Loading

Possibly related PRs

  • saucam/codeoid#73: Both PRs modify the web UI scrollback/message store behavior, and this PR’s chunked replay path appends into the same store structure.
  • saucam/codeoid#74: Both PRs modify src/daemon/scrollback.ts; this PR’s chunking relies on the per-entry size accounting introduced there.
  • saucam/codeoid#99: Both PRs modify web/src/state/messages.ts around message indexing and scrollback replay updates used by appendScrollback.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: chunking large scrollback replays to avoid WebSocket backpressure lockout.
Linked Issues check ✅ Passed The PR chunks replay frames and gates streaming on socket drain, which addresses the issue's backpressure lockout and giant-frame failure.
Out of Scope Changes check ✅ Passed The additional protocol, web client, and test updates are supporting changes for the chunked replay fix, not unrelated scope creep.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/scrollback-replay-chunking

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed. For unrecoverable errors, disable the tool in CodeRabbit configuration.


Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Jul 4, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 75.85%. Comparing base (f187ef7) to head (ffd204b).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #100      +/-   ##
==========================================
+ Coverage   75.66%   75.85%   +0.19%     
==========================================
  Files          66       66              
  Lines       11258    11308      +50     
==========================================
+ Hits         8518     8578      +60     
+ Misses       2740     2730      -10     
Flag Coverage Δ
daemon 75.85% <100.00%> (+0.19%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/daemon/scrollback.ts 100.00% <100.00%> (ø)
src/daemon/session.ts 75.09% <100.00%> (+1.21%) ⬆️
src/protocol/types.ts 100.00% <ø> (ø)
🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/daemon/server.ts (1)

376-376: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the repeated ws.data shape into a shared type.

The same inline ws.data type is spelled out four times (open, message, drain, close) with slightly different field subsets. That is drift-prone — a future field added to one cast can silently diverge from the others. Define one SocketData type and cast against it everywhere.

♻️ Suggested extraction
type SocketData = {
  clientId: string;
  authenticated: boolean;
  auth: AuthContext | null;
  authTimer?: ReturnType<typeof setTimeout>;
  drainWaiters?: Array<() => void>;
};

Then use const data = ws.data as SocketData; in each handler.

Also applies to: 385-385

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/daemon/server.ts` at line 376, The ws.data object shape is duplicated
across the server socket handlers, which risks the casts drifting apart over
time. Define a shared SocketData type in server.ts for the common fields used by
the open, message, drain, and close handlers, then replace each inline ws.data
cast with ws.data as SocketData so the type stays consistent in one place.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/daemon/server.ts`:
- Line 376: The ws.data object shape is duplicated across the server socket
handlers, which risks the casts drifting apart over time. Define a shared
SocketData type in server.ts for the common fields used by the open, message,
drain, and close handlers, then replace each inline ws.data cast with ws.data as
SocketData so the type stays consistent in one place.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 414cd2b0-8cd7-4a11-ae0e-b4d6838442c2

📥 Commits

Reviewing files that changed from the base of the PR and between f187ef7 and d6d2313.

📒 Files selected for processing (10)
  • src/daemon/scrollback.ts
  • src/daemon/server.ts
  • src/daemon/session.ts
  • src/protocol/types.ts
  • src/tests/scrollback.test.ts
  • src/tests/session-integration.test.ts
  • web/src/protocol/types.ts
  • web/src/state/connection.ts
  • web/src/state/messages.test.ts
  • web/src/state/messages.ts

Address CodeRabbit review on #100: the ws.data shape was spelled out
inline in four socket handlers (open/message/drain/close) with slightly
different field subsets — drift-prone. Define one SocketData type, cast
against it everywhere, and enforce the shape at the source via
`satisfies SocketData` on the upgrade init. Type-only change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@saucam

saucam commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator Author

Applied the nitpick in ffd204b — extracted a shared SocketData type used by all four socket handlers (open/message/drain/close), and enforced the shape at the source with satisfies SocketData on the upgrade init. Type-only change; tsc + biome clean, full daemon suite (786) green.

@saucam
saucam merged commit 7e42070 into main Jul 4, 2026
6 of 7 checks passed
@saucam saucam mentioned this pull request Jul 6, 2026
saucam added a commit that referenced this pull request Jul 6, 2026
…log (#117)

The 0.2.0 entry only covered the protocol/packages train (#100-#116) and
missed ten PRs that also ship in this release: the untrusted-content
sanitization and cross-tenant memory fixes (#91, #93 — now under a
proper Security heading), the performance run (#94-#99), and the model
catalog work (#78, #79).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

fix: scrollback replay frame can exceed WS backpressure limit and lock clients out of large sessions

1 participant