@inkandswitch/patchwork-bootloader 0.4.2 → 0.4.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,54 @@
1
1
  # @inkandswitch/patchwork-bootloader
2
2
 
3
+ ## 0.4.3
4
+
5
+ ### Patch Changes
6
+
7
+ - bd9cd3d: Reliability and boot-speed fixes:
8
+
9
+ - The service worker no longer blocks responses on cache writes (they move to
10
+ `waitUntil`), page caching writes its three entries in parallel, non-GET
11
+ requests bypass the worker entirely, cache-write failures (e.g. quota) are
12
+ always logged, the cache is capped at 2000 entries with oldest-first
13
+ trimming, and boot requests persistent storage so cache growth can't trip
14
+ origin-wide eviction of user data.
15
+ - `automerge.wasm` is fetched under one URL from both the tab and the
16
+ automerge worker (the `?main`/`?worker` tracing query strings defeated the
17
+ HTTP cache, the SW cache, and the sites' preload — the ~3MB body downloaded
18
+ twice).
19
+ - Tabs now recover when the automerge SharedWorker dies or its connection is
20
+ stranded. Previously death was only logged and tabs silently stopped syncing
21
+ until reload. Silence alone never tears anything down (a slow-booting or
22
+ busy worker delivers everything queued once it catches up): a silent port
23
+ first starts a non-destructive probe — a second connection to the same
24
+ instance — and only when the probe gets a `hello` while the original port
25
+ stays silent (proving a live instance with a stranded port) is the worker
26
+ handle recreated, with sync-state subscriptions replayed and every
27
+ subscriber's repo re-wired onto a fresh port. This rescues boots whose
28
+ initial SharedWorker port comes up deaf (~6s), including in hidden
29
+ background tabs; a port `close` event still recovers immediately.
30
+ - The worker no longer rescans every doc handle on every
31
+ `subduction-remote-heads` event (quadratic during sync bursts); it tracks
32
+ just the reported doc.
33
+ - `ModuleWatcher` announces are generation-tracked, so a stale retry of an
34
+ older module version can no longer land after a newer version and roll the
35
+ registry back.
36
+ - `resolveAccountHandle` never overwrites a valid stored account pointer when
37
+ `repo.find` fails: it retries briefly and then throws, instead of silently
38
+ creating a fresh account and orphaning the user's workspace.
39
+ - `OverlayRepo` no longer memoizes rejected resolutions: a `find` that failed
40
+ because the doc (or its keyhive access) hadn't synced yet used to pin every
41
+ later `find` of that url to the same cached rejection, so the views' "retry
42
+ once access syncs" recovery could never reach the base repo. Rejections now
43
+ evict and the next `find` re-resolves. `findWithProgress().subscribe` also
44
+ no longer leaks its inner subscription (or fires the callback) when
45
+ unsubscribed before the resolution settles.
46
+
47
+ - Updated dependencies [bd9cd3d]
48
+ - @inkandswitch/patchwork-filesystem@0.2.1
49
+ - @inkandswitch/patchwork-plugins@1.0.1
50
+ - @inkandswitch/patchwork-providers@0.4.1
51
+
3
52
  ## 0.4.2
4
53
 
5
54
  ### Patch Changes
@@ -172,11 +172,9 @@ setInterval(() => {
172
172
  const gap = now - watchdogLast;
173
173
  watchdogLast = now;
174
174
  if (gap > WATCHDOG_TICK_MS * WATCHDOG_GAP_FACTOR) {
175
- console.warn(`[lifecycle] worker resumed after ~${Math.round(gap / 1000)}s gap ` +
176
- `(timer expected every ${WATCHDOG_TICK_MS / 1000}s) likely ` +
177
- `suspended/frozen/throttled; WebSocket keepalive pongs were not sent ` +
178
- `during this window, so the sync server may have reaped us. at ` +
179
- `${new Date(now).toISOString()}`);
175
+ console.warn(`[lifecycle] ${new Date(now).toISOString()} watchdog timer gap ` +
176
+ `~${Math.round(gap / 1000)}s (expected every ` +
177
+ `${WATCHDOG_TICK_MS / 1000}s)`);
180
178
  }
181
179
  }, WATCHDOG_TICK_MS);
182
180
  // Sync server selection. Sub is the default. Build with KEYHIVE_SYNC_SERVER=true
@@ -225,9 +223,7 @@ function getSubductionEndpoints() {
225
223
  : [
226
224
  new WorkerWebSocketEndpoint(SUBDUCTION_SYNC_URL, {
227
225
  worker: subductionPortProvider.source,
228
- ...(WS_WINDOW_FRAMES
229
- ? { windowFrames: WS_WINDOW_FRAMES }
230
- : {}),
226
+ ...(WS_WINDOW_FRAMES ? { windowFrames: WS_WINDOW_FRAMES } : {}),
231
227
  }),
232
228
  ];
233
229
  }
@@ -273,7 +269,7 @@ async function connectClassicSyncNetwork(server) {
273
269
  throw err;
274
270
  }
275
271
  }
276
- const siteName = typeof __SITE_NAME__ !== "undefined" ? __SITE_NAME__ : "tiny-patchwork";
272
+ const siteName = typeof __SITE_NAME__ !== "undefined" ? __SITE_NAME__ : "patchwork.inkandswitch.com";
277
273
  const cacheableStatuses = [200, 203, 204];
278
274
  function log(...args) {
279
275
  if (!debugging)
@@ -288,7 +284,7 @@ function getRepoHive() {
288
284
  log("getRepo: starting");
289
285
  log("fetching wasm modules");
290
286
  const [amWasmBuf, sdnWasmBuf] = await Promise.all([
291
- fetch("/automerge.wasm?worker").then((r) => r.arrayBuffer()),
287
+ fetch("/automerge.wasm").then((r) => r.arrayBuffer()),
292
288
  fetch("/subduction.wasm").then((r) => r.arrayBuffer()),
293
289
  ]);
294
290
  initSubductionSync(new Uint8Array(sdnWasmBuf));
@@ -612,8 +608,13 @@ function setupSyncStateBroadcast(repo, identity) {
612
608
  byStorage.set(storageId, { heads: headsCopy, timestamp });
613
609
  postHeads(documentId, storageId, headsCopy, timestamp);
614
610
  // A doc the server reported is one we hold — make sure we're advertising
615
- // our own heads for it too.
616
- scanOwnHandles();
611
+ // our own heads for it too. Track just this doc: a full scanOwnHandles()
612
+ // per event is O(all handles) and goes quadratic during sync bursts,
613
+ // starving the thread that's doing the syncing. The 3s tick still covers
614
+ // general discovery.
615
+ const handle = repo.handles[documentId];
616
+ if (handle)
617
+ trackOwnHandle(handle);
617
618
  reviewResync(documentId);
618
619
  });
619
620
  repo.on("subduction-connection", ({ connected: isConnected }) => {
@@ -717,6 +718,16 @@ async function connectPort(port, connection) {
717
718
  }
718
719
  function handleControlMessage(event, controlPort, connection) {
719
720
  const data = event.data;
721
+ // Tally of control messages received, readable from the SharedWorker console
722
+ // as `self.patchworkControl`. Not using log(): that's gated on `debugging`,
723
+ // which is only enabled by a {type:"debug"} message arriving over this same
724
+ // channel.
725
+ const stats = (self.patchworkControl ??= {
726
+ connects: 0,
727
+ byType: {},
728
+ });
729
+ stats.byType[String(data?.type ?? "<untyped>")] =
730
+ (stats.byType[String(data?.type ?? "<untyped>")] ?? 0) + 1;
720
731
  if (data?.type === "port") {
721
732
  log("received repo channel");
722
733
  const [repoPort] = event.ports;
@@ -777,6 +788,7 @@ function handleControlMessage(event, controlPort, connection) {
777
788
  self.addEventListener("connect", (event) => {
778
789
  const controlPort = event.ports[0];
779
790
  const connection = { channels: new Set() };
791
+ (self.patchworkControl ??= { connects: 0, byType: {} }).connects++;
780
792
  controlPort.addEventListener("message", (messageEvent) => {
781
793
  handleControlMessage(messageEvent, controlPort, connection);
782
794
  });
@@ -848,6 +860,13 @@ function waitForHeads(handle, hexHeads, signal) {
848
860
  check();
849
861
  });
850
862
  }
863
+ /**
864
+ * Thrown instead of returning a Response when the request should fail as a
865
+ * network error rather than resolve to something the caller can memoize.
866
+ * See {@link HandoffAbortMessage}.
867
+ */
868
+ class AbortHandoff extends Error {
869
+ }
851
870
  async function resolveAutomergeUrl(automergeURL) {
852
871
  const { repo } = await getRepoHive();
853
872
  const href = automergeURL.href;
@@ -878,7 +897,10 @@ async function resolveAutomergeUrl(automergeURL) {
878
897
  // The heads may not have synced to us yet — give them the rest of the
879
898
  // resolve window to arrive before giving up.
880
899
  if (!(await waitForHeads(baseHandle, hexHeads ?? [], signal))) {
881
- return new Response("heads not found", { status: 404 });
900
+ // Not a 404: the heads may still be on their way, and this exact URL will
901
+ // be requested again once they land. Fail it as a network error so the
902
+ // caller doesn't memoize the miss.
903
+ throw new AbortHandoff(`heads not found for ${maybeAutomergeUrl} within ${RESOLVE_TIMEOUT_MS}ms`);
882
904
  }
883
905
  const rootHandle = baseHandle.view(heads);
884
906
  const resolved = await resolvePath(repo, rootHandle, path.map(decodeURIComponent));
@@ -949,6 +971,14 @@ async function handleHandoffRequest(message) {
949
971
  ]);
950
972
  }
951
973
  catch (error) {
974
+ if (error instanceof AbortHandoff) {
975
+ handoffChannel.postMessage({
976
+ id,
977
+ type: "abort",
978
+ reason: error.message,
979
+ });
980
+ return;
981
+ }
952
982
  const body = error instanceof Error
953
983
  ? `${error.message}\n\n${error.stack}`
954
984
  : String(error);
@@ -135,6 +135,9 @@ handoffChannel.addEventListener("message", (event) => {
135
135
  }
136
136
  }
137
137
  });
138
+ /** Signals that respondWith should reject; see {@link HandoffAbortMessage}. */
139
+ class HandoffAborted extends Error {
140
+ }
138
141
  function handoff(request, handoffURL) {
139
142
  const id = crypto.randomUUID();
140
143
  const resolvers = Promise.withResolvers();
@@ -190,19 +193,34 @@ function rootRequestFor(request) {
190
193
  }
191
194
  async function cachePage(cache, request, response) {
192
195
  const indexRequest = indexRequestFor(request);
193
- if (indexRequest)
194
- await cache.put(indexRequest, response.clone());
195
196
  const rootRequest = rootRequestFor(request);
196
- if (rootRequest)
197
- await cache.put(rootRequest, response.clone());
198
- await cache.put(request, response);
197
+ await Promise.all([
198
+ indexRequest && cache.put(indexRequest, response.clone()),
199
+ rootRequest && cache.put(rootRequest, response.clone()),
200
+ cache.put(request, response),
201
+ ]);
202
+ }
203
+ // Write to the cache without blocking the response: cache.put only resolves
204
+ // once the whole body has been consumed and persisted, so awaiting it before
205
+ // returning would turn time-to-first-byte into time-to-last-byte-plus-disk
206
+ // for every proxied asset. waitUntil keeps the worker alive for the write.
207
+ function cacheInBackground(fetchEvent, cache, request, response) {
208
+ fetchEvent.waitUntil((request.mode === "navigate" || request.destination === "document"
209
+ ? cachePage(cache, request, response)
210
+ : cache.put(request, response)).catch((error) => {
211
+ // Always loud (not gated on debugging): a QuotaExceededError here is
212
+ // the first sign the origin is under storage pressure.
213
+ console.warn(`error caching ${request.url} in ${cachename}`, error);
214
+ }));
199
215
  }
200
216
  // ── Fetch handler ──────────────────────────────────────────────────────
201
217
  self.addEventListener("fetch", (fetchEvent) => {
202
218
  log("fetch event", fetchEvent.request.url);
203
219
  const request = fetchEvent.request;
220
+ // Not calling respondWith at all lets the browser handle non-GETs natively
221
+ // instead of proxying their bodies through this worker.
204
222
  if (request.method !== "GET")
205
- return fetchEvent.respondWith(fetch(request));
223
+ return;
206
224
  const url = new URL(fetchEvent.request.url);
207
225
  let handoffURL;
208
226
  if (url.hostname == self.location.hostname &&
@@ -227,6 +245,13 @@ self.addEventListener("fetch", (fetchEvent) => {
227
245
  const replyPromise = handoff(request, handoffURL);
228
246
  fetchEvent.waitUntil(replyPromise.catch(() => { }));
229
247
  const reply = await replyPromise;
248
+ if (reply.type === "abort") {
249
+ // Rejecting respondWith gives the caller a network error rather
250
+ // than a response it can memoize. Rethrown past the catch below,
251
+ // which would otherwise turn this into a 556.
252
+ log(`aborting ${handoffURL}: ${reply.reason}`);
253
+ throw new HandoffAborted(reply.reason);
254
+ }
230
255
  if (reply.type === "response") {
231
256
  // errors, redirects and other things that shouldn't be cached
232
257
  log(`serving handed-off response for ${handoffURL}`, reply);
@@ -258,13 +283,7 @@ self.addEventListener("fetch", (fetchEvent) => {
258
283
  if ((response.status === 0 ||
259
284
  cacheableStatuses.includes(response.status)) &&
260
285
  /^https?:/.test(request.url)) {
261
- const cachedResponse = response.clone();
262
- await (request.mode === "navigate" ||
263
- request.destination === "document"
264
- ? cachePage(cache, request, cachedResponse)
265
- : cache.put(request, cachedResponse)).catch((error) => {
266
- log(`error caching ${request.url} in ${cachename}`, error);
267
- });
286
+ cacheInBackground(fetchEvent, cache, request, response.clone());
268
287
  }
269
288
  else {
270
289
  log(`skipping uncacheable response code from cache: ${response.status} for ${request.url}`);
@@ -277,6 +296,9 @@ self.addEventListener("fetch", (fetchEvent) => {
277
296
  }
278
297
  }
279
298
  catch (error) {
299
+ // Deliberate: fail the request as a network error, no response.
300
+ if (error instanceof HandoffAborted)
301
+ throw error;
280
302
  const message = error instanceof Error
281
303
  ? `${error.message}\n\n${error.stack}`
282
304
  : String(error);
package/dist/setup.js CHANGED
@@ -70,12 +70,82 @@ function configureServiceWorker(sw) {
70
70
  }
71
71
  // ── The automerge worker ───────────────────────────────────────────────
72
72
  // The automerge repo lives in a SharedWorker (not the service worker). One
73
- // instance is shared by every tab and lives exactly as long as any tab
74
- // does, so there's no keepalive ping and no restart detection: if we're
75
- // alive, it's alive. Repo sync ports are passed to it over its connect
76
- // port; it talks to the service worker over a BroadcastChannel.
73
+ // instance is shared by every tab and normally lives as long as any tab
74
+ // does but browsers do reap SharedWorkers under memory pressure, so we
75
+ // heartbeat it and rebuild everything if it dies (see
76
+ // recoverAutomergeWorker). Repo sync ports are passed to it over its
77
+ // connect port; it talks to the service worker over a BroadcastChannel.
77
78
  let automergeWorkerPath = "/automerge-worker.js";
78
79
  let automergeWorker;
80
+ // Bumped whenever a new SharedWorker is constructed. A repo port opened
81
+ // against instance N is stale once instance N+1 exists (its channel ends in
82
+ // a dead worker), so deliveries are guarded on the generation they started in.
83
+ let workerGeneration = 0;
84
+ // Tears down the current worker's heartbeat when it's replaced.
85
+ let disposeWorkerDeathDetection;
86
+ // Every subscribeToRepoChannel listener, kept so a recovered worker can hand
87
+ // each subscriber a fresh repo port.
88
+ const repoChannelListeners = new Set();
89
+ let recoveringWorker = false;
90
+ let lastWorkerRecoveryAt = 0;
91
+ // Below this spacing, skip: if the fresh worker is dead too, its own
92
+ // heartbeat re-triggers recovery later rather than spinning in a tight loop.
93
+ const RECOVERY_MIN_INTERVAL_MS = 15_000;
94
+ /**
95
+ * The automerge SharedWorker died (browser reaped it, or it crashed): build a
96
+ * replacement and re-wire everything a live tab holds against it — console
97
+ * forwarding and the io-proxy port donation (both re-done by
98
+ * getAutomergeWorker), the per-doc sync-state subscriptions, and every
99
+ * subscriber's repo sync port. The new instance boots with cold state; its
100
+ * repo is reconstructed on the first port we send.
101
+ */
102
+ async function recoverAutomergeWorker(reason, deadWorker) {
103
+ if (deadWorker !== automergeWorker)
104
+ return; // already replaced
105
+ if (recoveringWorker)
106
+ return;
107
+ const now = Date.now();
108
+ if (now - lastWorkerRecoveryAt < RECOVERY_MIN_INTERVAL_MS)
109
+ return;
110
+ recoveringWorker = true;
111
+ lastWorkerRecoveryAt = now;
112
+ console.warn(`[lifecycle] ${new Date().toISOString()} recreating the automerge ` +
113
+ `SharedWorker (${reason})`);
114
+ try {
115
+ disposeWorkerDeathDetection?.();
116
+ disposeWorkerDeathDetection = undefined;
117
+ automergeWorker = undefined;
118
+ try {
119
+ deadWorker.port.close();
120
+ }
121
+ catch {
122
+ // Port already dead.
123
+ }
124
+ const fresh = getAutomergeWorker();
125
+ // The fresh instance knows nothing — replay every doc subscription.
126
+ for (const documentId of syncStateListeners.keys()) {
127
+ fresh.port.postMessage({ type: "sync-sub", documentId });
128
+ }
129
+ // Hand every repo-channel subscriber a fresh port so their repos sync
130
+ // again (the old adapters sit on dead MessagePorts).
131
+ for (const listener of repoChannelListeners) {
132
+ try {
133
+ const generation = workerGeneration;
134
+ const port = await openRepoChannel();
135
+ // Replaced again while we waited — the newer recovery re-delivers.
136
+ if (generation !== workerGeneration)
137
+ break;
138
+ await listener(port);
139
+ }
140
+ catch (err) {
141
+ console.error("failed to re-wire a repo channel after worker recovery", err);
142
+ }
143
+ }
144
+ }
145
+ finally {
146
+ recoveringWorker = false;
147
+ }
148
+ }
79
149
  // SharedWorker proxy entry that owns the subduction WebSocket. Chrome can't
80
150
  // spawn workers from inside a SharedWorker, so each tab offers this proxy's
81
151
  // port to the automerge worker (which requests one via its port provider).
@@ -107,13 +177,23 @@ function workerBenchParams() {
107
177
  const qs = params.toString();
108
178
  return qs ? `?${qs}` : "";
109
179
  }
180
+ function automergeWorkerUrl() {
181
+ return `${automergeWorkerPath}${workerBenchParams()}`;
182
+ }
110
183
  export function getAutomergeWorker() {
111
184
  if (!automergeWorker) {
112
- const workerUrl = `${automergeWorkerPath}${workerBenchParams()}`;
113
- automergeWorker = new SharedWorker(workerUrl, {
185
+ workerGeneration++;
186
+ automergeWorker = new SharedWorker(automergeWorkerUrl(), {
114
187
  name: "patchwork-automerge",
115
188
  type: "module",
116
189
  });
190
+ // Fired when a message arrives that can't be structured-deserialized —
191
+ // e.g. a transfer list that named something unclonable. Silent otherwise:
192
+ // the message is simply dropped, which looks identical to a worker that
193
+ // never replied. Always loud, not gated on the lifecycle toggle.
194
+ automergeWorker.port.addEventListener("messageerror", (event) => {
195
+ console.error("[automerge-worker] undeserializable message from worker:", event);
196
+ });
117
197
  // Control replies (port-ready &c) come back on this port, so it needs
118
198
  // start() — we listen with addEventListener, not onmessage.
119
199
  automergeWorker.port.start();
@@ -169,16 +249,37 @@ export function getAutomergeWorker() {
169
249
  type: "module",
170
250
  name: "subduction-websocket",
171
251
  });
252
+ // This worker carries the websocket to the sync server, so if it fails
253
+ // to load, sync silently stops with no other symptom.
254
+ io.addEventListener("error", (event) => {
255
+ const error = event;
256
+ console.error(`[subduction-io] failed to load/run ${SUBDUCTION_IO_WORKER_URL}:`, error.message || event, error.filename ? `(${error.filename}:${error.lineno})` : "");
257
+ });
258
+ io.port.addEventListener("messageerror", (event) => {
259
+ console.error("[subduction-io] undeserializable message:", event);
260
+ });
172
261
  return io.port;
173
262
  });
174
- installWorkerDeathDetection(automergeWorker);
263
+ disposeWorkerDeathDetection = installWorkerDeathDetection(automergeWorker);
175
264
  }
176
265
  return automergeWorker;
177
266
  }
178
267
  /**
179
- * Detect when the automerge SharedWorker dies or restarts: control-port close,
180
- * worker error, changed instance id, or an unanswered heartbeat while the tab
181
- * is visible (a miss while hidden is more likely suspension). [lifecycle]-tagged.
268
+ * Detect when the automerge SharedWorker dies or its control port goes deaf.
269
+ *
270
+ * Silence alone is NOT proof of death: the worker may still be evaluating its
271
+ * (large) module graph on a cold boot, or its single thread may be busy with
272
+ * wasm/sync work — in both cases every queued message (including the repo
273
+ * ports the network adapters ride on) is delivered fine once it catches up,
274
+ * and tearing the port down would *lose* them. So silence only starts a
275
+ * non-destructive PROBE: a second SharedWorker connection to the same
276
+ * instance. Only when the probe gets a `hello` while this port stays silent do
277
+ * we know the instance is alive-and-responsive but our port is stranded (a
278
+ * failure mode observed in the wild) — or was replaced — and recovery is
279
+ * warranted. A `close` event (where supported) is a definitive death signal
280
+ * and recovers immediately. [lifecycle]-tagged. Returns a dispose that stops
281
+ * the heartbeat and any outstanding probe (called when this worker is
282
+ * replaced).
182
283
  */
183
284
  function installWorkerDeathDetection(worker) {
184
285
  const stamp = () => new Date().toISOString();
@@ -191,53 +292,135 @@ function installWorkerDeathDetection(worker) {
191
292
  console.info(`[lifecycle] ${stamp()} ${msg}`);
192
293
  };
193
294
  let instanceId;
194
- let lastPongAt = Date.now();
295
+ let lastHeardAt = Date.now();
195
296
  let warnedUnresponsive = false;
297
+ let disposed = false;
298
+ let probe;
299
+ const closeProbe = () => {
300
+ if (!probe)
301
+ return;
302
+ try {
303
+ probe.port.close();
304
+ }
305
+ catch {
306
+ // Already closed.
307
+ }
308
+ probe = undefined;
309
+ };
310
+ let warnedSendFailed = false;
311
+ let pingsSent = 0;
196
312
  worker.port.addEventListener("message", (event) => {
197
313
  const data = event.data;
198
314
  if (data?.type !== "hello" && data?.type !== "pong")
199
315
  return;
200
- if (data.type === "pong") {
201
- lastPongAt = Date.now();
202
- warnedUnresponsive = false;
203
- }
316
+ lastHeardAt = Date.now();
317
+ warnedUnresponsive = false;
318
+ // The port spoke — any outstanding probe is moot.
319
+ closeProbe();
204
320
  if (instanceId === undefined) {
205
321
  instanceId = data.instanceId;
206
322
  info(`automerge SharedWorker instance ${data.instanceId} (via ${data.type})`);
207
323
  }
208
324
  else if (data.instanceId && data.instanceId !== instanceId) {
209
- warn(`automerge SharedWorker RESTARTED (instance ${data.instanceId}, ` +
210
- `was ${instanceId}) — fresh peerId + cold state; docs need re-subscribe`);
325
+ warn(`automerge SharedWorker instance changed (instance ${data.instanceId}, ` +
326
+ `was ${instanceId})`);
211
327
  instanceId = data.instanceId;
212
328
  }
213
329
  });
214
330
  // Fires when the SharedWorker is destroyed (where supported).
215
331
  worker.port.addEventListener("close", () => {
216
- warn("automerge SharedWorker control port CLOSED — worker terminated");
332
+ if (disposed)
333
+ return;
334
+ warn("automerge SharedWorker control port closed");
335
+ void recoverAutomergeWorker("control port closed", worker);
217
336
  });
337
+ // Not gated on the lifecycle toggle: a worker that fails to load never
338
+ // replies to anything, and this is the only signal that says so.
218
339
  worker.addEventListener("error", event => {
219
- warn(`automerge SharedWorker error: ${event.message || event}`);
340
+ const error = event;
341
+ console.error(`[lifecycle] ${stamp()} automerge SharedWorker error:`, error.message || event, error.filename ? `(${error.filename}:${error.lineno})` : "");
220
342
  });
221
- // A missed pong while the tab is visible means the worker likely died (an
222
- // active tab keeps it alive); a miss while hidden is more likely suspension.
223
- const HEARTBEAT_MS = 10_000;
343
+ // On probe hello, give the suspect port this long to also speak before
344
+ // concluding it's stranded: after a slow worker boot both connections hello
345
+ // at roughly the same moment and cross-port delivery order isn't guaranteed.
346
+ const PROBE_GRACE_MS = 500;
347
+ const startProbe = (reason) => {
348
+ if (probe || disposed)
349
+ return;
350
+ warn(`automerge SharedWorker ${reason}; probing with a second connection`);
351
+ const startedAt = Date.now();
352
+ const p = new SharedWorker(automergeWorkerUrl(), {
353
+ name: "patchwork-automerge",
354
+ type: "module",
355
+ });
356
+ probe = p;
357
+ p.port.start();
358
+ p.port.addEventListener("message", (event) => {
359
+ if (event.data?.type !== "hello")
360
+ return;
361
+ setTimeout(() => {
362
+ if (disposed || probe !== p)
363
+ return; // superseded or torn down
364
+ closeProbe();
365
+ // The suspect spoke while (or just after) the probe ran: it was
366
+ // merely slow/busy, and everything queued on it has been delivered.
367
+ if (lastHeardAt >= startedAt)
368
+ return;
369
+ void recoverAutomergeWorker(`port unresponsive on a live worker (${reason}; probe confirmed)`, worker);
370
+ }, PROBE_GRACE_MS);
371
+ });
372
+ // No hello on the probe means the instance is loading or busy (the probe
373
+ // waits indefinitely — its hello triggers the check above whenever it
374
+ // lands) — never tear anything down on a timer.
375
+ };
376
+ // A silent-too-long port while the tab is visible starts a probe (a miss
377
+ // while hidden is more likely suspension). Before first contact the budget
378
+ // is tighter: an idle worker hellos within milliseconds of connecting, so
379
+ // probing early costs nothing and rescues genuinely stranded boots fast.
380
+ const HEARTBEAT_MS = 5_000;
224
381
  const HEARTBEAT_TIMEOUT_MS = 25_000;
382
+ const FIRST_CONTACT_TIMEOUT_MS = 4_000;
225
383
  let seq = 0;
226
- setInterval(() => {
384
+ const heartbeat = setInterval(() => {
227
385
  try {
228
386
  worker.port.postMessage({ type: "ping", id: ++seq });
387
+ pingsSent++;
229
388
  }
230
- catch {
231
- // Port already torn down the "close" handler covers that case.
389
+ catch (error) {
390
+ // Swallowing this makes a failed send indistinguishable from a dead
391
+ // worker in the "no pong" warning below. Once, not every heartbeat.
392
+ if (!warnedSendFailed) {
393
+ warnedSendFailed = true;
394
+ console.error(`[lifecycle] ${stamp()} automerge SharedWorker ping send threw ` +
395
+ `after ${pingsSent} sent:`, error);
396
+ }
232
397
  }
233
- const silentMs = Date.now() - lastPongAt;
398
+ const neverHeard = instanceId === undefined;
399
+ const silentMs = Date.now() - lastHeardAt;
400
+ const timeoutMs = neverHeard
401
+ ? FIRST_CONTACT_TIMEOUT_MS
402
+ : HEARTBEAT_TIMEOUT_MS;
234
403
  const visible = typeof document === "undefined" || document.visibilityState === "visible";
235
- if (silentMs > HEARTBEAT_TIMEOUT_MS && visible && !warnedUnresponsive) {
236
- warnedUnresponsive = true;
237
- warn(`automerge SharedWorker UNRESPONSIVE ~${Math.round(silentMs / 1000)}s ` +
238
- `while tab visible likely died/crashed`);
404
+ // First contact probes regardless of visibility: SharedWorkers don't
405
+ // suspend with tab visibility, boots in background tabs must still get
406
+ // rescued, and the probe destroys nothing. Post-contact silence defers to
407
+ // visibility, since a hidden page's own throttling can fake it.
408
+ if (silentMs > timeoutMs && (neverHeard || visible)) {
409
+ const reason = neverHeard
410
+ ? `no hello ~${Math.round(silentMs / 1000)}s after connecting`
411
+ : `no pong for ~${Math.round(silentMs / 1000)}s`;
412
+ if (!warnedUnresponsive) {
413
+ warnedUnresponsive = true;
414
+ warn(`automerge SharedWorker ${reason} (tab visible)`);
415
+ }
416
+ startProbe(reason);
239
417
  }
240
418
  }, HEARTBEAT_MS);
419
+ return () => {
420
+ disposed = true;
421
+ clearInterval(heartbeat);
422
+ closeProbe();
423
+ };
241
424
  }
242
425
  const syncStateListeners = new Map();
243
426
  function dispatchSyncState(update) {
@@ -273,7 +456,10 @@ export function subscribeSyncState(documentId, listener) {
273
456
  set.delete(listener);
274
457
  if (set.size === 0) {
275
458
  syncStateListeners.delete(documentId);
276
- worker.port.postMessage({ type: "sync-unsub", documentId });
459
+ // The worker may have been replaced since we subscribed (recovery
460
+ // replays subscriptions onto the new instance) — unsubscribe from
461
+ // whichever instance is current, not the one captured above.
462
+ automergeWorker?.port.postMessage({ type: "sync-unsub", documentId });
277
463
  }
278
464
  };
279
465
  }
@@ -375,6 +561,11 @@ export default async function setupServiceWorker(options) {
375
561
  // install / activate markers from the controlling worker are rendered here.
376
562
  installServiceWorkerLogForwarding();
377
563
  localStorage.removeItem(key);
564
+ // Ask for persistent storage so cache growth can't trip origin-wide
565
+ // eviction, which would take the Automerge IndexedDB — the user's documents
566
+ // — with it. Chrome/Safari decide silently from site engagement; Firefox may
567
+ // show a one-time prompt. Best-effort: denial just means default eviction.
568
+ void navigator.storage?.persist?.().catch(() => { });
378
569
  if (options?.workerPath)
379
570
  automergeWorkerPath = options.workerPath;
380
571
  // Start the automerge worker right away so it boots (wasm, repo) while the
@@ -417,10 +608,22 @@ export default async function setupServiceWorker(options) {
417
608
  getRepoChannel,
418
609
  subscribeSyncState,
419
610
  async subscribeToRepoChannel(listener) {
420
- // The automerge worker outlives the page, so unlike the old in-service-
421
- // worker repo there's nothing to reconnect: one port, handed over once.
422
- await listener(await openRepoChannel());
423
- return () => { };
611
+ // Called once with the boot port. If the automerge worker later dies
612
+ // and is recreated (recoverAutomergeWorker), the listener is called
613
+ // again with a fresh port — treat every call as "(re)wire your repo's
614
+ // sync onto this port".
615
+ repoChannelListeners.add(listener);
616
+ const generation = workerGeneration;
617
+ const port = await openRepoChannel();
618
+ // If the worker was replaced while this channel was opening (e.g. the
619
+ // port-ready wait timed out against a stranded connection and recovery
620
+ // already delivered a good port to this listener), drop the stale one
621
+ // rather than wiring the repo to a dead channel.
622
+ if (generation === workerGeneration)
623
+ await listener(port);
624
+ return () => {
625
+ repoChannelListeners.delete(listener);
626
+ };
424
627
  },
425
628
  };
426
629
  }