feat(web): bound per-subscriber delivery with gap frames, heartbeat and a contiguous client cursor
Summary
- A slow phone made the host hold an unbounded queue per subscription, and the client applied every event it received, even after a hole. A phone could lose or double text and never notice. A silent half-open link also looked alive.
- This change bounds what the host holds for each subscriber. The host tells the client when it drops a high-rate event. The host sends a heartbeat. The client applies events only when they are contiguous, and it judges the host from the frames it receives.
Related Issue
Refs #1070
Searched open and closed PRs for the same problem. No PR covers it. This is the third of several PRs for the read-model work in #1070. It builds on the epoch PR (#1089).
Type of Change
- feat: new feature
- fix: bug fix
- docs: documentation only
- style: formatting or non-functional styling changes
- refactor: internal cleanup with no behavior change
- perf: performance improvement
- test: adds or updates tests
- build: build or dependency changes
- ci: CI/CD workflow changes
- chore: maintenance or tooling
- revert: reverts a previous change
What Changed
- Each session subscription now feeds its connection through a bounded channel of 256 items. The old unbounded channel and the lossy ring are removed.
- A high-rate event that finds the channel full is dropped. The host then queues one
gapframe for that session ahead of the next event. The client installs a snapshot when it gets agap. - A reliable event that finds the channel full closes the connection. The client resumes by cursor. The existing queue tolerance still applies behind the channel.
- The write loop sends a
heartbeatframe every 10 seconds, straight to the socket. The watchdog is unchanged. - The client applies an event with
seq <= headSeqin order, and a later event only when it is the next seq. Any other seq is not applied. The session installs a snapshot instead, one at a time per session. - The client holds events that arrive before the
subscribeor snapshot reply. It judges them after the reply names the replay boundary. A pending permission request is still delivered after a snapshot. - The client judges host liveness from any received frame. After 25 seconds of silence it probes the host, then reconnects. A session whose resubscribe keeps failing is reported as degraded alone.
WS_PROTOCOL_VERSIONis now 4. Thegapandheartbeatframes are in both protocol registries. Docs and the changelog are updated.
How It Was Tested
-
bun run ci(Biome lint + format + imports, strict mode) -
bun run typecheck -
bun run test -
cargo clippy --all-targets -- -D warnings(in src-tauri) -
cargo test(in src-tauri) - Manual verification completed
- Not applicable
New Rust tests cover: a dropped high-rate event followed by one gap frame; a full channel closing the connection for reliable and idempotent events; a snapshot subscription closing the connection; heartbeat frames on a busy connection; resume by cursor after an overflow. New client tests cover: contiguity after the head, a skipped seq installing one snapshot, events that overtake the reply, the gap frame, host-frame liveness, live-only recovery, and per-session degrade.
Also ran cargo clippy --bin termul-server --features standalone-server -- -D warnings and bun run build:web.
Screenshots or Recordings
No user-visible UI change. The change affects the host and the transport only. The Rust and client tests above cover the behavior.
CI & Review Gate
- All CI checks pass (PR Validation, Rust Checks, Build Verification, Security Scans)
- Code review comments from CodeRabbit / Claude have been addressed or resolved
- No unresolved review findings remain
Checklist
- My PR title follows the conventional commit format used by this repo
- I linked the related issue or explained why none exists
- I updated docs when needed
- I added or updated tests when needed
- I verified the change does not introduce unrelated modifications
- I read AGENTS.md and followed the contributor guidelines
- A human reviewed the complete diff before submission
Summary by cubic
Bounds per-subscriber delivery so a slow client costs the host fixed memory and can no longer lose or double events without noticing, and a half-open link is detected from host frames instead of pings. Builds on the epoch PR for #1070.
- Each session subscription feeds its connection through a bounded 256-item channel; a full channel drops a LOSSY event, and the forwarder yields one
gapframe as soon as the channel drains (the client installs a snapshot) before closing the connection for reliable events so the client resumes by cursor. - The write loop sends a
heartbeatframe every 10 s; the client treats any received frame as proof of life and reconnects after 25 s of silence, degrading a single failing session alone. - The client applies events only when contiguous (replay may have holes, live events must be next seq); a skipped seq triggers a snapshot, and a gap held during a failed sync still snaps the session once it settles (one shared subscribe channel per session, reused across retries).
WS_PROTOCOL_VERSIONis now 4; thegapandheartbeatframes are registered in both protocol registries.
Migration โ No data migration needed; clients on protocol version 3 will reconnect under the new version.
Summary by CodeRabbit
- New Features
- Added WebSocket heartbeat frames to help detect unresponsive connections.
- Added gap notifications when lossy session events are dropped; affected sessions recover from a snapshot.
- Improved event replay and sequencing so duplicate or out-of-order events are handled safely.
- A session that cannot be resubscribed is now isolated, allowing the rest of the reconnect to continue.
- Bug Fixes
- Reliable-event overload now closes the affected connection, allowing events to be replayed after reconnect.
- Updated the connection error message when a session cannot be fully resynced.