@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.
- package/package.json +1 -1
- package/src/index.ts +42 -17
package/package.json
CHANGED
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
|
|
1164
|
-
//
|
|
1165
|
-
//
|
|
1166
|
-
//
|
|
1167
|
-
//
|
|
1168
|
-
//
|
|
1169
|
-
//
|
|
1170
|
-
//
|
|
1171
|
-
//
|
|
1172
|
-
//
|
|
1173
|
-
//
|
|
1174
|
-
|
|
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
|
-
},
|
|
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
|
}
|