@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 +49 -0
- package/dist/automerge-worker.js +43 -13
- package/dist/service-worker.js +35 -13
- package/dist/setup.js +238 -35
- package/dist/site.js +66 -12
- package/dist/types.d.ts +18 -1
- package/dist/vite/importmap-plugin.js +33 -3
- package/package.json +29 -10
- package/src/automerge-worker.ts +48 -16
- package/src/service-worker.ts +44 -13
- package/src/setup.ts +256 -39
- package/src/site.ts +79 -17
- package/src/types.ts +22 -1
- package/src/vite/importmap-plugin.ts +40 -3
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
|
package/dist/automerge-worker.js
CHANGED
|
@@ -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]
|
|
176
|
-
|
|
177
|
-
|
|
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__ : "
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/dist/service-worker.js
CHANGED
|
@@ -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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
74
|
-
// does
|
|
75
|
-
//
|
|
76
|
-
//
|
|
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
|
-
|
|
113
|
-
automergeWorker = new SharedWorker(
|
|
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
|
|
180
|
-
*
|
|
181
|
-
* is
|
|
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
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
|
210
|
-
`was ${instanceId})
|
|
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
|
-
|
|
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
|
-
|
|
340
|
+
const error = event;
|
|
341
|
+
console.error(`[lifecycle] ${stamp()} automerge SharedWorker error:`, error.message || event, error.filename ? `(${error.filename}:${error.lineno})` : "");
|
|
220
342
|
});
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
|
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
|
-
//
|
|
421
|
-
//
|
|
422
|
-
|
|
423
|
-
|
|
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
|
}
|