on-zero 0.16.19 → 0.16.20
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/cjs/createUseQuery.cjs +2 -1
- package/dist/cjs/createUseQuery.native.js +2 -1
- package/dist/cjs/createUseQuery.native.js.map +1 -1
- package/dist/cjs/createUseQueryDirect.cjs +2 -1
- package/dist/cjs/createUseQueryDirect.native.js +2 -1
- package/dist/cjs/createUseQueryDirect.native.js.map +1 -1
- package/dist/cjs/createZeroClient.cjs +75 -9
- package/dist/cjs/createZeroClient.native.js +151 -81
- package/dist/cjs/createZeroClient.native.js.map +1 -1
- package/dist/cjs/createZeroClient.recovery.test.cjs +351 -10
- package/dist/cjs/createZeroClient.recovery.test.native.js +412 -12
- package/dist/cjs/createZeroClient.recovery.test.native.js.map +1 -1
- package/dist/cjs/generate.cjs +16 -11
- package/dist/cjs/generate.native.js +16 -11
- package/dist/cjs/generate.native.js.map +1 -1
- package/dist/cjs/generate.test.cjs +22 -0
- package/dist/cjs/generate.test.native.js +22 -0
- package/dist/cjs/generate.test.native.js.map +1 -1
- package/dist/cjs/helpers/recoverZeroClient.cjs +542 -52
- package/dist/cjs/helpers/recoverZeroClient.native.js +721 -59
- package/dist/cjs/helpers/recoverZeroClient.native.js.map +1 -1
- package/dist/cjs/helpers/recoverZeroClient.test.cjs +318 -0
- package/dist/cjs/helpers/recoverZeroClient.test.native.js +361 -0
- package/dist/cjs/helpers/recoverZeroClient.test.native.js.map +1 -1
- package/dist/cjs/kvStore.cjs +30 -0
- package/dist/cjs/kvStore.native.js +34 -0
- package/dist/cjs/kvStore.native.js.map +1 -0
- package/dist/cjs/queryJson.cjs +58 -0
- package/dist/cjs/queryJson.native.js +86 -0
- package/dist/cjs/queryJson.native.js.map +1 -0
- package/dist/cjs/resolveQuery.cjs +2 -0
- package/dist/cjs/resolveQuery.native.js +2 -0
- package/dist/cjs/resolveQuery.native.js.map +1 -1
- package/dist/cjs/useQuery.empty.test.cjs +11 -1
- package/dist/cjs/useQuery.empty.test.native.js +23 -1
- package/dist/cjs/useQuery.empty.test.native.js.map +1 -1
- package/dist/cjs/useQuery.ssr.test.cjs +22 -1
- package/dist/cjs/useQuery.ssr.test.native.js +30 -1
- package/dist/cjs/useQuery.ssr.test.native.js.map +1 -1
- package/dist/esm/createUseQuery.mjs +2 -1
- package/dist/esm/createUseQuery.mjs.map +1 -1
- package/dist/esm/createUseQuery.native.js +2 -1
- package/dist/esm/createUseQuery.native.js.map +1 -1
- package/dist/esm/createUseQueryDirect.mjs +2 -1
- package/dist/esm/createUseQueryDirect.mjs.map +1 -1
- package/dist/esm/createUseQueryDirect.native.js +2 -1
- package/dist/esm/createUseQueryDirect.native.js.map +1 -1
- package/dist/esm/createZeroClient.mjs +76 -10
- package/dist/esm/createZeroClient.mjs.map +1 -1
- package/dist/esm/createZeroClient.native.js +152 -82
- package/dist/esm/createZeroClient.native.js.map +1 -1
- package/dist/esm/createZeroClient.recovery.test.mjs +352 -11
- package/dist/esm/createZeroClient.recovery.test.mjs.map +1 -1
- package/dist/esm/createZeroClient.recovery.test.native.js +413 -13
- package/dist/esm/createZeroClient.recovery.test.native.js.map +1 -1
- package/dist/esm/generate.mjs +16 -11
- package/dist/esm/generate.mjs.map +1 -1
- package/dist/esm/generate.native.js +16 -11
- package/dist/esm/generate.native.js.map +1 -1
- package/dist/esm/generate.test.mjs +22 -0
- package/dist/esm/generate.test.mjs.map +1 -1
- package/dist/esm/generate.test.native.js +22 -0
- package/dist/esm/generate.test.native.js.map +1 -1
- package/dist/esm/helpers/recoverZeroClient.mjs +542 -54
- package/dist/esm/helpers/recoverZeroClient.mjs.map +1 -1
- package/dist/esm/helpers/recoverZeroClient.native.js +721 -61
- package/dist/esm/helpers/recoverZeroClient.native.js.map +1 -1
- package/dist/esm/helpers/recoverZeroClient.test.mjs +319 -1
- package/dist/esm/helpers/recoverZeroClient.test.mjs.map +1 -1
- package/dist/esm/helpers/recoverZeroClient.test.native.js +362 -1
- package/dist/esm/helpers/recoverZeroClient.test.native.js.map +1 -1
- package/dist/esm/kvStore.mjs +5 -0
- package/dist/esm/kvStore.mjs.map +1 -0
- package/dist/esm/kvStore.native.js +6 -0
- package/dist/esm/kvStore.native.js.map +1 -0
- package/dist/esm/queryJson.mjs +32 -0
- package/dist/esm/queryJson.mjs.map +1 -0
- package/dist/esm/queryJson.native.js +57 -0
- package/dist/esm/queryJson.native.js.map +1 -0
- package/dist/esm/resolveQuery.mjs +2 -0
- package/dist/esm/resolveQuery.mjs.map +1 -1
- package/dist/esm/resolveQuery.native.js +2 -0
- package/dist/esm/resolveQuery.native.js.map +1 -1
- package/dist/esm/useQuery.empty.test.mjs +12 -2
- package/dist/esm/useQuery.empty.test.mjs.map +1 -1
- package/dist/esm/useQuery.empty.test.native.js +24 -2
- package/dist/esm/useQuery.empty.test.native.js.map +1 -1
- package/dist/esm/useQuery.ssr.test.mjs +23 -2
- package/dist/esm/useQuery.ssr.test.mjs.map +1 -1
- package/dist/esm/useQuery.ssr.test.native.js +31 -2
- package/dist/esm/useQuery.ssr.test.native.js.map +1 -1
- package/package.json +11 -3
- package/src/createUseQuery.tsx +2 -1
- package/src/createUseQueryDirect.tsx +2 -1
- package/src/createZeroClient.recovery.test.tsx +370 -7
- package/src/createZeroClient.tsx +100 -10
- package/src/generate.test.ts +31 -0
- package/src/generate.ts +22 -10
- package/src/helpers/recoverZeroClient.test.ts +323 -0
- package/src/helpers/recoverZeroClient.ts +896 -78
- package/src/kvStore.native.ts +7 -0
- package/src/kvStore.ts +7 -0
- package/src/queryJson.ts +46 -0
- package/src/resolveQuery.ts +2 -0
- package/src/useQuery.empty.test.tsx +16 -2
- package/src/useQuery.ssr.test.ts +23 -2
- package/types/createUseQuery.d.ts.map +1 -1
- package/types/createUseQueryDirect.d.ts.map +1 -1
- package/types/createZeroClient.d.ts.map +1 -1
- package/types/createZeroClient.recovery.test.d.ts.map +1 -1
- package/types/generate.d.ts.map +1 -1
- package/types/helpers/recoverZeroClient.d.ts +6 -0
- package/types/helpers/recoverZeroClient.d.ts.map +1 -1
- package/types/kvStore.d.ts +2 -0
- package/types/kvStore.d.ts.map +1 -0
- package/types/kvStore.native.d.ts +3 -0
- package/types/kvStore.native.d.ts.map +1 -0
- package/types/queryJson.d.ts +3 -0
- package/types/queryJson.d.ts.map +1 -0
- package/types/resolveQuery.d.ts.map +1 -1
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { globalValue } from './globalValue'
|
|
2
|
+
|
|
1
3
|
import type { ZeroEventsEmitter, ZeroRecoveryReasonKey } from '../types'
|
|
2
4
|
import type { Context, LogLevel, LogSink } from '@rocicorp/logger'
|
|
3
5
|
import type { UpdateNeededReason } from '@rocicorp/zero'
|
|
@@ -14,6 +16,15 @@ const RECOVER_GUARD_MS = 60_000
|
|
|
14
16
|
// land.
|
|
15
17
|
const RELOAD_LATCH_TIMEOUT_MS = 15_000
|
|
16
18
|
|
|
19
|
+
// update-needed reasons that mean "new client code landed": every mounted Zero
|
|
20
|
+
// instance whose group is stale fires one, and they all ride ONE group
|
|
21
|
+
// transition per shared client identity instead of each recovering alone.
|
|
22
|
+
const GROUP_TRANSITION_REASONS: ReadonlySet<ZeroRecoveryReasonKey> = new Set([
|
|
23
|
+
'NewClientGroup',
|
|
24
|
+
'VersionNotSupported',
|
|
25
|
+
'SchemaVersionNotSupported',
|
|
26
|
+
])
|
|
27
|
+
|
|
17
28
|
// the fatal local-store-loss signature Zero logs when the IndexedDB it expects
|
|
18
29
|
// is gone (evicted, or deleted by another tab).
|
|
19
30
|
const LOCAL_STORE_LOST = 'Expected IndexedDB not found'
|
|
@@ -44,6 +55,7 @@ export type ZeroLogPattern = string | RegExp
|
|
|
44
55
|
export type RecoveryGuardStorage = {
|
|
45
56
|
getItem: (key: string) => string | null
|
|
46
57
|
setItem: (key: string, value: string) => void
|
|
58
|
+
removeItem?: (key: string) => void
|
|
47
59
|
}
|
|
48
60
|
|
|
49
61
|
// what a scheduleReload consumer receives so it can gate/annotate the reload
|
|
@@ -85,24 +97,128 @@ export type ZeroRecoveryDeps = {
|
|
|
85
97
|
recoverInPlace?: () => Promise<boolean>
|
|
86
98
|
// injectable for tests; defaults to a real page reload.
|
|
87
99
|
reload?: () => void | boolean
|
|
100
|
+
// shared client identity for group-transition coalescing (the Zero userID):
|
|
101
|
+
// sibling instances with the same identity ride one transition. defaults to
|
|
102
|
+
// '' (one transition per document for every mounted instance).
|
|
103
|
+
clientIdentity?: string
|
|
104
|
+
// read this instance's Zero client group ID. instances share one ID if and
|
|
105
|
+
// only if they share name, mutators, indexes, and schema version, so a
|
|
106
|
+
// post-recovery failure proves by comparison whether the recovery actually
|
|
107
|
+
// landed new code. without it the transition falls back to a one-retry
|
|
108
|
+
// budget before going terminal.
|
|
109
|
+
getGroupID?: () => Promise<string> | string
|
|
110
|
+
// wait for the reminted replacement to connect. in-place path only: the
|
|
111
|
+
// transition resolves only when every reminted participant reports
|
|
112
|
+
// connected. defaults to resolving immediately.
|
|
113
|
+
awaitReconnected?: () => Promise<boolean>
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// one participant of an in-progress group transition: a mounted Zero instance
|
|
117
|
+
// (identified by its recovery closures, which are stable per instance) that
|
|
118
|
+
// signaled while the transition was open.
|
|
119
|
+
type TransitionParticipant = {
|
|
120
|
+
deps: ZeroRecoveryDeps
|
|
121
|
+
// whether this participant's stale store must be dropped before recovery.
|
|
122
|
+
// SchemaVersionNotSupported sets it; NewClientGroup never does (its data
|
|
123
|
+
// is still valid, and the store is shared with sibling tabs).
|
|
124
|
+
dropLocalState: boolean
|
|
125
|
+
deleteExecuted: boolean
|
|
126
|
+
remintExecuted: boolean
|
|
127
|
+
reminted: boolean
|
|
128
|
+
skipped: boolean
|
|
129
|
+
// the instance's group when it joined (undefined when unreadable).
|
|
130
|
+
oldGroupID: string | undefined
|
|
131
|
+
wrappedRemint: (() => Promise<boolean>) | null
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
type GroupTransition = {
|
|
135
|
+
key: string
|
|
136
|
+
reasons: Set<ZeroRecoveryReasonKey>
|
|
137
|
+
participants: Map<object, TransitionParticipant>
|
|
138
|
+
// every instance constructed after this sequence number belongs to the new
|
|
139
|
+
// code: if one of those fires while the transition is still open, the
|
|
140
|
+
// recovery did not fix it (or even newer code superseded it).
|
|
141
|
+
startSeq: number
|
|
142
|
+
startedAtMs: number
|
|
143
|
+
// set when a reload run gathers this transition's work: joins after that
|
|
144
|
+
// run their delete/remint immediately instead of waiting for a next run.
|
|
145
|
+
remintsExecuted: boolean
|
|
146
|
+
pendingWaits: number
|
|
147
|
+
settled: boolean
|
|
148
|
+
outcome: 'completed' | 'terminal' | 'reloading' | 'superseded' | null
|
|
149
|
+
// releases connected-waits when the transition settles another way
|
|
150
|
+
// (terminal, superseded): without this a settle during a wait would leave
|
|
151
|
+
// the reload run awaiting forever and wedge the latch with it.
|
|
152
|
+
settledPromise: Promise<void>
|
|
153
|
+
notifySettled: () => void
|
|
88
154
|
}
|
|
89
155
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
156
|
+
type TerminalStamp = {
|
|
157
|
+
t: number
|
|
158
|
+
groups: string[]
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// everything recovery coordinates through, shared across dual-loaded module
|
|
162
|
+
// copies (cjs/esm) via globalValue. before, each copy kept its own reload
|
|
163
|
+
// latch and guard: N copies scheduled N reloads and the second copy's guard
|
|
164
|
+
// check went fatal while the first copy's reload was still pending. now one
|
|
165
|
+
// transition, one latch, and one guard serve the whole realm.
|
|
166
|
+
type RecoverySharedState = {
|
|
167
|
+
// one reload per page-load. the local-state deletes that must precede it are
|
|
168
|
+
// collected so EVERY affected instance drops its own store before we reload.
|
|
169
|
+
// they are thunks (not started promises) so a deferred reload can't leave the
|
|
170
|
+
// app running on an already-deleted store: the deletes run only when the
|
|
171
|
+
// reload actually proceeds. keyed by owner so the same instance never queues
|
|
172
|
+
// its delete twice and a group transition can take a delete over.
|
|
173
|
+
reloadScheduled: boolean
|
|
174
|
+
reloadInProgress: boolean
|
|
175
|
+
reloadLatchTimer: ReturnType<typeof setTimeout> | undefined
|
|
176
|
+
pendingDeletes: Array<{ owner: object; run: () => Promise<unknown> }>
|
|
177
|
+
pendingInPlaceRecoveries: Array<() => Promise<boolean>>
|
|
178
|
+
// within-a-page-load per-reason guard for the non-transition reasons: real
|
|
179
|
+
// loop protection everywhere, including Hermes (no storage needed). a reload
|
|
180
|
+
// wipes it, which is why the injectable storage below survives across reloads.
|
|
181
|
+
inMemoryGuard: Map<string, number>
|
|
182
|
+
constructionSeq: number
|
|
183
|
+
constructionSeqByDeps: WeakMap<object, number>
|
|
184
|
+
groupByDeps: WeakMap<object, string>
|
|
185
|
+
transitions: Map<string, GroupTransition>
|
|
186
|
+
terminalMemory: Map<string, TerminalStamp>
|
|
187
|
+
terminalEmitted: Map<string, number>
|
|
188
|
+
unknownPostSignals: Map<string, { count: number; windowStartMs: number }>
|
|
189
|
+
lastStoreClosedAtMs: number
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function sharedRecoveryState(): RecoverySharedState {
|
|
193
|
+
return globalValue<RecoverySharedState>('on-zero:recovery-shared', () => ({
|
|
194
|
+
reloadScheduled: false,
|
|
195
|
+
reloadInProgress: false,
|
|
196
|
+
reloadLatchTimer: undefined,
|
|
197
|
+
pendingDeletes: [],
|
|
198
|
+
pendingInPlaceRecoveries: [],
|
|
199
|
+
inMemoryGuard: new Map(),
|
|
200
|
+
constructionSeq: 0,
|
|
201
|
+
constructionSeqByDeps: new WeakMap(),
|
|
202
|
+
groupByDeps: new WeakMap(),
|
|
203
|
+
transitions: new Map(),
|
|
204
|
+
terminalMemory: new Map(),
|
|
205
|
+
terminalEmitted: new Map(),
|
|
206
|
+
unknownPostSignals: new Map(),
|
|
207
|
+
lastStoreClosedAtMs: 0,
|
|
208
|
+
}))
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function documentScope(): string {
|
|
212
|
+
try {
|
|
213
|
+
return globalThis.location?.href ?? ''
|
|
214
|
+
} catch {
|
|
215
|
+
return ''
|
|
216
|
+
}
|
|
217
|
+
}
|
|
101
218
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const inMemoryGuard = new Map<string, number>()
|
|
219
|
+
function guardStorageKey(reasonKey: ZeroRecoveryReasonKey): string {
|
|
220
|
+
return `on-zero-recover-${documentScope()}-${reasonKey}`
|
|
221
|
+
}
|
|
106
222
|
|
|
107
223
|
function defaultGuardStorage(): RecoveryGuardStorage | undefined {
|
|
108
224
|
try {
|
|
@@ -114,11 +230,14 @@ function defaultGuardStorage(): RecoveryGuardStorage | undefined {
|
|
|
114
230
|
}
|
|
115
231
|
}
|
|
116
232
|
|
|
117
|
-
// cross-page-load, per-reason guard
|
|
118
|
-
//
|
|
119
|
-
// catches re-fires within a page-load (works
|
|
120
|
-
// survives the reload, so a reason that reloaded
|
|
121
|
-
// caught as a genuine fatal instead of
|
|
233
|
+
// cross-page-load, per-reason guard for the NON-transition reasons: distinct
|
|
234
|
+
// keys mean a client-state-not-found recovery never suppresses a later
|
|
235
|
+
// desync one. the in-memory tier catches re-fires within a page-load (works
|
|
236
|
+
// on Hermes); the storage tier survives the reload, so a reason that reloaded
|
|
237
|
+
// then immediately re-fires is caught as a genuine fatal instead of
|
|
238
|
+
// reload-storming across loads. group-transition reasons use the richer
|
|
239
|
+
// markers below instead (they must tell "the reload didn't help" apart from
|
|
240
|
+
// "newer code landed since").
|
|
122
241
|
function recoveryGuardOpen(
|
|
123
242
|
reasonKey: ZeroRecoveryReasonKey,
|
|
124
243
|
guardStorage: RecoveryGuardStorage | undefined
|
|
@@ -127,14 +246,10 @@ function recoveryGuardOpen(
|
|
|
127
246
|
// its guard to the current document so one preview frame recovering does not
|
|
128
247
|
// falsely fatal a sibling frame. the URL survives a reload, preserving the
|
|
129
248
|
// cross-reload loop guard for the document that actually recovered.
|
|
130
|
-
|
|
131
|
-
try {
|
|
132
|
-
documentScope = globalThis.location?.href ?? ''
|
|
133
|
-
} catch {}
|
|
134
|
-
const key = `on-zero-recover-${documentScope}-${reasonKey}`
|
|
249
|
+
const key = guardStorageKey(reasonKey)
|
|
135
250
|
const now = Date.now()
|
|
136
251
|
|
|
137
|
-
const memLast = inMemoryGuard.get(key) ?? 0
|
|
252
|
+
const memLast = sharedRecoveryState().inMemoryGuard.get(key) ?? 0
|
|
138
253
|
if (memLast > 0 && now - memLast < RECOVER_GUARD_MS) return false
|
|
139
254
|
|
|
140
255
|
const storage = guardStorage ?? defaultGuardStorage()
|
|
@@ -149,20 +264,81 @@ function recoveryGuardOpen(
|
|
|
149
264
|
}
|
|
150
265
|
}
|
|
151
266
|
|
|
152
|
-
inMemoryGuard.set(key, now)
|
|
267
|
+
sharedRecoveryState().inMemoryGuard.set(key, now)
|
|
153
268
|
return true
|
|
154
269
|
}
|
|
155
270
|
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
|
|
271
|
+
// what a group transition persists across a reload: when it fired, which
|
|
272
|
+
// client groups it recovered from, and whether it ended terminal. legacy
|
|
273
|
+
// writers stored a bare timestamp; those read back as { t } with unknown
|
|
274
|
+
// groups and keep the legacy conservative behavior.
|
|
275
|
+
type RecoveryMarker = {
|
|
276
|
+
t: number
|
|
277
|
+
groups?: string[]
|
|
278
|
+
terminal?: boolean
|
|
279
|
+
msg?: string
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function readRecoveryMarker(
|
|
283
|
+
reasonKey: ZeroRecoveryReasonKey,
|
|
284
|
+
guardStorage: RecoveryGuardStorage | undefined
|
|
285
|
+
): RecoveryMarker | null {
|
|
286
|
+
const storage = guardStorage ?? defaultGuardStorage()
|
|
287
|
+
if (!storage) return null
|
|
288
|
+
let raw: string | null = null
|
|
289
|
+
try {
|
|
290
|
+
raw = storage.getItem(guardStorageKey(reasonKey))
|
|
291
|
+
} catch {
|
|
292
|
+
return null
|
|
293
|
+
}
|
|
294
|
+
if (!raw) return null
|
|
295
|
+
if (/^\d+$/.test(raw)) return { t: Number(raw) }
|
|
296
|
+
try {
|
|
297
|
+
const parsed = JSON.parse(raw) as RecoveryMarker
|
|
298
|
+
if (!parsed || typeof parsed.t !== 'number') return null
|
|
299
|
+
return parsed
|
|
300
|
+
} catch {
|
|
301
|
+
return null
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function writeRecoveryMarker(
|
|
306
|
+
reasonKey: ZeroRecoveryReasonKey,
|
|
307
|
+
marker: RecoveryMarker,
|
|
308
|
+
guardStorage: RecoveryGuardStorage | undefined
|
|
309
|
+
): void {
|
|
310
|
+
const storage = guardStorage ?? defaultGuardStorage()
|
|
311
|
+
if (!storage) return
|
|
312
|
+
try {
|
|
313
|
+
storage.setItem(guardStorageKey(reasonKey), JSON.stringify(marker))
|
|
314
|
+
} catch {
|
|
315
|
+
// storage unavailable mid-flight — the in-memory tier is the floor.
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function clearRecoveryMarker(
|
|
320
|
+
reasonKey: ZeroRecoveryReasonKey,
|
|
321
|
+
guardStorage: RecoveryGuardStorage | undefined
|
|
322
|
+
): void {
|
|
323
|
+
const storage = guardStorage ?? defaultGuardStorage()
|
|
324
|
+
if (!storage) return
|
|
325
|
+
try {
|
|
326
|
+
if (typeof storage.removeItem === 'function') {
|
|
327
|
+
storage.removeItem(guardStorageKey(reasonKey))
|
|
328
|
+
} else {
|
|
329
|
+
storage.setItem(guardStorageKey(reasonKey), JSON.stringify({ t: 0 }))
|
|
330
|
+
}
|
|
331
|
+
} catch {
|
|
332
|
+
// storage unavailable mid-flight — the next read treats it as stale.
|
|
333
|
+
}
|
|
334
|
+
}
|
|
160
335
|
|
|
161
336
|
function armReloadLatchTimeout() {
|
|
337
|
+
const shared = sharedRecoveryState()
|
|
162
338
|
if (typeof setTimeout !== 'function') return
|
|
163
|
-
if (reloadLatchTimer) clearTimeout(reloadLatchTimer)
|
|
164
|
-
reloadLatchTimer = setTimeout(() => {
|
|
165
|
-
reloadLatchTimer = undefined
|
|
339
|
+
if (shared.reloadLatchTimer) clearTimeout(shared.reloadLatchTimer)
|
|
340
|
+
shared.reloadLatchTimer = setTimeout(() => {
|
|
341
|
+
shared.reloadLatchTimer = undefined
|
|
166
342
|
// re-open SCHEDULING only. a reload that never landed (still deferred behind
|
|
167
343
|
// a consumer's IDE gate / countdown, which can hold for minutes) must not
|
|
168
344
|
// kill future recovery — but the deferred performReload still owns the
|
|
@@ -170,32 +346,561 @@ function armReloadLatchTimeout() {
|
|
|
170
346
|
// beforeReload can exceed this timeout), reloadInProgress must stay set so a
|
|
171
347
|
// second recovery can't double-drive the reload. so touch neither
|
|
172
348
|
// pendingDeletes nor reloadInProgress here.
|
|
173
|
-
reloadScheduled = false
|
|
349
|
+
shared.reloadScheduled = false
|
|
174
350
|
}, RELOAD_LATCH_TIMEOUT_MS)
|
|
175
351
|
// don't hold the event loop open (node/native) waiting on this defense timer.
|
|
176
352
|
if (
|
|
177
|
-
reloadLatchTimer &&
|
|
178
|
-
typeof reloadLatchTimer === 'object' &&
|
|
179
|
-
'unref' in reloadLatchTimer
|
|
353
|
+
shared.reloadLatchTimer &&
|
|
354
|
+
typeof shared.reloadLatchTimer === 'object' &&
|
|
355
|
+
'unref' in shared.reloadLatchTimer
|
|
180
356
|
) {
|
|
181
|
-
reloadLatchTimer.unref()
|
|
357
|
+
shared.reloadLatchTimer.unref()
|
|
182
358
|
}
|
|
183
359
|
}
|
|
184
360
|
|
|
185
361
|
function disarmReloadLatchTimeout() {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
reloadLatchTimer
|
|
362
|
+
const shared = sharedRecoveryState()
|
|
363
|
+
if (shared.reloadLatchTimer) {
|
|
364
|
+
clearTimeout(shared.reloadLatchTimer)
|
|
365
|
+
shared.reloadLatchTimer = undefined
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function pushGlobalDelete(deps: ZeroRecoveryDeps): void {
|
|
370
|
+
const shared = sharedRecoveryState()
|
|
371
|
+
if (shared.pendingDeletes.some((entry) => entry.owner === deps.deleteLocalState)) {
|
|
372
|
+
return
|
|
373
|
+
}
|
|
374
|
+
shared.pendingDeletes.push({
|
|
375
|
+
owner: deps.deleteLocalState,
|
|
376
|
+
run: () => Promise.resolve().then(deps.deleteLocalState),
|
|
377
|
+
})
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function removeGlobalDelete(owner: object): void {
|
|
381
|
+
const pending = sharedRecoveryState().pendingDeletes
|
|
382
|
+
const index = pending.findIndex((entry) => entry.owner === owner)
|
|
383
|
+
if (index !== -1) pending.splice(index, 1)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function removeGlobalRemint(fn: (() => Promise<boolean>) | undefined): void {
|
|
387
|
+
if (!fn) return
|
|
388
|
+
const pending = sharedRecoveryState().pendingInPlaceRecoveries
|
|
389
|
+
const index = pending.indexOf(fn)
|
|
390
|
+
if (index !== -1) pending.splice(index, 1)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// the construction sequence number tells pre-transition instances (built
|
|
394
|
+
// before the transition started: they join it) from post-transition ones
|
|
395
|
+
// (built by the transition's own remints: if one of those fires, the recovery
|
|
396
|
+
// did not fix it). stamped in makeZeroRecovery, which runs once per instance.
|
|
397
|
+
function seqOf(deps: ZeroRecoveryDeps): number {
|
|
398
|
+
const shared = sharedRecoveryState()
|
|
399
|
+
let seq = shared.constructionSeqByDeps.get(deps)
|
|
400
|
+
if (seq === undefined) {
|
|
401
|
+
seq = ++shared.constructionSeq
|
|
402
|
+
shared.constructionSeqByDeps.set(deps, seq)
|
|
403
|
+
}
|
|
404
|
+
return seq
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function refreshGroupCache(deps: ZeroRecoveryDeps): void {
|
|
408
|
+
if (!deps.getGroupID) return
|
|
409
|
+
let result: Promise<string> | string
|
|
410
|
+
try {
|
|
411
|
+
result = deps.getGroupID()
|
|
412
|
+
} catch {
|
|
413
|
+
return
|
|
414
|
+
}
|
|
415
|
+
if (typeof result === 'string') {
|
|
416
|
+
if (result) sharedRecoveryState().groupByDeps.set(deps, result)
|
|
417
|
+
return
|
|
418
|
+
}
|
|
419
|
+
void Promise.resolve(result).then(
|
|
420
|
+
(id) => {
|
|
421
|
+
if (typeof id === 'string' && id) sharedRecoveryState().groupByDeps.set(deps, id)
|
|
422
|
+
},
|
|
423
|
+
() => {}
|
|
424
|
+
)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function cachedGroupID(deps: ZeroRecoveryDeps): string | undefined {
|
|
428
|
+
return sharedRecoveryState().groupByDeps.get(deps)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// prime the group cache right after construction so the synchronous read at
|
|
432
|
+
// signal time hits: signals always fire post-open, long after this resolves.
|
|
433
|
+
export function primeGroupCache(deps: ZeroRecoveryDeps): void {
|
|
434
|
+
refreshGroupCache(deps)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function participantKey(deps: ZeroRecoveryDeps): object {
|
|
438
|
+
return deps.recoverInPlace ?? deps.deleteLocalState
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function transitionKey(deps: ZeroRecoveryDeps): string {
|
|
442
|
+
return `${documentScope()}\0${deps.clientIdentity ?? ''}`
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function findOpenTransitionParticipant(
|
|
446
|
+
deps: ZeroRecoveryDeps
|
|
447
|
+
): TransitionParticipant | undefined {
|
|
448
|
+
const transition = sharedRecoveryState().transitions.get(transitionKey(deps))
|
|
449
|
+
if (!transition || transition.settled) return undefined
|
|
450
|
+
// a post-transition instance is never a participant of the open transition.
|
|
451
|
+
if (seqOf(deps) > transition.startSeq) return undefined
|
|
452
|
+
return transition.participants.get(participantKey(deps))
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// true while any group transition is in flight. remint() consults this: a
|
|
456
|
+
// transition owns the retry policy (one remint per provider per transition,
|
|
457
|
+
// terminal-on-failure), so its remints bypass the cooldown/attempt budget and
|
|
458
|
+
// rapid legitimate transitions all land.
|
|
459
|
+
export function isGroupTransitionOpen(): boolean {
|
|
460
|
+
return sharedRecoveryState().transitions.size > 0
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function terminalKey(key: string, reasonKey: ZeroRecoveryReasonKey): string {
|
|
464
|
+
return `${key}\0${reasonKey}`
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function stampTerminal(
|
|
468
|
+
key: string,
|
|
469
|
+
reasonKey: ZeroRecoveryReasonKey,
|
|
470
|
+
guardStorage: RecoveryGuardStorage | undefined,
|
|
471
|
+
message: string,
|
|
472
|
+
groups: string[]
|
|
473
|
+
): void {
|
|
474
|
+
sharedRecoveryState().terminalMemory.set(terminalKey(key, reasonKey), {
|
|
475
|
+
t: Date.now(),
|
|
476
|
+
groups,
|
|
477
|
+
})
|
|
478
|
+
writeRecoveryMarker(
|
|
479
|
+
reasonKey,
|
|
480
|
+
{ t: Date.now(), terminal: true, groups, msg: message },
|
|
481
|
+
guardStorage
|
|
482
|
+
)
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// the one terminal error a failed transition emits: a single console error
|
|
486
|
+
// carrying the old/new group IDs, one fatal event, and one host event — never
|
|
487
|
+
// a cascade, never a loop. re-emits after the window expires so a failure an
|
|
488
|
+
// hour later still surfaces.
|
|
489
|
+
function emitTerminalOnce(
|
|
490
|
+
deps: ZeroRecoveryDeps,
|
|
491
|
+
key: string,
|
|
492
|
+
reasonKey: ZeroRecoveryReasonKey,
|
|
493
|
+
message: string
|
|
494
|
+
): void {
|
|
495
|
+
const shared = sharedRecoveryState()
|
|
496
|
+
const now = Date.now()
|
|
497
|
+
const emitKey = terminalKey(key, reasonKey)
|
|
498
|
+
const lastEmitted = shared.terminalEmitted.get(emitKey) ?? 0
|
|
499
|
+
if (lastEmitted > 0 && now - lastEmitted < RECOVER_GUARD_MS) return
|
|
500
|
+
shared.terminalEmitted.set(emitKey, now)
|
|
501
|
+
try {
|
|
502
|
+
console.error(`[on-zero] ${message}`)
|
|
503
|
+
deps.zeroEvents.emit({ type: 'fatal', reasonKey, reason: message })
|
|
504
|
+
} catch {}
|
|
505
|
+
// hosts embedding this app (preview shells, test harnesses) observe the
|
|
506
|
+
// terminal state without sharing the module instance: a plain event on the
|
|
507
|
+
// realm's global scope.
|
|
508
|
+
try {
|
|
509
|
+
globalThis.dispatchEvent?.(
|
|
510
|
+
new CustomEvent('on-zero-fatal', { detail: { reasonKey, reason: message } })
|
|
511
|
+
)
|
|
512
|
+
} catch {}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function settleTransition(
|
|
516
|
+
transition: GroupTransition,
|
|
517
|
+
outcome: GroupTransition['outcome']
|
|
518
|
+
): void {
|
|
519
|
+
if (transition.settled) return
|
|
520
|
+
transition.settled = true
|
|
521
|
+
transition.outcome = outcome
|
|
522
|
+
sharedRecoveryState().transitions.delete(transition.key)
|
|
523
|
+
transition.notifySettled()
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function checkTransitionComplete(transition: GroupTransition): void {
|
|
527
|
+
if (transition.settled) return
|
|
528
|
+
for (const participant of transition.participants.values()) {
|
|
529
|
+
if (!participant.reminted && !participant.skipped) return
|
|
530
|
+
}
|
|
531
|
+
if (transition.pendingWaits > 0) return
|
|
532
|
+
settleTransition(transition, 'completed')
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function failTransition(
|
|
536
|
+
transition: GroupTransition,
|
|
537
|
+
input: {
|
|
538
|
+
reasonKey: ZeroRecoveryReasonKey
|
|
539
|
+
message: string
|
|
540
|
+
signalDeps: ZeroRecoveryDeps
|
|
541
|
+
}
|
|
542
|
+
): void {
|
|
543
|
+
if (transition.settled) return
|
|
544
|
+
const groups = [
|
|
545
|
+
...new Set(
|
|
546
|
+
[...transition.participants.values()]
|
|
547
|
+
.map((participant) => participant.oldGroupID)
|
|
548
|
+
.filter((group): group is string => typeof group === 'string')
|
|
549
|
+
),
|
|
550
|
+
]
|
|
551
|
+
settleTransition(transition, 'terminal')
|
|
552
|
+
// the transition failed as a whole: stamp every reason it carried plus the
|
|
553
|
+
// signal's own, so a re-fire on any of them stays silent this window.
|
|
554
|
+
for (const reasonKey of new Set([...transition.reasons, input.reasonKey])) {
|
|
555
|
+
stampTerminal(
|
|
556
|
+
transition.key,
|
|
557
|
+
reasonKey,
|
|
558
|
+
input.signalDeps.guardStorage,
|
|
559
|
+
input.message,
|
|
560
|
+
groups
|
|
561
|
+
)
|
|
562
|
+
}
|
|
563
|
+
emitTerminalOnce(input.signalDeps, transition.key, input.reasonKey, input.message)
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function makeWrappedRemint(
|
|
567
|
+
transition: GroupTransition,
|
|
568
|
+
participant: TransitionParticipant
|
|
569
|
+
): () => Promise<boolean> {
|
|
570
|
+
return async () => {
|
|
571
|
+
if (participant.skipped || participant.reminted) return true
|
|
572
|
+
const remint = participant.deps.recoverInPlace
|
|
573
|
+
if (!remint) {
|
|
574
|
+
participant.skipped = true
|
|
575
|
+
checkTransitionComplete(transition)
|
|
576
|
+
return false
|
|
577
|
+
}
|
|
578
|
+
let ok: boolean
|
|
579
|
+
try {
|
|
580
|
+
ok = await remint()
|
|
581
|
+
} catch (error) {
|
|
582
|
+
// the recovery itself is broken — fail loud once rather than spin.
|
|
583
|
+
const [firstReason] = transition.reasons
|
|
584
|
+
failTransition(transition, {
|
|
585
|
+
reasonKey: firstReason ?? 'NewClientGroup',
|
|
586
|
+
message: `in-place recovery threw (${error instanceof Error ? error.message : String(error)})`,
|
|
587
|
+
signalDeps: participant.deps,
|
|
588
|
+
})
|
|
589
|
+
return false
|
|
590
|
+
}
|
|
591
|
+
// false means "nothing to reconstruct" (the provider unmounted
|
|
592
|
+
// mid-transition): skip the connected wait, don't fail the transition.
|
|
593
|
+
if (!ok) {
|
|
594
|
+
participant.skipped = true
|
|
595
|
+
checkTransitionComplete(transition)
|
|
596
|
+
return false
|
|
597
|
+
}
|
|
598
|
+
participant.reminted = true
|
|
599
|
+
const awaitReconnected = participant.deps.awaitReconnected
|
|
600
|
+
if (awaitReconnected) {
|
|
601
|
+
transition.pendingWaits += 1
|
|
602
|
+
try {
|
|
603
|
+
// released early when the transition settles another way so the
|
|
604
|
+
// reload run awaiting this remint can finish and reopen the latch.
|
|
605
|
+
await Promise.race([awaitReconnected(), transition.settledPromise])
|
|
606
|
+
} catch {
|
|
607
|
+
// a wait that rejects is over either way; the transition resolves
|
|
608
|
+
// from whoever is left, and a still-stale instance re-fires into a
|
|
609
|
+
// new transition.
|
|
610
|
+
} finally {
|
|
611
|
+
transition.pendingWaits -= 1
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
checkTransitionComplete(transition)
|
|
615
|
+
return true
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
async function runParticipantImmediate(
|
|
620
|
+
transition: GroupTransition,
|
|
621
|
+
participant: TransitionParticipant
|
|
622
|
+
): Promise<void> {
|
|
623
|
+
// claim both flags synchronously: a reload run gathering in a later
|
|
624
|
+
// microtask must skip this participant, never double-run it.
|
|
625
|
+
const doDelete = participant.dropLocalState && !participant.deleteExecuted
|
|
626
|
+
const doRemint = !participant.remintExecuted
|
|
627
|
+
participant.deleteExecuted = true
|
|
628
|
+
participant.remintExecuted = true
|
|
629
|
+
if (doDelete) {
|
|
630
|
+
try {
|
|
631
|
+
await participant.deps.deleteLocalState()
|
|
632
|
+
} catch {}
|
|
633
|
+
}
|
|
634
|
+
if (doRemint) {
|
|
635
|
+
if (participant.wrappedRemint) await participant.wrappedRemint()
|
|
636
|
+
else {
|
|
637
|
+
participant.skipped = true
|
|
638
|
+
checkTransitionComplete(transition)
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function joinTransition(
|
|
644
|
+
transition: GroupTransition,
|
|
645
|
+
deps: ZeroRecoveryDeps,
|
|
646
|
+
reasonKey: ZeroRecoveryReasonKey,
|
|
647
|
+
dropLocalState: boolean
|
|
648
|
+
): void {
|
|
649
|
+
transition.reasons.add(reasonKey)
|
|
650
|
+
const key = participantKey(deps)
|
|
651
|
+
const existing = transition.participants.get(key)
|
|
652
|
+
if (existing) {
|
|
653
|
+
// same instance firing again (or for a second reason) while the
|
|
654
|
+
// transition is open: dedupe, folding a stricter drop need in.
|
|
655
|
+
if (dropLocalState && !existing.dropLocalState) {
|
|
656
|
+
existing.dropLocalState = true
|
|
657
|
+
// the delete already ran without the drop: queue it for the next run
|
|
658
|
+
// rather than strand it.
|
|
659
|
+
if (existing.deleteExecuted) pushGlobalDelete(deps)
|
|
660
|
+
}
|
|
661
|
+
return
|
|
662
|
+
}
|
|
663
|
+
// this instance's recovery now belongs to the transition: take over any
|
|
664
|
+
// globally-queued work so it can neither double-run nor strand.
|
|
665
|
+
removeGlobalDelete(deps.deleteLocalState)
|
|
666
|
+
removeGlobalRemint(deps.recoverInPlace)
|
|
667
|
+
const participant: TransitionParticipant = {
|
|
668
|
+
deps,
|
|
669
|
+
dropLocalState,
|
|
670
|
+
deleteExecuted: false,
|
|
671
|
+
remintExecuted: false,
|
|
672
|
+
reminted: false,
|
|
673
|
+
skipped: false,
|
|
674
|
+
oldGroupID: cachedGroupID(deps),
|
|
675
|
+
wrappedRemint: null,
|
|
676
|
+
}
|
|
677
|
+
participant.wrappedRemint = makeWrappedRemint(transition, participant)
|
|
678
|
+
transition.participants.set(key, participant)
|
|
679
|
+
// joins stay silent (the transition's one recovering already fired), but a
|
|
680
|
+
// join after the reload run gathered must execute now — the run won't come
|
|
681
|
+
// back for it.
|
|
682
|
+
if (transition.remintsExecuted) void runParticipantImmediate(transition, participant)
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function startTransition(
|
|
686
|
+
deps: ZeroRecoveryDeps,
|
|
687
|
+
reasonKey: ZeroRecoveryReasonKey,
|
|
688
|
+
message: string,
|
|
689
|
+
dropLocalState: boolean
|
|
690
|
+
): void {
|
|
691
|
+
const shared = sharedRecoveryState()
|
|
692
|
+
const key = transitionKey(deps)
|
|
693
|
+
let notifySettled!: () => void
|
|
694
|
+
const settledPromise = new Promise<void>((resolve) => {
|
|
695
|
+
notifySettled = resolve
|
|
696
|
+
})
|
|
697
|
+
const transition: GroupTransition = {
|
|
698
|
+
key,
|
|
699
|
+
reasons: new Set([reasonKey]),
|
|
700
|
+
participants: new Map(),
|
|
701
|
+
startSeq: shared.constructionSeq,
|
|
702
|
+
startedAtMs: Date.now(),
|
|
703
|
+
remintsExecuted: false,
|
|
704
|
+
pendingWaits: 0,
|
|
705
|
+
settled: false,
|
|
706
|
+
outcome: null,
|
|
707
|
+
settledPromise,
|
|
708
|
+
notifySettled,
|
|
709
|
+
}
|
|
710
|
+
shared.transitions.set(key, transition)
|
|
711
|
+
joinTransition(transition, deps, reasonKey, dropLocalState)
|
|
712
|
+
console.warn(`[on-zero] ${message} — recovering`)
|
|
713
|
+
deps.zeroEvents.emit({ type: 'recovering', reasonKey, reason: message })
|
|
714
|
+
|
|
715
|
+
// a pending (not yet running) reload gathers this transition's work when it
|
|
716
|
+
// runs: piggyback on it. a mid-flight reload already gathered: run ours
|
|
717
|
+
// immediately instead of stranding it.
|
|
718
|
+
if (shared.reloadScheduled && !shared.reloadInProgress) return
|
|
719
|
+
if (shared.reloadInProgress) {
|
|
720
|
+
transition.remintsExecuted = true
|
|
721
|
+
for (const participant of transition.participants.values()) {
|
|
722
|
+
void runParticipantImmediate(transition, participant)
|
|
723
|
+
}
|
|
724
|
+
return
|
|
725
|
+
}
|
|
726
|
+
shared.reloadScheduled = true
|
|
727
|
+
armReloadLatchTimeout()
|
|
728
|
+
|
|
729
|
+
const runReload = () => performReload(deps)
|
|
730
|
+
if (deps.scheduleReload) {
|
|
731
|
+
deps.scheduleReload({
|
|
732
|
+
reason: message,
|
|
733
|
+
reasonKey,
|
|
734
|
+
dropLocalState,
|
|
735
|
+
performReload: runReload,
|
|
736
|
+
})
|
|
737
|
+
} else {
|
|
738
|
+
void runReload()
|
|
189
739
|
}
|
|
190
740
|
}
|
|
191
741
|
|
|
742
|
+
// no open transition: decide whether this failure may start one. signals that
|
|
743
|
+
// arrive while a transition is in flight join it and never touch the guard —
|
|
744
|
+
// only a failure after a completed transition consumes it. verifiable progress
|
|
745
|
+
// (the firing instance's group differs from every group on record) always
|
|
746
|
+
// starts a new transition, so rapid legitimate changes all land; an unchanged
|
|
747
|
+
// group after a reload, or a repeated failure with unreadable groups, goes
|
|
748
|
+
// terminal once instead of looping.
|
|
749
|
+
function checkTransitionGuard(
|
|
750
|
+
deps: ZeroRecoveryDeps,
|
|
751
|
+
reasonKey: ZeroRecoveryReasonKey
|
|
752
|
+
): 'allow' | 'silent' {
|
|
753
|
+
const shared = sharedRecoveryState()
|
|
754
|
+
const key = transitionKey(deps)
|
|
755
|
+
const now = Date.now()
|
|
756
|
+
const myGroup = cachedGroupID(deps)
|
|
757
|
+
|
|
758
|
+
const memory = shared.terminalMemory.get(terminalKey(key, reasonKey))
|
|
759
|
+
if (memory && memory.t > 0 && now - memory.t < RECOVER_GUARD_MS) {
|
|
760
|
+
if (
|
|
761
|
+
myGroup !== undefined &&
|
|
762
|
+
memory.groups.length > 0 &&
|
|
763
|
+
!memory.groups.includes(myGroup)
|
|
764
|
+
) {
|
|
765
|
+
// newer code landed since the terminal: retry.
|
|
766
|
+
shared.terminalMemory.delete(terminalKey(key, reasonKey))
|
|
767
|
+
} else {
|
|
768
|
+
return 'silent'
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const marker = readRecoveryMarker(reasonKey, deps.guardStorage)
|
|
773
|
+
if (marker && marker.t > 0 && now - marker.t < RECOVER_GUARD_MS) {
|
|
774
|
+
const markerGroups =
|
|
775
|
+
marker.groups && marker.groups.length > 0 ? marker.groups : undefined
|
|
776
|
+
if (
|
|
777
|
+
myGroup !== undefined &&
|
|
778
|
+
markerGroups !== undefined &&
|
|
779
|
+
!markerGroups.includes(myGroup)
|
|
780
|
+
) {
|
|
781
|
+
// the firing instance already runs newer code than the recorded
|
|
782
|
+
// recovery did: clear the marker and start a fresh transition.
|
|
783
|
+
clearRecoveryMarker(reasonKey, deps.guardStorage)
|
|
784
|
+
return 'allow'
|
|
785
|
+
}
|
|
786
|
+
if (marker.terminal) {
|
|
787
|
+
emitTerminalOnce(
|
|
788
|
+
deps,
|
|
789
|
+
key,
|
|
790
|
+
reasonKey,
|
|
791
|
+
marker.msg ??
|
|
792
|
+
`update needed (${reasonKey}) — recovery already failed, not retrying`
|
|
793
|
+
)
|
|
794
|
+
return 'silent'
|
|
795
|
+
}
|
|
796
|
+
if (
|
|
797
|
+
myGroup !== undefined &&
|
|
798
|
+
markerGroups !== undefined &&
|
|
799
|
+
markerGroups.includes(myGroup)
|
|
800
|
+
) {
|
|
801
|
+
// the recorded reload did not change the client group: the reload
|
|
802
|
+
// can't fix this, so go terminal once with the IDs.
|
|
803
|
+
const message = `update needed (${reasonKey}) — client group ${myGroup} → ${myGroup} (unchanged after reload), not reloading`
|
|
804
|
+
stampTerminal(key, reasonKey, deps.guardStorage, message, markerGroups)
|
|
805
|
+
emitTerminalOnce(deps, key, reasonKey, message)
|
|
806
|
+
return 'silent'
|
|
807
|
+
}
|
|
808
|
+
// unreadable groups plus a fresh reload marker: stay conservative
|
|
809
|
+
// (legacy parity) and go terminal once rather than risk a reload loop.
|
|
810
|
+
const message = `update needed (${reasonKey}) — already recovered once, not reloading`
|
|
811
|
+
stampTerminal(key, reasonKey, deps.guardStorage, message, markerGroups ?? [])
|
|
812
|
+
emitTerminalOnce(deps, key, reasonKey, message)
|
|
813
|
+
return 'silent'
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
return 'allow'
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// an instance built by the open transition's own remints is firing: the
|
|
820
|
+
// recovery did not fix it, or even newer code superseded it. an unchanged
|
|
821
|
+
// group proves the former and fails the transition terminally; a changed
|
|
822
|
+
// group proves the latter and supersedes into a fresh transition.
|
|
823
|
+
function handlePostTransitionSignal(
|
|
824
|
+
transition: GroupTransition,
|
|
825
|
+
deps: ZeroRecoveryDeps,
|
|
826
|
+
reasonKey: ZeroRecoveryReasonKey,
|
|
827
|
+
message: string,
|
|
828
|
+
dropLocalState: boolean
|
|
829
|
+
): void {
|
|
830
|
+
const shared = sharedRecoveryState()
|
|
831
|
+
const newGroup = cachedGroupID(deps)
|
|
832
|
+
const oldGroups = [
|
|
833
|
+
...new Set(
|
|
834
|
+
[...transition.participants.values()]
|
|
835
|
+
.map((participant) => participant.oldGroupID)
|
|
836
|
+
.filter((group): group is string => typeof group === 'string')
|
|
837
|
+
),
|
|
838
|
+
]
|
|
839
|
+
if (newGroup !== undefined && oldGroups.length > 0) {
|
|
840
|
+
if (!oldGroups.includes(newGroup)) {
|
|
841
|
+
// verifiably newer code: the transition's target is obsolete, not
|
|
842
|
+
// failed. supersede it — no guard consumed — and start fresh.
|
|
843
|
+
settleTransition(transition, 'superseded')
|
|
844
|
+
startTransition(deps, reasonKey, message, dropLocalState)
|
|
845
|
+
return
|
|
846
|
+
}
|
|
847
|
+
failTransition(transition, {
|
|
848
|
+
reasonKey,
|
|
849
|
+
message: `${message} — client group ${newGroup} did not change after recovery (still ${oldGroups.join(', ')}), not retrying`,
|
|
850
|
+
signalDeps: deps,
|
|
851
|
+
})
|
|
852
|
+
return
|
|
853
|
+
}
|
|
854
|
+
// groups unreadable: allow one supersede per window (the overlapping-edit
|
|
855
|
+
// case), then go terminal rather than remint-loop.
|
|
856
|
+
const now = Date.now()
|
|
857
|
+
const budget = shared.unknownPostSignals.get(transition.key)
|
|
858
|
+
const freshBudget =
|
|
859
|
+
budget && now - budget.windowStartMs < RECOVER_GUARD_MS ? budget : null
|
|
860
|
+
if (freshBudget && freshBudget.count >= 1) {
|
|
861
|
+
failTransition(transition, {
|
|
862
|
+
reasonKey,
|
|
863
|
+
message: `${message} — recovery did not resolve the client group, not retrying`,
|
|
864
|
+
signalDeps: deps,
|
|
865
|
+
})
|
|
866
|
+
return
|
|
867
|
+
}
|
|
868
|
+
shared.unknownPostSignals.set(transition.key, {
|
|
869
|
+
count: (freshBudget?.count ?? 0) + 1,
|
|
870
|
+
windowStartMs: freshBudget?.windowStartMs ?? now,
|
|
871
|
+
})
|
|
872
|
+
settleTransition(transition, 'superseded')
|
|
873
|
+
startTransition(deps, reasonKey, message, dropLocalState)
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function recoverViaGroupTransition(
|
|
877
|
+
deps: ZeroRecoveryDeps,
|
|
878
|
+
reasonKey: ZeroRecoveryReasonKey,
|
|
879
|
+
message: string,
|
|
880
|
+
dropLocalState: boolean
|
|
881
|
+
): void {
|
|
882
|
+
const key = transitionKey(deps)
|
|
883
|
+
const open = sharedRecoveryState().transitions.get(key)
|
|
884
|
+
if (open && !open.settled) {
|
|
885
|
+
if (seqOf(deps) > open.startSeq) {
|
|
886
|
+
handlePostTransitionSignal(open, deps, reasonKey, message, dropLocalState)
|
|
887
|
+
return
|
|
888
|
+
}
|
|
889
|
+
joinTransition(open, deps, reasonKey, dropLocalState)
|
|
890
|
+
return
|
|
891
|
+
}
|
|
892
|
+
if (checkTransitionGuard(deps, reasonKey) !== 'allow') return
|
|
893
|
+
startTransition(deps, reasonKey, message, dropLocalState)
|
|
894
|
+
}
|
|
895
|
+
|
|
192
896
|
// deletes every collected instance's local store, awaits beforeReload, then
|
|
193
897
|
// reloads. deferred a microtask so sibling instances failing in the same tick
|
|
194
898
|
// enqueue their deletes first. idempotent: a consumer that calls performReload
|
|
195
899
|
// twice (or after the latch timeout) only runs one reload.
|
|
196
900
|
function performReload(deps: ZeroRecoveryDeps): Promise<void> {
|
|
197
|
-
|
|
198
|
-
reloadInProgress
|
|
901
|
+
const shared = sharedRecoveryState()
|
|
902
|
+
if (shared.reloadInProgress) return Promise.resolve()
|
|
903
|
+
shared.reloadInProgress = true
|
|
199
904
|
// the consumer has committed to reloading, so the un-latch timeout has no job
|
|
200
905
|
// now — disarm it before the (possibly slow) delete/beforeReload chain so it
|
|
201
906
|
// can't fire mid-flight and re-open scheduling under an in-progress reload.
|
|
@@ -213,20 +918,79 @@ function performReload(deps: ZeroRecoveryDeps): Promise<void> {
|
|
|
213
918
|
reload.call(globalThis.location)
|
|
214
919
|
return true
|
|
215
920
|
})
|
|
216
|
-
|
|
921
|
+
// gather in the first microtask so sibling instances failing in the same
|
|
922
|
+
// tick enqueue first: every synchronous signal runs before any microtask
|
|
923
|
+
// does. a transition that joins after the gather runs its own work
|
|
924
|
+
// immediately instead of waiting for a next run.
|
|
925
|
+
let globalDeletes: Array<{ owner: object; run: () => Promise<unknown> }> = []
|
|
926
|
+
let globalRemints: Array<() => Promise<boolean>> = []
|
|
927
|
+
let openTransitions: GroupTransition[] = []
|
|
928
|
+
const transitionDeletes: Array<() => Promise<unknown>> = []
|
|
929
|
+
const transitionRemints: Array<{
|
|
930
|
+
transition: GroupTransition
|
|
931
|
+
run: () => Promise<boolean>
|
|
932
|
+
}> = []
|
|
217
933
|
return Promise.resolve()
|
|
218
934
|
.then(() => {
|
|
219
|
-
|
|
220
|
-
|
|
935
|
+
globalDeletes = shared.pendingDeletes.splice(0)
|
|
936
|
+
globalRemints = shared.pendingInPlaceRecoveries.splice(0)
|
|
937
|
+
openTransitions = [...shared.transitions.values()].filter(
|
|
938
|
+
(transition) => !transition.settled
|
|
939
|
+
)
|
|
940
|
+
for (const transition of openTransitions) {
|
|
941
|
+
transition.remintsExecuted = true
|
|
942
|
+
for (const participant of transition.participants.values()) {
|
|
943
|
+
if (participant.dropLocalState && !participant.deleteExecuted) {
|
|
944
|
+
participant.deleteExecuted = true
|
|
945
|
+
const runDelete = participant.deps.deleteLocalState
|
|
946
|
+
transitionDeletes.push(() => Promise.resolve().then(runDelete))
|
|
947
|
+
}
|
|
948
|
+
if (!participant.remintExecuted) {
|
|
949
|
+
participant.remintExecuted = true
|
|
950
|
+
if (participant.wrappedRemint) {
|
|
951
|
+
transitionRemints.push({
|
|
952
|
+
transition,
|
|
953
|
+
run: participant.wrappedRemint,
|
|
954
|
+
})
|
|
955
|
+
} else {
|
|
956
|
+
participant.skipped = true
|
|
957
|
+
checkTransitionComplete(transition)
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
return Promise.allSettled([
|
|
963
|
+
...globalDeletes.map((entry) => entry.run()),
|
|
964
|
+
...transitionDeletes.map((run) => run()),
|
|
965
|
+
])
|
|
221
966
|
})
|
|
222
967
|
.then(() => deps.beforeReload?.())
|
|
223
968
|
.catch(() => {})
|
|
224
969
|
.then(async () => {
|
|
225
|
-
|
|
226
|
-
if (reloadStarted !== false) return
|
|
970
|
+
let reloadStarted: void | boolean = false
|
|
227
971
|
try {
|
|
228
|
-
|
|
229
|
-
|
|
972
|
+
reloadStarted = doReload()
|
|
973
|
+
} catch {
|
|
974
|
+
// a throwing reload is an unavailable reload: fall through to the
|
|
975
|
+
// in-place path rather than strand the recovery.
|
|
976
|
+
reloadStarted = false
|
|
977
|
+
}
|
|
978
|
+
if (reloadStarted !== false) {
|
|
979
|
+
noteTransitionsReloading(openTransitions)
|
|
980
|
+
return
|
|
981
|
+
}
|
|
982
|
+
try {
|
|
983
|
+
// a transition may have settled (terminal, superseded) while the
|
|
984
|
+
// deletes/beforeReload ran: skip its gathered remints.
|
|
985
|
+
const liveRemints = transitionRemints.filter(
|
|
986
|
+
({ transition }) => !transition.settled
|
|
987
|
+
)
|
|
988
|
+
const results = await Promise.all([
|
|
989
|
+
...globalRemints.map((recover) => recover()),
|
|
990
|
+
...liveRemints.map(({ run }) => run()),
|
|
991
|
+
])
|
|
992
|
+
const globalResults = results.slice(0, globalRemints.length)
|
|
993
|
+
if (globalRemints.length > 0 && globalResults.some((recovered) => !recovered)) {
|
|
230
994
|
console.error('[on-zero] recovery could not reload or reconstruct the client')
|
|
231
995
|
}
|
|
232
996
|
} catch (error) {
|
|
@@ -234,12 +998,38 @@ function performReload(deps: ZeroRecoveryDeps): Promise<void> {
|
|
|
234
998
|
} finally {
|
|
235
999
|
// a page reload tears down this module, but an in-place recovery keeps
|
|
236
1000
|
// it alive. reopen the latch so a later, distinct failure can recover.
|
|
237
|
-
reloadInProgress = false
|
|
238
|
-
reloadScheduled = false
|
|
1001
|
+
shared.reloadInProgress = false
|
|
1002
|
+
shared.reloadScheduled = false
|
|
239
1003
|
}
|
|
240
1004
|
})
|
|
241
1005
|
}
|
|
242
1006
|
|
|
1007
|
+
// the page is really going down for these transitions: persist which groups
|
|
1008
|
+
// they recovered from so the reloaded page can tell "the reload didn't help"
|
|
1009
|
+
// (same group → terminal once) from "newer code landed" (changed group → a
|
|
1010
|
+
// fresh transition is fine).
|
|
1011
|
+
function noteTransitionsReloading(transitions: readonly GroupTransition[]): void {
|
|
1012
|
+
for (const transition of transitions) {
|
|
1013
|
+
if (transition.settled) continue
|
|
1014
|
+
const groups = [
|
|
1015
|
+
...new Set(
|
|
1016
|
+
[...transition.participants.values()]
|
|
1017
|
+
.map((participant) => participant.oldGroupID)
|
|
1018
|
+
.filter((group): group is string => typeof group === 'string')
|
|
1019
|
+
),
|
|
1020
|
+
]
|
|
1021
|
+
const [firstParticipant] = transition.participants.values()
|
|
1022
|
+
for (const reasonKey of transition.reasons) {
|
|
1023
|
+
writeRecoveryMarker(
|
|
1024
|
+
reasonKey,
|
|
1025
|
+
{ t: Date.now(), groups },
|
|
1026
|
+
firstParticipant?.deps.guardStorage
|
|
1027
|
+
)
|
|
1028
|
+
}
|
|
1029
|
+
settleTransition(transition, 'reloading')
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
243
1033
|
function recover(
|
|
244
1034
|
deps: ZeroRecoveryDeps,
|
|
245
1035
|
reasonKey: ZeroRecoveryReasonKey,
|
|
@@ -248,17 +1038,34 @@ function recover(
|
|
|
248
1038
|
): void {
|
|
249
1039
|
if (typeof window === 'undefined') return
|
|
250
1040
|
deps.onRecovery?.(reasonKey)
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
pendingDeletes.push(() => Promise.resolve().then(deps.deleteLocalState))
|
|
1041
|
+
refreshGroupCache(deps)
|
|
1042
|
+
if (GROUP_TRANSITION_REASONS.has(reasonKey)) {
|
|
1043
|
+
recoverViaGroupTransition(deps, reasonKey, message, dropLocalState)
|
|
1044
|
+
return
|
|
256
1045
|
}
|
|
257
|
-
|
|
258
|
-
|
|
1046
|
+
const shared = sharedRecoveryState()
|
|
1047
|
+
const participant = findOpenTransitionParticipant(deps)
|
|
1048
|
+
if (!participant) {
|
|
1049
|
+
// each affected instance drops its OWN stale store, even when a reload is
|
|
1050
|
+
// already queued — otherwise a sibling's store survives the reload and
|
|
1051
|
+
// fatal-loops on the next boot. the single scheduled reload awaits these.
|
|
1052
|
+
if (dropLocalState) {
|
|
1053
|
+
pushGlobalDelete(deps)
|
|
1054
|
+
}
|
|
1055
|
+
if (
|
|
1056
|
+
deps.recoverInPlace &&
|
|
1057
|
+
!shared.pendingInPlaceRecoveries.includes(deps.recoverInPlace)
|
|
1058
|
+
) {
|
|
1059
|
+
shared.pendingInPlaceRecoveries.push(deps.recoverInPlace)
|
|
1060
|
+
}
|
|
1061
|
+
} else if (dropLocalState && !participant.dropLocalState) {
|
|
1062
|
+
// the open transition owns this instance's recovery: fold the stricter
|
|
1063
|
+
// drop need into it instead of queueing a second delete.
|
|
1064
|
+
participant.dropLocalState = true
|
|
1065
|
+
if (participant.deleteExecuted) pushGlobalDelete(deps)
|
|
259
1066
|
}
|
|
260
1067
|
// only ONE reload per page-load; a later trigger just contributes its delete.
|
|
261
|
-
if (reloadScheduled) return
|
|
1068
|
+
if (shared.reloadScheduled) return
|
|
262
1069
|
if (!recoveryGuardOpen(reasonKey, deps.guardStorage)) {
|
|
263
1070
|
console.error(`[on-zero] ${message} — already recovered once, not reloading`)
|
|
264
1071
|
deps.zeroEvents.emit({ type: 'fatal', reasonKey, reason: message })
|
|
@@ -272,7 +1079,7 @@ function recover(
|
|
|
272
1079
|
} catch {}
|
|
273
1080
|
return
|
|
274
1081
|
}
|
|
275
|
-
reloadScheduled = true
|
|
1082
|
+
shared.reloadScheduled = true
|
|
276
1083
|
armReloadLatchTimeout()
|
|
277
1084
|
console.warn(`[on-zero] ${message} — recovering`)
|
|
278
1085
|
deps.zeroEvents.emit({ type: 'recovering', reasonKey, reason: message })
|
|
@@ -294,6 +1101,9 @@ function recover(
|
|
|
294
1101
|
// reloadWithReason, so these handlers are the ONLY recovery — they must cover
|
|
295
1102
|
// every reason or the app fatal-blanks forever.
|
|
296
1103
|
export function makeZeroRecovery(deps: ZeroRecoveryDeps) {
|
|
1104
|
+
// stamp the construction order now: post-transition instances (built by the
|
|
1105
|
+
// transition's own remints) must read newer than any transition they meet.
|
|
1106
|
+
seqOf(deps)
|
|
297
1107
|
return {
|
|
298
1108
|
onUpdateNeeded(reason: UpdateNeededReason) {
|
|
299
1109
|
// every update-needed reason needs new client code → reload. but only
|
|
@@ -438,8 +1248,9 @@ export function classifyZeroRecoveryLog(
|
|
|
438
1248
|
return { ...desync, dropLocalState: true }
|
|
439
1249
|
}
|
|
440
1250
|
if (text.includes(STORE_CLOSED) && !isBenignStoreClosedLog(text)) {
|
|
441
|
-
const
|
|
442
|
-
|
|
1251
|
+
const shared = sharedRecoveryState()
|
|
1252
|
+
const prevMs = shared.lastStoreClosedAtMs
|
|
1253
|
+
shared.lastStoreClosedAtMs = nowMs
|
|
443
1254
|
if (
|
|
444
1255
|
prevMs > 0 &&
|
|
445
1256
|
nowMs - prevMs >= STORE_CLOSED_REPEAT_MIN_MS &&
|
|
@@ -500,19 +1311,26 @@ export function isRecoverableZeroStalePokeMessage(message: string): boolean {
|
|
|
500
1311
|
)
|
|
501
1312
|
}
|
|
502
1313
|
|
|
503
|
-
// test-only: the reload latch + pending work + in-memory guard
|
|
504
|
-
// (a real page reload clears them); tests simulate
|
|
505
|
-
// "page loads". the injectable/sessionStorage
|
|
506
|
-
// real reload, it survives, which is what
|
|
1314
|
+
// test-only: the reload latch + pending work + in-memory guard + open
|
|
1315
|
+
// transitions are in-memory (a real page reload clears them); tests simulate
|
|
1316
|
+
// that reset between successive "page loads". the injectable/sessionStorage
|
|
1317
|
+
// guard is NOT cleared here — like a real reload, it survives, which is what
|
|
1318
|
+
// catches an immediate re-fire. the construction sequence stays monotonic so
|
|
1319
|
+
// pre/post-transition comparisons keep working across resets.
|
|
507
1320
|
export function resetRecoveryStateForTests() {
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
reloadLatchTimer
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
1321
|
+
const shared = sharedRecoveryState()
|
|
1322
|
+
shared.reloadScheduled = false
|
|
1323
|
+
shared.reloadInProgress = false
|
|
1324
|
+
if (shared.reloadLatchTimer) {
|
|
1325
|
+
clearTimeout(shared.reloadLatchTimer)
|
|
1326
|
+
shared.reloadLatchTimer = undefined
|
|
1327
|
+
}
|
|
1328
|
+
shared.pendingDeletes.length = 0
|
|
1329
|
+
shared.pendingInPlaceRecoveries.length = 0
|
|
1330
|
+
shared.lastStoreClosedAtMs = 0
|
|
1331
|
+
shared.inMemoryGuard.clear()
|
|
1332
|
+
shared.transitions.clear()
|
|
1333
|
+
shared.terminalMemory.clear()
|
|
1334
|
+
shared.terminalEmitted.clear()
|
|
1335
|
+
shared.unknownPostSignals.clear()
|
|
518
1336
|
}
|