@pylonsync/sync 0.3.183 → 0.3.185

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.ts +42 -17
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.3.183",
6
+ "version": "0.3.185",
7
7
  "type": "module",
8
8
  "main": "src/index.ts",
9
9
  "types": "src/index.ts",
package/src/index.ts CHANGED
@@ -603,6 +603,19 @@ export interface SyncEngineConfig {
603
603
  * becomes visible).
604
604
  */
605
605
  reconcileOnVisibility?: boolean;
606
+ /**
607
+ * Client → server keepalive interval (ms). The Pylon dev server's
608
+ * dedicated :port+1 WS listener uses a dual-thread design where the
609
+ * writer thread wakes on every broadcast — pings are pure liveness,
610
+ * 25_000 (25s) is the right default. The HTTP-multiplexed
611
+ * `/api/sync/ws` fallback path uses a single-thread design where
612
+ * the reader's mutex unlocks only between reads; on THAT path,
613
+ * broadcast latency is bounded by this interval, so set it lower
614
+ * (e.g. 200) to trade traffic for delivery latency. Most production
615
+ * deployments should leave this at the default and configure their
616
+ * edge proxy to forward to the dual-thread listener instead.
617
+ */
618
+ pingIntervalMs?: number;
606
619
  }
607
620
 
608
621
  /**
@@ -1160,22 +1173,18 @@ export class SyncEngine {
1160
1173
  this.reconnectAttempts = 0;
1161
1174
  this.wsStableTimer = null;
1162
1175
  }, 5_000);
1163
- // Client-side keepalive ping at 200ms. The server's per-client
1164
- // reader thread blocks synchronously on `ws.read()` and holds the
1165
- // per-client mutex for the duration of the call. On the dedicated
1166
- // `:port+1` listener that block is bounded by a 200ms TCP read
1167
- // timeout (`stream.set_read_timeout`), so the broadcaster gets a
1168
- // window every 200ms. The HTTP-multiplexed `/api/sync/ws` path
1169
- // goes through tiny_http's `CustomStream`, which doesn't expose
1170
- // the underlying TcpStream, so we can't set a timeout there.
1171
- // These periodic JSON pings give the broadcaster the same 200ms
1172
- // window by causing the read to return (the server treats
1173
- // unknown `type` values as no-ops; the side effect is releasing
1174
- // the mutex between iterations). Browsers don't expose WS-level
1175
- // PING frames at the application layer, so we send a Text frame.
1176
- // Tradeoff: ~5 inbound frames/sec/client of background traffic
1177
- // for sub-second broadcast latency — worth it for the demo
1178
- // experience and idle tabs alike.
1176
+ // Client-side keepalive ping. Default 25s pure liveness, since
1177
+ // the dedicated :port+1 server uses a dual-thread design that
1178
+ // wakes the writer instantly on every broadcast (no mutex
1179
+ // contention with the reader, no ping-bounded latency).
1180
+ //
1181
+ // The HTTP-multiplexed `/api/sync/ws` fallback path is still
1182
+ // single-threaded (tiny_http's CustomStream hides the TcpStream
1183
+ // so we can't set a kernel-level read timeout). On that path,
1184
+ // broadcast latency IS bounded by this interval apps that
1185
+ // can't route to the :port+1 listener can pass
1186
+ // `init({ pingIntervalMs: 200 })` to trade traffic for latency.
1187
+ const pingIntervalMs = this.config.pingIntervalMs ?? 25_000;
1179
1188
  if (this.pingTimer) clearInterval(this.pingTimer);
1180
1189
  this.pingTimer = setInterval(() => {
1181
1190
  if (this.ws?.readyState !== WebSocket.OPEN) return;
@@ -1184,7 +1193,7 @@ export class SyncEngine {
1184
1193
  } catch {
1185
1194
  // ignore — onclose will trigger reconnect
1186
1195
  }
1187
- }, 200);
1196
+ }, pingIntervalMs);
1188
1197
  // Re-send any active CRDT subscriptions across the new socket.
1189
1198
  // The server purged them on disconnect (`unsubscribe_all`), so
1190
1199
  // without this resync a tab that was subscribed before a network
@@ -1726,6 +1735,13 @@ export class SyncEngine {
1726
1735
  // bypass the tombstone — re-creation server-side still propagates.
1727
1736
  const tombstoneSeq = this.cursor.last_seq;
1728
1737
  for (const entity of names) {
1738
+ // Capture cursor BEFORE the fetch so we can detect drift mid-
1739
+ // reconcile. If a WS event lands while this entity is being
1740
+ // pulled, our snapshot is already stale — applying it would
1741
+ // overwrite a newer authoritative row. Skip apply in that case
1742
+ // and rely on the WS event (which has the correct seq) plus the
1743
+ // next reconcile trigger to converge. Codex P1.
1744
+ const cursorBeforeFetch = this.cursor.last_seq;
1729
1745
  let serverRows: Row[];
1730
1746
  try {
1731
1747
  serverRows = await this.fetchEntityRows(entity);
@@ -1741,6 +1757,15 @@ export class SyncEngine {
1741
1757
  }
1742
1758
  continue;
1743
1759
  }
1760
+ if (this.cursor.last_seq !== cursorBeforeFetch) {
1761
+ // Cursor moved during fetch — at least one WS event for this
1762
+ // (or another) entity landed and might have a fresher value
1763
+ // for a row our snapshot just captured. Bail out for this
1764
+ // entity; reconcile() is triggered again on visibility-change
1765
+ // and reconnect, and the WS event already carried the latest
1766
+ // state for the affected row.
1767
+ continue;
1768
+ }
1744
1769
  await this.applyEntityReconcile(entity, serverRows, tombstoneSeq);
1745
1770
  }
1746
1771
  }