@componentor/fs 3.0.46 → 3.0.48

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/dist/index.d.mts CHANGED
@@ -359,6 +359,8 @@ declare class VFSFileSystem {
359
359
  private isFollower;
360
360
  private holdingLeaderLock;
361
361
  private brokerInitialized;
362
+ private brokerHeartbeatTimer;
363
+ private brokerControlPort;
362
364
  private leaderChangeBc;
363
365
  private _sync;
364
366
  private _async;
@@ -390,7 +392,31 @@ declare class VFSFileSystem {
390
392
  private connectToLeader;
391
393
  /** Register the VFS service worker and return the active SW */
392
394
  private getServiceWorker;
393
- /** Register as leader with SW broker (receives follower ports via control channel) */
395
+ /** Register as leader with SW broker (receives follower ports via control channel).
396
+ *
397
+ * Re-registers on a heartbeat so the broker survives SW idle-kill. Without this,
398
+ * a follower opening a tab after the SW has been killed (≥30s idle on Chrome)
399
+ * sees its `transfer-port` queued in the new SW's `pending` array forever:
400
+ * the prior leader's `port2` was held by the dead SW instance, the new SW
401
+ * starts with `serverPort=null`, and the leader has no way to know to
402
+ * re-register.
403
+ *
404
+ * Re-posting `register-server` is idempotent in the SW handler — it replaces
405
+ * `serverPort` and flushes `pending` — so the heartbeat alone unsticks
406
+ * followers without needing to disturb anyone else. The follower's queued
407
+ * `mc.port2` rides through the pending-flush, and because it's a
408
+ * MessageChannel, any messages the follower's sync-relay had already posted
409
+ * on `port1` are buffered on `port2` until the leader's syncWorker starts
410
+ * the received port. Standard MessageChannel semantics — no follower-side
411
+ * notification required.
412
+ *
413
+ * We deliberately do NOT broadcast `leader-changed` from the heartbeat:
414
+ * followers receiving it call `connectToLeader()`, which tears down the
415
+ * existing `leader-port` and resolves any in-flight sync FS request with
416
+ * EIO (sync-relay.worker.ts: `pendingResolve(EIO)`). Broadcasting on every
417
+ * tick would inject random EIOs into long-running ops on every connected
418
+ * follower. Broadcast only fires once, at initial registration, to wake any
419
+ * pre-existing followers (e.g. left over from a previous leader). */
394
420
  private initLeaderBroker;
395
421
  /** Promote from follower to leader (after leader tab dies and lock is acquired) */
396
422
  private promoteToLeader;
package/dist/index.js CHANGED
@@ -2606,6 +2606,8 @@ var VFSFileSystem = class {
2606
2606
  isFollower = false;
2607
2607
  holdingLeaderLock = false;
2608
2608
  brokerInitialized = false;
2609
+ brokerHeartbeatTimer = null;
2610
+ brokerControlPort = null;
2609
2611
  leaderChangeBc = null;
2610
2612
  // Bound request functions for method delegation
2611
2613
  _sync = (buf) => this.syncRequest(buf);
@@ -2878,37 +2880,78 @@ var VFSFileSystem = class {
2878
2880
  onState();
2879
2881
  });
2880
2882
  }
2881
- /** Register as leader with SW broker (receives follower ports via control channel) */
2883
+ /** Register as leader with SW broker (receives follower ports via control channel).
2884
+ *
2885
+ * Re-registers on a heartbeat so the broker survives SW idle-kill. Without this,
2886
+ * a follower opening a tab after the SW has been killed (≥30s idle on Chrome)
2887
+ * sees its `transfer-port` queued in the new SW's `pending` array forever:
2888
+ * the prior leader's `port2` was held by the dead SW instance, the new SW
2889
+ * starts with `serverPort=null`, and the leader has no way to know to
2890
+ * re-register.
2891
+ *
2892
+ * Re-posting `register-server` is idempotent in the SW handler — it replaces
2893
+ * `serverPort` and flushes `pending` — so the heartbeat alone unsticks
2894
+ * followers without needing to disturb anyone else. The follower's queued
2895
+ * `mc.port2` rides through the pending-flush, and because it's a
2896
+ * MessageChannel, any messages the follower's sync-relay had already posted
2897
+ * on `port1` are buffered on `port2` until the leader's syncWorker starts
2898
+ * the received port. Standard MessageChannel semantics — no follower-side
2899
+ * notification required.
2900
+ *
2901
+ * We deliberately do NOT broadcast `leader-changed` from the heartbeat:
2902
+ * followers receiving it call `connectToLeader()`, which tears down the
2903
+ * existing `leader-port` and resolves any in-flight sync FS request with
2904
+ * EIO (sync-relay.worker.ts: `pendingResolve(EIO)`). Broadcasting on every
2905
+ * tick would inject random EIOs into long-running ops on every connected
2906
+ * follower. Broadcast only fires once, at initial registration, to wake any
2907
+ * pre-existing followers (e.g. left over from a previous leader). */
2882
2908
  initLeaderBroker() {
2883
2909
  if (this.brokerInitialized) return;
2884
2910
  this.brokerInitialized = true;
2885
- this.getServiceWorker().then((sw) => {
2886
- const mc = new MessageChannel();
2887
- sw.postMessage({ type: "register-server" }, [mc.port2]);
2888
- mc.port1.onmessage = (event) => {
2889
- if (event.data.type === "client-port") {
2890
- const clientPort = event.ports[0];
2891
- if (clientPort) {
2892
- this.syncWorker.postMessage(
2893
- { type: "client-port", tabId: event.data.tabId, port: clientPort },
2894
- [clientPort]
2895
- );
2911
+ const register = () => {
2912
+ this.getServiceWorker().then((sw) => {
2913
+ const mc = new MessageChannel();
2914
+ sw.postMessage({ type: "register-server" }, [mc.port2]);
2915
+ mc.port1.onmessage = (event) => {
2916
+ if (event.data.type === "client-port") {
2917
+ const clientPort = event.ports[0];
2918
+ if (clientPort) {
2919
+ this.syncWorker.postMessage(
2920
+ { type: "client-port", tabId: event.data.tabId, port: clientPort },
2921
+ [clientPort]
2922
+ );
2923
+ }
2896
2924
  }
2897
- }
2898
- };
2899
- mc.port1.start();
2900
- const bc = new BroadcastChannel(`${this.ns}-leader-change`);
2901
- bc.postMessage({ type: "leader-changed" });
2902
- bc.close();
2903
- }).catch((err) => {
2904
- console.warn("[VFS] SW broker unavailable, single-tab only:", err.message);
2905
- });
2925
+ };
2926
+ mc.port1.start();
2927
+ this.brokerControlPort = mc.port1;
2928
+ }).catch((err) => {
2929
+ console.warn("[VFS] SW broker unavailable, single-tab only:", err.message);
2930
+ });
2931
+ };
2932
+ register();
2933
+ const bc = new BroadcastChannel(`${this.ns}-leader-change`);
2934
+ bc.postMessage({ type: "leader-changed" });
2935
+ bc.close();
2936
+ if (this.brokerHeartbeatTimer) clearInterval(this.brokerHeartbeatTimer);
2937
+ this.brokerHeartbeatTimer = setInterval(register, 5e3);
2906
2938
  }
2907
2939
  /** Promote from follower to leader (after leader tab dies and lock is acquired) */
2908
2940
  promoteToLeader() {
2909
2941
  this.isFollower = false;
2910
2942
  this.isReady = false;
2911
2943
  this.brokerInitialized = false;
2944
+ if (this.brokerHeartbeatTimer) {
2945
+ clearInterval(this.brokerHeartbeatTimer);
2946
+ this.brokerHeartbeatTimer = null;
2947
+ }
2948
+ if (this.brokerControlPort) {
2949
+ try {
2950
+ this.brokerControlPort.close();
2951
+ } catch {
2952
+ }
2953
+ this.brokerControlPort = null;
2954
+ }
2912
2955
  if (this.leaderChangeBc) {
2913
2956
  this.leaderChangeBc.close();
2914
2957
  this.leaderChangeBc = null;