Skip to content

fix(collaboration): survive strict-Event dispatch on Workers; log sync frame decode failures - #6047

Merged
synoet merged 8 commits into
mainfrom
synoet/sync-init-snapshot-fallback-7d1d
Aug 31, 2026
Merged

synoet merged 8 commits into
mainfrom
synoet/sync-init-snapshot-fallback-7d1d

Conversation

@synoet

@synoet synoet commented Aug 31, 2026 •

Copy link
Copy Markdown
Contributor

Context

Every production EditDocument (MCP and in-app AI edit) has failed since 2026-08-26 14:02 UTC with an opaque initial sync failed: timeout (10000ms) — zero successes since, across all documents, in prod and dev alike, with no Macro deploy at the cutoff. Browsers were unaffected.

Instrumented dev deploys during the investigation established the failure chain precisely:

  1. The worker's WebSocket handshake reaches sync-service, which sends the initial sync (its /connect span records snapshot.bytes).
  2. The frame arrives intact — an independent probe decode of the received bytes succeeded (ok:initial_sync, valid Bebop envelope + loro snapshot magic).
  3. Dispatch then dies inside the worker in the same millisecond the frame arrives: the transport wrapper's message handler never completes, no deserialize result is ever seen, and the socket is errored without a close — a zombie that never retries, burning the whole 10s window.

The timing matches workerd v1.20260825.1 (in production at the cutoff), which shipped a large Event/EventTarget standards refactor (workerd#7053). The wrapper's message handler copies origin/lastEventId/source off the native event into a re-dispatched MessageEvent; under stricter Event internals that copy can throw, and a throw escaping a message listener into native dispatch errors out the socket.

Change

One file: packages/collaboration/src/websocket/core/websocket.ts.

  • Fix: construct the re-dispatched MessageEvent with a data-only fallback when copying origin/lastEventId/source throws. Consumers only read data.
  • Log: when an incoming frame fails to deserialize, emit a clear error before rethrowing — frame shape (ArrayBuffer of N bytes), token-stripped socket path, and the exception. Previously that throw died inside the runtime's native dispatch with no trace, making an undecodable frame indistinguishable from a frame that never arrived.

The instrumentation and snapshot-fallback commits from earlier revisions of this branch are fully reverted; diagnostics served their purpose during the investigation.

Testing

  • Collaboration suite 228/228 (websocket transport tests included); ai-editing-worker suite 352/352; tsc --noEmit and biome clean in both packages.
  • Local e2e, healthy path: POST /edit against the local stack → HTTP 200 in 7.3s. Behavior unchanged.
  • Local e2e, poisoned frame (WS proxy XOR-corrupts the first server→client frame): still the expected 502 timeout, but the worker log now reads
    websocket: failed to deserialize incoming message (ArrayBuffer of 3740 bytes) from ws://…/document/<id>/connect: BebopRuntimeError: Unrecognized discriminator while decoding FromRemote
    — decode failures are no longer silent.

Rollout

Deploy dev (bun run deploy-dev in services/ai-editing-worker, or the Deploy AI Editing Worker workflow dispatch from this branch) and run one edit. Dev currently fails 100%, so one successful edit confirms the MessageEvent copy was the killing throw. If it still times out, wrangler tail --env dev now distinguishes the remaining possibilities: a deserialize-failure log means bad bytes; silence with an instant socket error means the runtime is aborting multi-listener dispatch — evidence for a Cloudflare ticket referencing workerd#7053 / STOR-5552.

Open in Web Open in Cursor 

cursoragent and others added 2 commits August 30, 2026 18:47
…rumentation

Every prod EditDocument since 2026-08-26 fails with an opaque
"initial sync failed: timeout (10000ms)". Add passive WebSocket
diagnostics to the worker so the next timeout says how far the
connection got: never opened, raw frame arrived but never decoded
(e.g. Blob where Bebop expects ArrayBuffer), or decoded frames with
no RemoteInitialSync.

- new sync-diagnostics recorder wraps the platform WebSocket factory
  and observes open/message/close/error on the raw socket
- decoded-frame kinds counted via the wrapper's public Message event
- edit.sync_init span gets sync.ws.* attrs; the timeout error message
  carries a compact summary into the 502 body
- createSyncSocket accepts an optional socket factory (additive)
- WebsocketBuilder.withSerializer no longer drops a factory set
  before it

Co-authored-by: teo <synoet@users.noreply.github.com>
…ial sync is lost

Every prod edit has died on 'initial sync failed: timeout (10000ms)' since
2026-08-26 14:02 UTC. Sync-service accepts the worker's /connect and sends
the snapshot (span records snapshot.bytes), but the frame never reaches the
worker: a workerd hibernation regression (STOR-5552; cloudflare/workerd#7163,
follow-up #7147) dropped sends caught in the connect-request handover to the
hibernation manager.

PeerRequestSnapshot -> RemoteSnapshot is served from the message handler on
the manager-owned socket, which does not cross that boundary and demonstrably
still works in prod. WorkerSyncSource (worker-only subclass) requests a
snapshot when the pushed RemoteInitialSync stays silent for 2s and bootstraps
from the reply; first success wins. Browsers keep the plain push-based flow —
the shared source only widens initialSyncReceived to protected.

Co-authored-by: teo <synoet@users.noreply.github.com>
@coderabbitai

coderabbitai Bot commented Aug 31, 2026 •

Copy link
Copy Markdown
Contributor

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: 602583df-6f9b-4bc8-802d-d51b15dd95f3

📥 Commits

Reviewing files that changed from the base of the PR and between 0ac1c24 and 7c18cc6.

📒 Files selected for processing (1)
  • packages/collaboration/src/websocket/core/websocket.ts

Included review availability: Your plan provides up to 8 included reviews per hour; 6 remain after this review.


📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes
    • Improved error reporting when incoming collaboration messages cannot be deserialized.
    • Sensitive URL query parameters are excluded from diagnostic logs.
    • Added a compatibility fallback for environments that cannot construct messages with full event metadata.

Walkthrough

The WebSocket message handler now describes frame data without reading its bytes and redacts URL query strings before logging. Deserialization failures are logged with the frame description and redacted URL, then rethrown. Message event construction now falls back to a data-only MessageEvent when construction with metadata throws.

Merge Risk: ⚪ Minimal · up to 7c18c

This localized update improves WebSocket resilience and makes decode failures visible without changing the established message contract; no actionable merge-blocking risk remains after normal checks and review.

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Title check ⚠️ Warning The title uses the required conventional commit prefix and accurately describes the changes, but it is 92 characters and exceeds the 72-character limit. Shorten the title to 72 characters or fewer while retaining the fix: prefix and main change.
✅ Passed checks (3 passed)
Check name Status Explanation
Description check ✅ Passed The description directly explains the Worker dispatch fix, deserialization logging, testing, and rollout details.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
  • Fix all pre-merge checks with AI

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@github-actions

github-actions Bot commented Aug 31, 2026 •

Copy link
Copy Markdown

cursoragent and others added 3 commits August 31, 2026 00:12
… break delivery

The diagnostics listeners attach to the native socket ahead of the transport
wrapper's handlers. A listener exception there could suppress the wrapper's
dispatch for that event (or error out the socket under workerd's fail-fast
event dispatch), making the instrumentation cause the failure it measures.
The bodies are all typeof/instanceof checks that should not throw; swallow
exceptions anyway so observation stays observation.

Co-authored-by: teo <synoet@users.noreply.github.com>
Dev deploy of the fallback still failed, and its diagnostics finally showed
why: the initial sync ARRIVES (785-byte ArrayBuffer, matching the 771-byte
snapshot the sync service recorded sending) but fails to deserialize, and the
deserialize throw escapes the wrapper's message listener into the runtime's
event dispatch, which errors out the socket without a close. The zombie
socket never retries and silently swallowed the snapshot fallback request.

Two changes:
- Tolerant serializer (worker-only): deserialize failures report to
  diagnostics and yield an inert message instead of throwing, so the socket
  survives and the snapshot fallback still runs. createSyncSocket gains an
  optional serializer parameter (additive; browser flow unchanged).
- Diagnostics v2: hex prefix + independent probe decode (with exception text)
  of the first frame, ordered event log with ms offsets, and decode-failure
  counters — the next dev run will say exactly what the bytes are.

Co-authored-by: teo <synoet@users.noreply.github.com>
…eEvent copy

The probe build on dev showed the frame is VALID (probe=ok:initial_sync,
hex has the Bebop envelope + loro magic) yet decoded=0 with no deserialize
attempt and an error event in the same ms as the frame: dispatch dies between
the first-registered native listener and the wrapper's handler.

- Wrapper: copying origin/lastEventId/source into the re-dispatched
  MessageEvent can throw on runtimes with stricter Event internals; fall back
  to a data-only event (consumers only read data).
- Diagnostics: route every native-socket listener through a guard that
  contains exceptions and records a per-listener start/ok/threw trail plus
  the first error with stack line (sync.ws.first_listener_error) — the next
  run distinguishes 'listener threw' from 'runtime aborted dispatch'.

Co-authored-by: teo <synoet@users.noreply.github.com>
@cursor cursor Bot changed the title fix(ai-editing-worker): bootstrap from a requested snapshot when initial sync is lost fix(ai-editing-worker): intrument ws connection, investigate failures Aug 31, 2026
…cs + dispatch fix

Scope the PR to instrumentation and the dispatch fix per review: remove
WorkerSyncSource, the tolerant serializer, createSyncSocket's serializer
param, the protected widening of initialSyncReceived, and the worker's
neverthrow dependency. Deserialize throws are still contained and recorded by
the diagnostics listener guards (listener_errors / first_listener_error), so
a poison frame degrades to a fully-described timeout instead of a zombie
socket.

Co-authored-by: teo <synoet@users.noreply.github.com>
@cursor cursor Bot changed the title fix(ai-editing-worker): intrument ws connection, investigate failures fix(ai-editing-worker): sync socket diagnostics + survive workerd's strict-Event dispatch Aug 31, 2026
… dispatch fix + decode-failure log

Net change vs main is now a single file. The wrapper's message path:
- logs a clear, token-free error when an incoming frame fails to
  deserialize (frame shape + socket path + exception), so an undecodable
  initial sync is distinguishable from one that never arrived — previously
  the throw died inside the runtime's native dispatch with no trace;
- constructs the re-dispatched MessageEvent with a data-only fallback, since
  copying origin/lastEventId/source can throw on runtimes with stricter
  Event internals (Cloudflare Workers) and error out the socket.

Co-authored-by: teo <synoet@users.noreply.github.com>
@cursor cursor Bot changed the title fix(ai-editing-worker): sync socket diagnostics + survive workerd's strict-Event dispatch fix(collaboration): survive strict-Event dispatch on Workers; log sync frame decode failures Aug 31, 2026
…ilure log

Co-authored-by: teo <synoet@users.noreply.github.com>
@synoet
synoet marked this pull request as ready for review August 31, 2026 01:19
@synoet
synoet requested a review from 404Wolf August 31, 2026 01:25
@synoet
synoet merged commit 3f842eb into main Aug 31, 2026
31 of 37 checks passed
@synoet
synoet deleted the synoet/sync-init-snapshot-fallback-7d1d branch August 31, 2026 01:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants