@fairfox/polly 0.55.0 → 0.56.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.
@@ -219,6 +219,101 @@ export interface TransportSnapshot {
219
219
  /** `performance.now()` at the time the stats were last refreshed. */
220
220
  at: number;
221
221
  }
222
+ /** Reasons the adapter declined to construct an RTC slot for a peer
223
+ * that appeared in the signalling roster or the keyring. Recorded
224
+ * per-peer in {@link MeshWebRTCAdapter.getPeerStateSnapshot} so a
225
+ * consumer harness observing "(no slot)" can tell which gate inside
226
+ * the adapter stopped construction without having to log-correlate
227
+ * through three layers of timing. Polly issue #106 item 7.
228
+ *
229
+ * - `self`: the peerId equals the local peerId.
230
+ * - `not-in-keyring`: the live keyring (or captured Set) does not
231
+ * currently authorise this peer.
232
+ * - `not-present`: the peer is not in the signalling roster. The
233
+ * adapter only dials peers it has heard about through
234
+ * `peers-present` or `peer-joined`; keyring entries that have not
235
+ * appeared on signalling are quietly held without a slot.
236
+ * - `tie-break-other-side`: the lex-tie-break designates the remote
237
+ * peer as the initiator; we wait for their offer.
238
+ * - `slot-already-exists`: a slot exists already, possibly in any
239
+ * negotiation state.
240
+ * - `fatal-error`: an exception was thrown while attempting to build
241
+ * the slot. The accompanying {@link SlotInitiationDecision.error}
242
+ * string carries the message.
243
+ */
244
+ export type SlotInitiationRejectionReason = "self" | "not-in-keyring" | "not-present" | "tie-break-other-side" | "slot-already-exists" | "fatal-error";
245
+ /** Most-recent slot-initiation decision for a peer. Computed at
246
+ * snapshot time so the view always reflects the current state of the
247
+ * relevant gates; a `decision === "accepted"` value paired with a
248
+ * `slot === undefined` view on the same snapshot is the load-bearing
249
+ * signal for "the adapter wants to dial but isn't" — the failure
250
+ * shape polly#106 documents. */
251
+ export interface SlotInitiationDecision {
252
+ /** "accepted" means every gate in `shouldInitiateTo` would pass right
253
+ * now and a sweep tick would construct a slot. "rejected" means at
254
+ * least one gate failed; the `reason` names it. */
255
+ decision: "accepted" | "rejected";
256
+ /** Populated only on `rejected` decisions. */
257
+ reason: SlotInitiationRejectionReason | undefined;
258
+ /** If the previous sweep tick caught a synchronous throw while
259
+ * building this peer's slot, the message is preserved here for the
260
+ * next snapshot. The reason will be `fatal-error`. */
261
+ error: string | undefined;
262
+ /** `performance.now()` at the time the decision was computed. */
263
+ at: number;
264
+ }
265
+ /** Most-recent sync-handshake timeline for a peer slot. Each timestamp
266
+ * is `performance.now()` of the corresponding event the first time it
267
+ * fired on the current slot — slots that get evicted and rebuilt start
268
+ * over. The four fields together describe whether the adapter and the
269
+ * receiver downstream of it have done their part in initiating sync:
270
+ *
271
+ * - `dataChannelOpenedAt`: when the wire is ready to carry bytes.
272
+ * - `peerCandidateEmittedAt`: when polly emitted `peer-candidate`
273
+ * upward; Automerge's network subsystem hooks this event to add the
274
+ * peer to its known set and kick off the per-document sync exchange.
275
+ * If this is `undefined` long after the data channel has opened,
276
+ * polly never signalled "ready" to Automerge — that's a failure in
277
+ * the adapter.
278
+ * - `firstOutboundSendAt`: when polly first dispatched bytes through
279
+ * {@link MeshWebRTCAdapter.send} for this peer. If
280
+ * `peerCandidateEmittedAt` is set but this is still `undefined`, the
281
+ * wrapper above polly (Automerge's NetworkSubsystem, MeshNetworkAdapter)
282
+ * never asked the adapter to send anything; that's a failure
283
+ * upstream of polly.
284
+ * - `firstInboundMessageAt`: when polly first emitted a `message` event
285
+ * for this peer. If `peerCandidateEmittedAt` is set on the remote and
286
+ * `firstOutboundSendAt` is set on the remote but this is `undefined`
287
+ * locally, bytes were sent across the wire but never decoded — that
288
+ * points at the crypto envelope or the wire fragmenter.
289
+ *
290
+ * Polly issue #106 item 7. */
291
+ export interface SyncHandshakeAttemptSnapshot {
292
+ dataChannelOpenedAt: number | undefined;
293
+ peerCandidateEmittedAt: number | undefined;
294
+ firstOutboundSendAt: number | undefined;
295
+ firstInboundMessageAt: number | undefined;
296
+ }
297
+ /** Sweep-loop observability for the periodic dial re-evaluation. The
298
+ * sweep is what catches peers that were not in the keyring at the time
299
+ * of their `peer-joined` notification (polly#103) AND peers whose
300
+ * previous slot failed and got evicted (polly#106). Exposing its tick
301
+ * counter lets a consumer distinguish "sweep is running but
302
+ * `shouldInitiateTo` rejected the peer" from "sweep is broken and
303
+ * never fires" without instrumenting polly internals. */
304
+ export interface SweepSnapshot {
305
+ /** True iff the sweep timer is currently scheduled — false on the
306
+ * captured-set fallback path (no keyringSource) or when the interval
307
+ * is configured to 0. */
308
+ enabled: boolean;
309
+ /** Configured interval in milliseconds. 0 means disabled. */
310
+ intervalMs: number;
311
+ /** How many times the sweep callback has fired since `connect()`. */
312
+ runCount: number;
313
+ /** `performance.now()` at the last tick. `undefined` until the
314
+ * first tick fires. */
315
+ lastRunAt: number | undefined;
316
+ }
222
317
  /** Per-peer view of an in-flight initial sync. Populated by
223
318
  * {@link MeshWebRTCAdapter.handleSyncFragment} as fragments of a
224
319
  * single reassembly arrive, and reset to `undefined` once the
@@ -301,6 +396,21 @@ export declare class MeshWebRTCAdapter extends NetworkAdapter {
301
396
  private readonly slots;
302
397
  private ready;
303
398
  private readyResolver;
399
+ /** Sticky cache of the most recent {@link SlotInitiationDecision}
400
+ * per peer. Updated on every `shouldInitiateTo` call and on every
401
+ * caught throw inside the sweep loop, so a snapshot taken at any
402
+ * moment can answer "why is there no slot for this peer right
403
+ * now?". Polly issue #106 item 7. */
404
+ private readonly lastSlotInitiationDecisions;
405
+ /** Tick count of the periodic sweep — incremented inside the
406
+ * `setInterval` callback, exposed via {@link getPeerStateSnapshot}.
407
+ * Lets a consumer rule out "sweep is dead" before chasing the
408
+ * shouldInitiateTo gates. */
409
+ private sweepRunCount;
410
+ /** `performance.now()` at the last sweep tick. Paired with
411
+ * {@link sweepRunCount} so a stalled sweep is visible at a glance
412
+ * via the snapshot's `sweep` block. */
413
+ private lastSweepAt;
304
414
  /** The peers this adapter will dial. Backward-compatible read accessor
305
415
  * for callers that previously iterated the `knownPeerIds` array. With
306
416
  * a {@link MeshWebRTCAdapterOptions.keyringSource} configured, the
@@ -348,10 +458,13 @@ export declare class MeshWebRTCAdapter extends NetworkAdapter {
348
458
  localPeerId: string;
349
459
  knownPeerIds: string[];
350
460
  presentPeerIds: string[];
461
+ sweep: SweepSnapshot;
351
462
  peers: Array<{
352
463
  peerId: string;
353
464
  knownInKeyring: boolean;
354
465
  presentInSignalling: boolean;
466
+ slotInitiationRejectionReason: SlotInitiationRejectionReason | undefined;
467
+ slotInitiationDecision: SlotInitiationDecision;
355
468
  slot: undefined | {
356
469
  signalingState: string;
357
470
  iceConnectionState: string;
@@ -361,6 +474,7 @@ export declare class MeshWebRTCAdapter extends NetworkAdapter {
361
474
  pendingRemoteIceCount: number;
362
475
  inFlightSync: InFlightSyncSnapshot | undefined;
363
476
  transport: TransportSnapshot | undefined;
477
+ lastSyncHandshakeAttempt: SyncHandshakeAttemptSnapshot;
364
478
  };
365
479
  }>;
366
480
  };
@@ -378,6 +492,17 @@ export declare class MeshWebRTCAdapter extends NetworkAdapter {
378
492
  * every listed peer, so a device joining into an established lobby
379
493
  * dials every knownPeer it is meant to initiate to in one pass. */
380
494
  handlePeersPresent(peerIds: string[]): void;
495
+ /** Construct an initiating slot inside a per-peer try/catch and
496
+ * record any throw as a `fatal-error` rejection on the per-peer
497
+ * decision map so the snapshot surface names it. Every dial entry
498
+ * point ({@link handlePeerJoined}, {@link handlePeersPresent},
499
+ * {@link addKnownPeer}, {@link refreshKnownPeers}) routes through
500
+ * here so a single peer's broken construction can never take down a
501
+ * batch of peers — pre-#106 a thrown `new RTCPeerConnection()`
502
+ * inside `handlePeersPresent` would skip every later peer in the
503
+ * same batch with no observable trace because the signalling
504
+ * client's frame dispatch swallowed the rejection. */
505
+ private tryCreateInitiatingSlot;
381
506
  /** Handle the signalling server's `peer-left` notification: a
382
507
  * previously joined peer has closed its socket. Evict any slot we
383
508
  * hold for that peer so a subsequent `peer-joined` from the same
@@ -406,9 +531,41 @@ export declare class MeshWebRTCAdapter extends NetworkAdapter {
406
531
  * dial the ones the keyring authorises that we do not already have
407
532
  * a slot for. The periodic sweep started in {@link connect} calls
408
533
  * this; consumers can call it manually to skip the wait after they
409
- * apply a fresh pairing token. Idempotent. */
534
+ * apply a fresh pairing token. Idempotent.
535
+ *
536
+ * A throw from {@link createInitiatingSlot} for one peer must not
537
+ * prevent the sweep from continuing to the next one — pre-#106 a
538
+ * synchronous throw inside `new RTCPeerConnection()` (a real risk
539
+ * once the page has built dozens of connections and Chrome's
540
+ * per-page cap is in play) skipped every later peer in the same
541
+ * sweep, with no observable trace because `setInterval` swallows
542
+ * the rejection silently. {@link tryCreateInitiatingSlot} caches
543
+ * the error onto the snapshot's slotInitiationRejectionReason as
544
+ * `fatal-error` so the failing peer is named instead of disguised
545
+ * as "(no slot)". */
410
546
  refreshKnownPeers(): void;
411
547
  private shouldInitiateTo;
548
+ /** Pure-function form of the gate cascade behind {@link shouldInitiateTo}.
549
+ * Returns `undefined` when every gate passes (the slot would be
550
+ * built); otherwise returns the named reason the dial was declined.
551
+ * Pulling the gates out of the boolean wrapper lets the snapshot
552
+ * surface name *which* gate stopped construction without the caller
553
+ * having to re-implement the check. Polly issue #106 item 7.
554
+ *
555
+ * The "not-present" gate is checked here even though the inbound
556
+ * call sites (`handlePeerJoined`, `handlePeersPresent`,
557
+ * `refreshKnownPeers`, `addKnownPeer`) only invoke `shouldInitiateTo`
558
+ * for peers in the signalling roster — so on those paths the gate
559
+ * never fires. It's load-bearing on the snapshot path, where the
560
+ * reason is computed for every peer the caller might ask about
561
+ * (including keyring entries that aren't currently signalling). */
562
+ private evaluateInitiation;
563
+ /** Compute the latest initiation decision for a peer at snapshot
564
+ * time. Prefers the cached decision when a sweep tick fixed the
565
+ * outcome to `fatal-error` (a thrown construction is sticky until
566
+ * the next successful sweep clears it); otherwise re-evaluates the
567
+ * gates against current state. Pure read; never mutates the map. */
568
+ private snapshotInitiationDecision;
412
569
  whenReady(): Promise<void>;
413
570
  /**
414
571
  * Start the adapter. Marks the adapter ready so Automerge's
@@ -428,7 +585,15 @@ export declare class MeshWebRTCAdapter extends NetworkAdapter {
428
585
  * keyring after their `peer-joined` notification has already fired.
429
586
  * No-op when no keyringSource was supplied — the captured-set
430
587
  * fallback has no live source to re-read, so the sweep would be
431
- * useless. No-op when the interval is configured to 0. */
588
+ * useless. No-op when the interval is configured to 0.
589
+ *
590
+ * Each tick increments {@link sweepRunCount} and stamps
591
+ * {@link lastSweepAt} *before* dispatching to
592
+ * {@link refreshKnownPeers} so a snapshot can distinguish "sweep is
593
+ * running but every peer is rejected" from "sweep is dead". An
594
+ * outer try/catch keeps the timer alive even if the per-peer
595
+ * try/catch inside `refreshKnownPeers` somehow leaks. Polly issue
596
+ * #106 item 7. */
432
597
  private startKnownPeersSweep;
433
598
  private stopKnownPeersSweep;
434
599
  /**
@@ -521,6 +686,19 @@ export declare class MeshWebRTCAdapter extends NetworkAdapter {
521
686
  * them — a single bad candidate must not stall the connection. */
522
687
  private flushPendingRemoteIce;
523
688
  private wireConnection;
689
+ /** Emit `peer-candidate` upward exactly once per slot lifetime,
690
+ * stamping the slot's `lastSyncHandshakeAttempt.peerCandidateEmittedAt`
691
+ * with the moment of emission. The "once" semantics protect Automerge's
692
+ * NetworkSubsystem from a double-add when both the
693
+ * `connectionstatechange = connected` event AND the
694
+ * `dataChannel.onopen` event fire on the same slot — under werift
695
+ * the former is sometimes flaky, under Chrome the latter sometimes
696
+ * fires first; pre-#106 only the connection-state path emitted, so a
697
+ * werift slot whose data channel opened cleanly but whose connection
698
+ * state never advanced past `connecting` would never signal "ready"
699
+ * upward. Polly issue #106 Failure B item — closing one named
700
+ * mechanism for "data channel open, sync never started". */
701
+ private emitPeerCandidateOnce;
524
702
  private wireDataChannel;
525
703
  /** Refresh the per-peer {@link TransportSnapshot} for one peer by
526
704
  * pulling {@link RTCPeerConnection.getStats} and distilling the
@@ -553,6 +731,11 @@ export declare class MeshWebRTCAdapter extends NetworkAdapter {
553
731
  * `inFlightSync` reassembly state worth bookkeeping. Small
554
732
  * single-message dispatches yield but don't touch inFlightSync. */
555
733
  private scheduleEmitMessage;
734
+ /** Stamp the slot's `firstInboundMessageAt` the first time a
735
+ * dispatched (non-fragment, non-blob) message lands for a peer. Pure
736
+ * observability for {@link SyncHandshakeAttemptSnapshot}; does not
737
+ * affect dispatch. */
738
+ private stampFirstInboundMessage;
556
739
  private finishInFlightSyncApply;
557
740
  private emitSyncProgress;
558
741
  private handleSyncFragment;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fairfox/polly",
3
- "version": "0.55.0",
3
+ "version": "0.56.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",