@fairfox/polly 0.53.0 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -89,6 +89,20 @@ export interface CreateMeshClientOptions {
89
89
  * `examples/mesh-recovery-pair`). Forwarded straight to
90
90
  * {@link MeshWebRTCAdapterOptions.knownPeersRefreshIntervalMs}. */
91
91
  knownPeersRefreshIntervalMs?: MeshWebRTCAdapterOptions["knownPeersRefreshIntervalMs"];
92
+ /** Forward of {@link MeshWebRTCAdapterOptions.syncYieldEnabled}.
93
+ * Defaults to `true`. The `examples/mesh-large-initial-sync`
94
+ * example flips this to `false` when `POLLY_104_DISABLE_FIX=1` is
95
+ * set, to demonstrate the pre-#104 tight-loop behaviour against
96
+ * post-fix polly. Production callers should leave this at the
97
+ * default. */
98
+ syncYieldEnabled?: MeshWebRTCAdapterOptions["syncYieldEnabled"];
99
+ /** Forward of
100
+ * {@link MeshWebRTCAdapterOptions.syncFragmentChunkSizeOverride}.
101
+ * Production callers should leave this undefined. The
102
+ * `examples/mesh-large-initial-sync` example passes 64 KiB when
103
+ * `POLLY_104_DISABLE_FIX=1` to recreate the pre-#104
104
+ * fragmentation bug. */
105
+ syncFragmentChunkSizeOverride?: MeshWebRTCAdapterOptions["syncFragmentChunkSizeOverride"];
92
106
  };
93
107
  /** The local peer's keyring — one of three shapes:
94
108
  *
@@ -111,6 +111,78 @@ export interface MeshWebRTCAdapterOptions {
111
111
  * (e.g. `werift` or `@roamhq/wrtc`) when running outside a browser, or
112
112
  * to use a custom subclass for tests or instrumentation. */
113
113
  RTCPeerConnection?: typeof RTCPeerConnection;
114
+ /** When `true` (the default), the adapter yields to the event loop
115
+ * between the points where a large initial sync would otherwise hold
116
+ * the main thread for tens of seconds: between each batch of
117
+ * fragmented `RTCDataChannel.send` calls on the sender side, and at
118
+ * the boundary between reassembling a sync message and dispatching
119
+ * it (deserialise → MeshNetworkAdapter unwrap → Automerge
120
+ * `applyChanges`) on the receiver side. Set to `false` to recover
121
+ * the pre-#104 tight-loop behaviour; this is the configuration the
122
+ * `POLLY_104_DISABLE_FIX=1` falsification path in
123
+ * `examples/mesh-large-initial-sync` uses to demonstrate the bug
124
+ * against post-fix polly. Production callers should leave this at
125
+ * the default. */
126
+ syncYieldEnabled?: boolean;
127
+ /** Override the sync fragment chunk size. Defaults to
128
+ * {@link SYNC_FRAGMENT_CHUNK_SIZE} (60 KiB), which leaves header
129
+ * overhead inside werift's hard 64 KiB max-message-size cap.
130
+ * Setting this to 64 KiB recreates the pre-#104 fragmentation bug,
131
+ * where peer A's outbound fragments overshoot the cap and werift
132
+ * rejects them silently — sync stalls forever. Used by the
133
+ * `POLLY_104_DISABLE_FIX=1` falsification path. Production callers
134
+ * should leave this at the default. */
135
+ syncFragmentChunkSizeOverride?: number;
136
+ }
137
+ /** Payload of the polly-specific `"sync-progress"` event emitted by
138
+ * {@link MeshWebRTCAdapter}. Consumers can subscribe via the adapter's
139
+ * standard `.on()` surface (the same one that carries `peer-candidate`
140
+ * and `peer-disconnected`) to observe fragment receive and dispatch
141
+ * activity in real time, without polling
142
+ * {@link MeshWebRTCAdapter.getPeerStateSnapshot}. Polly issue #104
143
+ * item 7. */
144
+ export interface SyncProgressEvent {
145
+ /** Remote peer the fragment or dispatch is for. */
146
+ peerId: string;
147
+ /** Lifecycle stage. `fragment-received` fires for each chunk that
148
+ * arrives during reassembly; `dispatch-applied` fires once the
149
+ * reassembled message has been emitted upward to Automerge. */
150
+ kind: "fragment-received" | "dispatch-applied";
151
+ /** Bytes carried by the chunk that triggered the event. Zero for
152
+ * `dispatch-applied`. */
153
+ bytesDelta: number;
154
+ /** Running total of fragments received for the current reassembly. */
155
+ chunksReceived: number;
156
+ /** Running total of bytes received for the current reassembly. */
157
+ bytesReceived: number;
158
+ /** Number of reassembled messages whose dispatch has been scheduled
159
+ * but not yet emitted upward to Automerge. */
160
+ applyBacklog: number;
161
+ /** `performance.now()` at event emission. */
162
+ at: number;
163
+ }
164
+ /** Per-peer view of an in-flight initial sync. Populated by
165
+ * {@link MeshWebRTCAdapter.handleSyncFragment} as fragments of a
166
+ * single reassembly arrive, and reset to `undefined` once the
167
+ * reassembled message has been dispatched. Exposed verbatim through
168
+ * {@link MeshWebRTCAdapter.getPeerStateSnapshot} so a consumer
169
+ * harness can observe progress mid-stream. Polly issue #104 item 7. */
170
+ export interface InFlightSyncSnapshot {
171
+ /** Fragments received for the current reassembly. Cleared once
172
+ * reassembly completes. */
173
+ chunksReceived: number;
174
+ /** Bytes received across the fragments of the current reassembly.
175
+ * The reassembled message will be slightly smaller than this sum
176
+ * because each fragment carries a small header. */
177
+ bytesReceived: number;
178
+ /** `performance.now()` value at the last fragment arrival. */
179
+ lastChunkAt: number;
180
+ /** Count of reassembled messages whose dispatch has been
181
+ * scheduled but not yet run. With the receiver-side `setTimeout(0)`
182
+ * yield enabled, this is normally 0 or 1; with the yield
183
+ * disabled (the falsification path) dispatch runs synchronously
184
+ * and this stays 0. */
185
+ applyBacklog: number;
114
186
  }
115
187
  /**
116
188
  * Automerge-Repo NetworkAdapter backed by real WebRTC data channels.
@@ -142,6 +214,17 @@ export declare class MeshWebRTCAdapter extends NetworkAdapter {
142
214
  * {@link MeshWebRTCAdapterOptions.knownPeersRefreshIntervalMs} at
143
215
  * construction. Defaults to 2000ms; tests override to 100–250ms. */
144
216
  private readonly knownPeersRefreshIntervalMs;
217
+ /** When `true`, the sender side awaits between batches of fragment
218
+ * sends and the receiver side schedules dispatch via `setTimeout(0)`
219
+ * so the JS event loop can drain timers (including the consumer's
220
+ * own setInterval-based liveness probes) between large-message
221
+ * apply calls. Defaults to `true`; set to `false` only by the
222
+ * `POLLY_104_DISABLE_FIX` falsification path. */
223
+ private readonly syncYieldEnabled;
224
+ /** Resolved chunk size for fragmenting oversized messages.
225
+ * Defaults to {@link SYNC_FRAGMENT_CHUNK_SIZE}; can be overridden
226
+ * via {@link MeshWebRTCAdapterOptions.syncFragmentChunkSizeOverride}. */
227
+ private readonly syncFragmentChunkSize;
145
228
  /** Peers currently visible in the signalling roster — populated by
146
229
  * {@link handlePeersPresent} / {@link handlePeerJoined} and pruned by
147
230
  * {@link handlePeerLeft}. Read by {@link addKnownPeer} to decide
@@ -216,6 +299,7 @@ export declare class MeshWebRTCAdapter extends NetworkAdapter {
216
299
  dataChannelState: string;
217
300
  pendingSendCount: number;
218
301
  pendingRemoteIceCount: number;
302
+ inFlightSync: InFlightSyncSnapshot | undefined;
219
303
  };
220
304
  }>;
221
305
  };
@@ -293,13 +377,30 @@ export declare class MeshWebRTCAdapter extends NetworkAdapter {
293
377
  * queued until the data channel is open.
294
378
  */
295
379
  send(message: Message): void;
380
+ /** Number of consecutive fragment sends after which the sender
381
+ * yields to the macrotask queue when {@link syncYieldEnabled} is on.
382
+ * 8 × 64 KiB = 512 KiB of bytes between yields — small enough that
383
+ * a 5 MB sync produces many yield points (and the JS event loop
384
+ * drains the consumer's `setInterval` liveness probes between
385
+ * them), large enough that the per-yield overhead does not dominate
386
+ * the wire cost. */
387
+ private static readonly SEND_YIELD_EVERY_N_FRAGMENTS;
296
388
  /** Send raw wire bytes, fragmenting if they exceed the SCTP maxMessageSize
297
389
  * cap. The default RTCDataChannel limit is 256 KiB in current Chrome and
298
390
  * werift; oversized sends either throw, drop silently, or stall the
299
391
  * channel, none of which surface as an error to the caller. Fragments
300
392
  * use the same length-prefixed JSON header wire format as ordinary
301
393
  * messages but carry a `sync-fragment` type that the receive path
302
- * detects and reassembles before deserialising. */
394
+ * detects and reassembles before deserialising.
395
+ *
396
+ * When {@link MeshWebRTCAdapterOptions.syncYieldEnabled} is true (the
397
+ * default), the loop awaits the macrotask queue every
398
+ * {@link SEND_YIELD_EVERY_N_FRAGMENTS} fragments so the JS event
399
+ * loop drains between batches — without this, a 5–8 MB initial
400
+ * sync produces 78–125 back-to-back `RTCDataChannel.send` calls in
401
+ * a tight loop, starving anything else on the main thread (polly
402
+ * issue #104, sender side). When the option is false the legacy
403
+ * tight-loop shape is preserved for the falsification path. */
303
404
  private sendBytesMaybeFragmented;
304
405
  /**
305
406
  * Entry point the signalling client calls when it receives a signal
@@ -323,7 +424,31 @@ export declare class MeshWebRTCAdapter extends NetworkAdapter {
323
424
  private wireConnection;
324
425
  private wireDataChannel;
325
426
  private dispatchMessage;
427
+ /** Hand a deserialised Automerge message off to whoever is listening
428
+ * on this adapter's `"message"` event. When
429
+ * {@link MeshWebRTCAdapterOptions.syncYieldEnabled} is true (the
430
+ * default), the emit runs on a fresh macrotask so the crypto-unwrap
431
+ * and Automerge `applyChanges` chain downstream of this method does
432
+ * not sit on the same JS stack frame as the wire `onmessage` callback
433
+ * — that's the receiver-side starvation site polly issue #104
434
+ * documents. When the option is false the emit is synchronous,
435
+ * recovering the pre-fix shape used by the falsification path.
436
+ *
437
+ * The `viaFragmentPath` argument tags whether this dispatch came out
438
+ * of a reassembled fragment chain; only those carry an
439
+ * `inFlightSync` reassembly state worth bookkeeping. Small
440
+ * single-message dispatches yield but don't touch inFlightSync. */
441
+ private scheduleEmitMessage;
442
+ private finishInFlightSyncApply;
443
+ private emitSyncProgress;
326
444
  private handleSyncFragment;
445
+ /** Dispatch a reassembled fragment payload back through
446
+ * {@link dispatchMessage}, but tagged so the
447
+ * {@link scheduleEmitMessage} path knows it owes a
448
+ * `finishInFlightSyncApply` afterwards. Synchronous re-entry into
449
+ * `dispatchMessage` would lose that signal, so the post-fragment
450
+ * deserialise+emit is inlined here. */
451
+ private dispatchReassembled;
327
452
  /** Peer IDs with an open data channel, suitable for blob requests. */
328
453
  get connectedPeerIds(): string[];
329
454
  /** Send a pre-serialised blob message to a specific peer. Returns false
@@ -17,12 +17,23 @@
17
17
  * does not mistake a sync fragment for a blob chunk.
18
18
  */
19
19
  /** Maximum bytes a single channel.send may carry without fragmentation.
20
- * Chosen well below the 256 KiB SCTP cap so the framing header for a
21
- * single chunk cannot push the wire frame over the limit. Matches the
22
- * blob-transfer chunk size so the two transports have a consistent
23
- * per-message footprint on the data channel. */
20
+ * Werift (the node-side WebRTC implementation polly recommends for
21
+ * CLI/daemon use) enforces a hard 64 KiB (65536 bytes) maxMessageSize
22
+ * on its RTCDataChannel anything larger is rejected with a
23
+ * `max-message-size exceeded` error and silently drops the channel
24
+ * for that send. Chrome's SCTP cap is 256 KiB and would tolerate
25
+ * larger frames, but the threshold is chosen to fit inside werift's
26
+ * cap WITH per-fragment header overhead included so a single mesh
27
+ * deployment works on both transports. Matches the blob-transfer
28
+ * chunk size so the two transports have a consistent per-message
29
+ * footprint on the data channel. */
24
30
  export declare const SYNC_FRAGMENT_THRESHOLD: number;
25
- /** Chunk size used when a message exceeds the threshold. */
31
+ /** Chunk size used when a message exceeds the threshold. Left at the
32
+ * same value as {@link SYNC_FRAGMENT_THRESHOLD} so the framing
33
+ * header (a JSON-encoded `SyncFragmentHeader` of ~90 bytes plus a
34
+ * 4-byte length prefix) does not push any fragment over werift's
35
+ * 64 KiB wire limit — see polly issue #104 for the failure mode
36
+ * this guards against. */
26
37
  export declare const SYNC_FRAGMENT_CHUNK_SIZE: number;
27
38
  export interface SyncFragmentHeader {
28
39
  type: "sync-fragment";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fairfox/polly",
3
- "version": "0.53.0",
3
+ "version": "0.54.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Multi-execution-context framework with reactive state and cross-context messaging for Chrome extensions, PWAs, and worker-based applications",