@net-mesh/browser 0.37.0
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 +517 -0
- package/LICENSE-APACHE +190 -0
- package/LICENSE-MIT +21 -0
- package/README.md +666 -0
- package/dist/async-queue.d.ts +26 -0
- package/dist/async-queue.js +62 -0
- package/dist/credential.d.ts +70 -0
- package/dist/credential.js +95 -0
- package/dist/errors.d.ts +400 -0
- package/dist/errors.js +726 -0
- package/dist/events.d.ts +232 -0
- package/dist/events.js +298 -0
- package/dist/identity.d.ts +40 -0
- package/dist/identity.js +78 -0
- package/dist/index.bundle.js +2 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.js +46 -0
- package/dist/leader/events.d.ts +111 -0
- package/dist/leader/events.js +116 -0
- package/dist/leader/index.d.ts +14 -0
- package/dist/leader/index.js +11 -0
- package/dist/leader/session.d.ts +539 -0
- package/dist/leader/session.js +770 -0
- package/dist/leader/wasm.d.ts +123 -0
- package/dist/leader/wasm.js +39 -0
- package/dist/lobby.d.ts +183 -0
- package/dist/lobby.js +504 -0
- package/dist/local.d.ts +80 -0
- package/dist/local.js +160 -0
- package/dist/net_leaf.d.ts +1494 -0
- package/dist/net_leaf.js +3753 -0
- package/dist/net_leaf_bg.wasm +0 -0
- package/dist/net_leaf_bg.wasm.d.ts +137 -0
- package/dist/netcode/client.d.ts +102 -0
- package/dist/netcode/client.js +296 -0
- package/dist/netcode/clock.d.ts +45 -0
- package/dist/netcode/clock.js +54 -0
- package/dist/netcode/host.d.ts +118 -0
- package/dist/netcode/host.js +276 -0
- package/dist/netcode/index.d.ts +19 -0
- package/dist/netcode/index.js +14 -0
- package/dist/netcode/interpolate.d.ts +50 -0
- package/dist/netcode/interpolate.js +100 -0
- package/dist/netcode/wire.d.ts +125 -0
- package/dist/netcode/wire.js +117 -0
- package/dist/node.d.ts +908 -0
- package/dist/node.js +1085 -0
- package/dist/org.d.ts +412 -0
- package/dist/org.js +567 -0
- package/dist/peer-driver.d.ts +117 -0
- package/dist/peer-driver.js +224 -0
- package/dist/store/assembly.d.ts +176 -0
- package/dist/store/assembly.js +325 -0
- package/dist/store/chunker.d.ts +83 -0
- package/dist/store/chunker.js +190 -0
- package/dist/store/core.d.ts +136 -0
- package/dist/store/core.js +477 -0
- package/dist/store/definition.d.ts +23 -0
- package/dist/store/definition.js +83 -0
- package/dist/store/errors.d.ts +77 -0
- package/dist/store/errors.js +94 -0
- package/dist/store/host.d.ts +247 -0
- package/dist/store/host.js +675 -0
- package/dist/store/index.d.ts +50 -0
- package/dist/store/index.js +40 -0
- package/dist/store/interest.d.ts +40 -0
- package/dist/store/interest.js +77 -0
- package/dist/store/inventory.d.ts +81 -0
- package/dist/store/inventory.js +140 -0
- package/dist/store/join.d.ts +118 -0
- package/dist/store/join.js +724 -0
- package/dist/store/json.d.ts +50 -0
- package/dist/store/json.js +283 -0
- package/dist/store/ledger.d.ts +187 -0
- package/dist/store/ledger.js +292 -0
- package/dist/store/owner.d.ts +616 -0
- package/dist/store/owner.js +1711 -0
- package/dist/store/patch.d.ts +70 -0
- package/dist/store/patch.js +166 -0
- package/dist/store/player.d.ts +65 -0
- package/dist/store/player.js +261 -0
- package/dist/store/replica.d.ts +187 -0
- package/dist/store/replica.js +740 -0
- package/dist/store/state.d.ts +42 -0
- package/dist/store/state.js +165 -0
- package/dist/store/types.d.ts +200 -0
- package/dist/store/types.js +25 -0
- package/dist/store/visibility.d.ts +118 -0
- package/dist/store/visibility.js +282 -0
- package/dist/store/wire.d.ts +309 -0
- package/dist/store/wire.js +834 -0
- package/dist/stream.d.ts +174 -0
- package/dist/stream.js +238 -0
- package/dist/three/index.d.ts +109 -0
- package/dist/three/index.js +130 -0
- package/dist/udp-probe.d.ts +159 -0
- package/dist/udp-probe.js +245 -0
- package/dist/wasm.d.ts +642 -0
- package/dist/wasm.js +98 -0
- package/dist/world/border.d.ts +84 -0
- package/dist/world/border.js +99 -0
- package/dist/world/handoff.d.ts +187 -0
- package/dist/world/handoff.js +222 -0
- package/dist/world/index.d.ts +13 -0
- package/dist/world/index.js +9 -0
- package/dist/world/link.d.ts +179 -0
- package/dist/world/link.js +476 -0
- package/dist/world/regions.d.ts +154 -0
- package/dist/world/regions.js +314 -0
- package/package.json +72 -0
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slice D — the replica: snapshot install, delta apply, resync,
|
|
3
|
+
* generations.
|
|
4
|
+
*
|
|
5
|
+
* ## What this module is
|
|
6
|
+
*
|
|
7
|
+
* The other half of slice C. The owner emits `man` + `snap` chunks and
|
|
8
|
+
* later `delta`s; this decides what a caller is allowed to SEE as a
|
|
9
|
+
* result. It is the production form of the state machine
|
|
10
|
+
* `test/store/lifecycle-model.ts` models abstractly (§1.7a), with the
|
|
11
|
+
* real codec, the real assembler (slice B1) and the real document.
|
|
12
|
+
*
|
|
13
|
+
* ## The three monotone numbers (§1.7a), which are different things
|
|
14
|
+
*
|
|
15
|
+
* - `installed` — the generation whose snapshot is published.
|
|
16
|
+
* - `assembling` — the generation of the open assembly.
|
|
17
|
+
* - `retired` — the highest generation ever **admitted**, installed or
|
|
18
|
+
* not. This is the watermark that makes an abandoned generation
|
|
19
|
+
* inadmissible for ever: `g > installed` would let a delayed manifest
|
|
20
|
+
* reopen a generation that was admitted, abandoned, and never
|
|
21
|
+
* published. Admission tests `g > retired`.
|
|
22
|
+
*
|
|
23
|
+
* Duplicate active manifests fall out of the same rule rather than
|
|
24
|
+
* needing their own: admitting the first set `retired := g`, so the
|
|
25
|
+
* second fails `g > retired` and the open assembly is untouched.
|
|
26
|
+
*
|
|
27
|
+
* ## What an empty slot is not
|
|
28
|
+
*
|
|
29
|
+
* `fenced` exists because an empty correlation slot is not consent. A
|
|
30
|
+
* refused request fences the subscription's view; only `ready` accepts
|
|
31
|
+
* unsolicited owner refreshes, and a `fenced` replica records no skip,
|
|
32
|
+
* so an owner emission cannot lift a fence the caller placed.
|
|
33
|
+
*
|
|
34
|
+
* ## Scope
|
|
35
|
+
*
|
|
36
|
+
* Audience transitions and `resume` are slice F, actions and the ledger
|
|
37
|
+
* are slice E. Both are refused here rather than half-served: this
|
|
38
|
+
* module issues `join` and `resync` and nothing else.
|
|
39
|
+
*
|
|
40
|
+
* Its acceptance gate is reliable transfer (§4 D), exactly as B2′ — a
|
|
41
|
+
* replica that applies deltas in order is not evidence that the
|
|
42
|
+
* transport delivers them in order.
|
|
43
|
+
*/
|
|
44
|
+
import { ASSEMBLY_DEADLINE_MS, AssemblyTable, } from './assembly.js';
|
|
45
|
+
import { StoreError } from './errors.js';
|
|
46
|
+
import { decimalValue, decodeMessage, encodeMessage } from './wire.js';
|
|
47
|
+
/**
|
|
48
|
+
* How many times an unanswered transition re-asks before the replica
|
|
49
|
+
* gives the caller a terminal answer.
|
|
50
|
+
*
|
|
51
|
+
* Three: enough that a join survives independent losses, few enough
|
|
52
|
+
* that a store nobody is serving stops asking. Each re-ask abandons
|
|
53
|
+
* the subscription the owner may have opened for the previous one;
|
|
54
|
+
* nothing can release it by name, because a replica whose MANIFEST
|
|
55
|
+
* was lost never learned a handle to release — the owner's own idle
|
|
56
|
+
* lease (`owner.ts`'s `HANDLE_LEASE_MS`) is what reclaims it, which
|
|
57
|
+
* is why the ladder is bounded rather than endless.
|
|
58
|
+
*/
|
|
59
|
+
export const MAX_JOIN_REASKS = 3;
|
|
60
|
+
/**
|
|
61
|
+
* A replica of one owner's store.
|
|
62
|
+
*
|
|
63
|
+
* Every method returns the requests it wants sent; nothing here touches
|
|
64
|
+
* a transport, so the state machine is testable without one.
|
|
65
|
+
*/
|
|
66
|
+
export class StoreReplica {
|
|
67
|
+
deps;
|
|
68
|
+
#state = 'joining';
|
|
69
|
+
/** The handle, once LEARNED from a manifest. Only `join` creates one. */
|
|
70
|
+
#handle = null;
|
|
71
|
+
#incarnation = null;
|
|
72
|
+
#installed = null;
|
|
73
|
+
/** The owner's revision for the installed view — not `core.revision`. */
|
|
74
|
+
#revision = null;
|
|
75
|
+
#assembling = null;
|
|
76
|
+
#retired = 0n;
|
|
77
|
+
#skipped = null;
|
|
78
|
+
#behind = false;
|
|
79
|
+
/** The desired-transition slot: one `q` per shared transition. */
|
|
80
|
+
#slot = null;
|
|
81
|
+
/**
|
|
82
|
+
* When the outstanding transition was ASKED for, and how many times
|
|
83
|
+
* the deadline has re-asked it.
|
|
84
|
+
*
|
|
85
|
+
* The assembly deadline covers an assembly that stalls half-built.
|
|
86
|
+
* It cannot cover a transition that never produced one: a `join`
|
|
87
|
+
* whose MANIFEST is lost opens nothing, so there is nothing to
|
|
88
|
+
* expire, and before this the caller's `ready()` never settled and
|
|
89
|
+
* nothing was ever sent again — the failure was pure silence. A
|
|
90
|
+
* real browser produced exactly that, and the lost datagram was
|
|
91
|
+
* 130-200 bytes: the manifest, far too small to be one of the
|
|
92
|
+
* multi-kilobyte chunks.
|
|
93
|
+
*
|
|
94
|
+
* Never cleared: `tick` reads it only for a state with no open
|
|
95
|
+
* assembly, and every path into `joining` or `installing` stamps it
|
|
96
|
+
* on entry, so a stale value cannot be read.
|
|
97
|
+
*/
|
|
98
|
+
#askedAt = null;
|
|
99
|
+
#reasks = 0;
|
|
100
|
+
/**
|
|
101
|
+
* Bumped by every caller-initiated transition.
|
|
102
|
+
*
|
|
103
|
+
* The replica calls APPLICATION code in two places inside an
|
|
104
|
+
* installation — the definition's validator, from the assembler, and
|
|
105
|
+
* a subscriber, from the publication — and either may cancel or
|
|
106
|
+
* change audience re-entrantly. Comparing the epoch across those
|
|
107
|
+
* calls is what stops an installation that was cancelled halfway
|
|
108
|
+
* from finishing and reporting `ready`: without it, a cancellation
|
|
109
|
+
* from a validator was simply overwritten, and one from a
|
|
110
|
+
* subscriber left the caller `ready` and not stale over the empty
|
|
111
|
+
* view `cancel` had just published.
|
|
112
|
+
*/
|
|
113
|
+
#epoch = 0;
|
|
114
|
+
#assembly = null;
|
|
115
|
+
/** The caller's latest desired audience. Survives every transition. */
|
|
116
|
+
#desired;
|
|
117
|
+
/** Local waiters on the slot's transition, independently cancellable. */
|
|
118
|
+
#waiters = 0;
|
|
119
|
+
#assemblies = new AssemblyTable();
|
|
120
|
+
#dropped = {};
|
|
121
|
+
constructor(deps) {
|
|
122
|
+
this.deps = deps;
|
|
123
|
+
this.#desired = [...deps.audience];
|
|
124
|
+
this.#interest = deps.interest === undefined ? null : [...deps.interest];
|
|
125
|
+
}
|
|
126
|
+
#interest;
|
|
127
|
+
/**
|
|
128
|
+
* The caller's desired interest set, stated by every later `join` and
|
|
129
|
+
* `resume` so a rejoin never falls back to an older one. `null`: none
|
|
130
|
+
* declared, every entity delivered.
|
|
131
|
+
*/
|
|
132
|
+
get interest() {
|
|
133
|
+
return this.#interest === null ? null : [...this.#interest];
|
|
134
|
+
}
|
|
135
|
+
set interest(keys) {
|
|
136
|
+
this.#interest = keys === null ? null : [...keys];
|
|
137
|
+
}
|
|
138
|
+
/** The caller's latest desired audience. */
|
|
139
|
+
get desired() {
|
|
140
|
+
return [...this.#desired];
|
|
141
|
+
}
|
|
142
|
+
/** Local waiters on the transition in flight. */
|
|
143
|
+
get waiters() {
|
|
144
|
+
return this.#waiters;
|
|
145
|
+
}
|
|
146
|
+
get state() {
|
|
147
|
+
return this.#state;
|
|
148
|
+
}
|
|
149
|
+
/** The learned handle, or null while none has been learned. */
|
|
150
|
+
get handle() {
|
|
151
|
+
return this.#handle;
|
|
152
|
+
}
|
|
153
|
+
/** The installed generation, as a canonical decimal, or null. */
|
|
154
|
+
get installed() {
|
|
155
|
+
return this.#installed === null ? null : this.#installed.toString();
|
|
156
|
+
}
|
|
157
|
+
/** The owner's revision for the installed view, or null. */
|
|
158
|
+
get revision() {
|
|
159
|
+
return this.#revision === null ? null : this.#revision.toString();
|
|
160
|
+
}
|
|
161
|
+
/** The watermark: the highest generation ever admitted. */
|
|
162
|
+
get retired() {
|
|
163
|
+
return this.#retired.toString();
|
|
164
|
+
}
|
|
165
|
+
/** The highest refresh dropped mid-transition, or null. */
|
|
166
|
+
get skipped() {
|
|
167
|
+
return this.#skipped === null ? null : this.#skipped.toString();
|
|
168
|
+
}
|
|
169
|
+
/** Arrivals not taken, by reason. */
|
|
170
|
+
get dropped() {
|
|
171
|
+
return { ...this.#dropped };
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* The first request: a `join`.
|
|
175
|
+
*
|
|
176
|
+
* Only `join` creates a handle, so this is the only entry point from
|
|
177
|
+
* a state with none — including after `no {closed}`, which discards
|
|
178
|
+
* the handle and all of its generation state.
|
|
179
|
+
*/
|
|
180
|
+
join() {
|
|
181
|
+
if (this.#state === 'closed') {
|
|
182
|
+
throw new StoreError('closed', 'a closed replica cannot join again');
|
|
183
|
+
}
|
|
184
|
+
this.#epoch += 1;
|
|
185
|
+
this.#waiters = 1;
|
|
186
|
+
// A join is a FRESH SUBSCRIPTION with a new handle, and §1.7a
|
|
187
|
+
// restarts that handle's generations at 1 — so every value scoped
|
|
188
|
+
// to the old handle goes, `retired` included. Keeping the old
|
|
189
|
+
// watermark wedges the rejoin permanently: the new handle's first
|
|
190
|
+
// manifest names generation 1, fails `g > retired` against the
|
|
191
|
+
// watermark of a handle that no longer exists, and nothing can
|
|
192
|
+
// ever install again. Recovery knowledge goes for the same reason
|
|
193
|
+
// — a skip recorded before a cancellation must not make the next
|
|
194
|
+
// installation ask for work the caller cancelled.
|
|
195
|
+
this.#reset();
|
|
196
|
+
const q = this.deps.newQ();
|
|
197
|
+
this.#slot = q;
|
|
198
|
+
this.#askedAt = this.deps.now();
|
|
199
|
+
this.#reasks = 0;
|
|
200
|
+
this.#state = 'joining';
|
|
201
|
+
this.deps.core.setStatus({ phase: 'connecting', stale: this.#published() });
|
|
202
|
+
return {
|
|
203
|
+
kind: 'join',
|
|
204
|
+
q,
|
|
205
|
+
frame: encodeMessage({
|
|
206
|
+
k: 'join',
|
|
207
|
+
q,
|
|
208
|
+
def: this.deps.definition.id,
|
|
209
|
+
store: this.deps.store,
|
|
210
|
+
ver: this.deps.definition.version,
|
|
211
|
+
key: this.deps.key,
|
|
212
|
+
aud: [...this.#desired],
|
|
213
|
+
...(this.#interest === null ? {} : { int: [...this.#interest] }),
|
|
214
|
+
}),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Change what this caller wants to see (§1.7).
|
|
219
|
+
*
|
|
220
|
+
* Three moments, deliberately separate: local intent is IMMEDIATE —
|
|
221
|
+
* the view is cleared to `empty()` and the status is `syncing`,
|
|
222
|
+
* before the owner has agreed to anything, because continuing to
|
|
223
|
+
* render the old audience is the disclosure this exists to stop.
|
|
224
|
+
* Owner acceptance allocates the generation, and installation
|
|
225
|
+
* publishes it.
|
|
226
|
+
*
|
|
227
|
+
* Equal pending requests share one wire request and one `q`, with a
|
|
228
|
+
* local waiter each: `q` is the transition's identity on the wire,
|
|
229
|
+
* not a waiter's.
|
|
230
|
+
*/
|
|
231
|
+
setAudience(names) {
|
|
232
|
+
if (this.#state === 'closed') {
|
|
233
|
+
throw new StoreError('closed', 'a closed replica cannot change audience');
|
|
234
|
+
}
|
|
235
|
+
this.#epoch += 1;
|
|
236
|
+
const next = [...names];
|
|
237
|
+
if (this.#state === 'installing' && sameAudience(next, this.#desired)) {
|
|
238
|
+
// The same transition, already in flight. One wire request, one
|
|
239
|
+
// slot, one more waiter.
|
|
240
|
+
this.#waiters += 1;
|
|
241
|
+
return [];
|
|
242
|
+
}
|
|
243
|
+
this.#desired = next;
|
|
244
|
+
const h = this.#handle;
|
|
245
|
+
// Immediately, whatever the owner later says.
|
|
246
|
+
this.#retireAssembly();
|
|
247
|
+
this.#installed = null;
|
|
248
|
+
this.#revision = null;
|
|
249
|
+
this.#skipped = null;
|
|
250
|
+
this.#behind = false;
|
|
251
|
+
this.deps.core.applySnapshot(this.deps.definition.empty());
|
|
252
|
+
this.deps.core.setStatus({ phase: 'syncing', stale: false, error: null });
|
|
253
|
+
if (h === null) {
|
|
254
|
+
// Nothing to change the audience OF: only `join` creates a
|
|
255
|
+
// handle, so the desired audience rides the join instead.
|
|
256
|
+
this.#waiters = 1;
|
|
257
|
+
return [this.join()];
|
|
258
|
+
}
|
|
259
|
+
// A newer transition rejects the previous waiters and takes the
|
|
260
|
+
// slot with a NEW `q`.
|
|
261
|
+
const q = this.deps.newQ();
|
|
262
|
+
this.#slot = q;
|
|
263
|
+
this.#askedAt = this.deps.now();
|
|
264
|
+
this.#waiters = 1;
|
|
265
|
+
this.#state = 'installing';
|
|
266
|
+
return [{ kind: 'aud', q, frame: encodeMessage({ k: 'aud', q, h, aud: next }) }];
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* The session was replaced (§1.6).
|
|
270
|
+
*
|
|
271
|
+
* A reconnect is not an audience change: the last snapshot is
|
|
272
|
+
* RETAINED and marked stale, because what the caller may see has not
|
|
273
|
+
* changed — only whether it is current. The request states the
|
|
274
|
+
* caller's latest desired audience, never the owner's older one.
|
|
275
|
+
*/
|
|
276
|
+
reconnect() {
|
|
277
|
+
if (this.#state === 'closed') {
|
|
278
|
+
throw new StoreError('closed', 'a closed replica cannot reconnect');
|
|
279
|
+
}
|
|
280
|
+
this.#epoch += 1;
|
|
281
|
+
const h = this.#handle;
|
|
282
|
+
// The lost session's assembly cannot be completed on the new one.
|
|
283
|
+
this.#retireAssembly();
|
|
284
|
+
if (h === null) {
|
|
285
|
+
// No handle learned, so there is nothing to resume: a forged `h`
|
|
286
|
+
// is exactly what `resume` must never be allowed to mint, so the
|
|
287
|
+
// only honest request is a fresh `join`.
|
|
288
|
+
return [this.join()];
|
|
289
|
+
}
|
|
290
|
+
const q = this.deps.newQ();
|
|
291
|
+
this.#slot = q;
|
|
292
|
+
this.#state = 'installing';
|
|
293
|
+
this.deps.core.setStatus({ phase: 'reconnecting', stale: this.#published() });
|
|
294
|
+
return [
|
|
295
|
+
{
|
|
296
|
+
kind: 'resume',
|
|
297
|
+
q,
|
|
298
|
+
frame: encodeMessage({
|
|
299
|
+
k: 'resume',
|
|
300
|
+
q,
|
|
301
|
+
h,
|
|
302
|
+
aud: [...this.#desired],
|
|
303
|
+
...(this.#interest === null ? {} : { int: [...this.#interest] }),
|
|
304
|
+
}),
|
|
305
|
+
},
|
|
306
|
+
];
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* One local waiter gives up.
|
|
310
|
+
*
|
|
311
|
+
* Cancellation is per waiter; the wire request is not. Cancelling the
|
|
312
|
+
* last one fences the subscription — the view was invalidated at
|
|
313
|
+
* request time and is NOT restored, because the caller asked for a
|
|
314
|
+
* different one.
|
|
315
|
+
*/
|
|
316
|
+
cancelWaiter() {
|
|
317
|
+
if (this.#waiters > 0)
|
|
318
|
+
this.#waiters -= 1;
|
|
319
|
+
if (this.#waiters > 0)
|
|
320
|
+
return;
|
|
321
|
+
if (this.#state !== 'installing')
|
|
322
|
+
return;
|
|
323
|
+
this.cancel();
|
|
324
|
+
}
|
|
325
|
+
/** One decoded arrival from the owner. */
|
|
326
|
+
receive(frame) {
|
|
327
|
+
if (this.#state === 'closed')
|
|
328
|
+
return this.#drop('closed');
|
|
329
|
+
const decoded = decodeMessage(frame, { maxBytes: this.deps.maxEventBytes, as: 'replica' });
|
|
330
|
+
if (!decoded.ok)
|
|
331
|
+
return this.#drop(decoded.reason);
|
|
332
|
+
const message = decoded.message;
|
|
333
|
+
// A message naming a handle other than the learned one is dropped
|
|
334
|
+
// and counted, whatever else it says. This is checked before the
|
|
335
|
+
// kind, so a `delta` for a foreign handle cannot reach the patch
|
|
336
|
+
// path at all.
|
|
337
|
+
if ('h' in message && message.h !== undefined && this.#handle !== null && message.h !== this.#handle) {
|
|
338
|
+
return this.#drop('foreign-handle');
|
|
339
|
+
}
|
|
340
|
+
switch (message.k) {
|
|
341
|
+
case 'man':
|
|
342
|
+
return this.#manifest(message);
|
|
343
|
+
case 'snap':
|
|
344
|
+
return this.#chunk(message);
|
|
345
|
+
case 'delta':
|
|
346
|
+
return this.#delta(message);
|
|
347
|
+
case 'no':
|
|
348
|
+
return this.#refused(message.code, message.q);
|
|
349
|
+
case 'ok':
|
|
350
|
+
case 'res':
|
|
351
|
+
// Slice E's correlated replies. Counted, never half-applied.
|
|
352
|
+
return this.#drop('unimplemented-kind');
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* The assembly deadline, and the lease the caller keeps by asking.
|
|
357
|
+
*
|
|
358
|
+
* Returns the requests the elapsed time produced — at most one
|
|
359
|
+
* `resync`, because recovery is coalesced.
|
|
360
|
+
*/
|
|
361
|
+
tick() {
|
|
362
|
+
const now = this.deps.now();
|
|
363
|
+
if (this.#state === 'installing' && this.#assembly !== null) {
|
|
364
|
+
if (!this.#assembly.expired(now))
|
|
365
|
+
return [];
|
|
366
|
+
return this.#abandon('assembly-deadline');
|
|
367
|
+
}
|
|
368
|
+
// A transition that produced NO assembly, past its deadline: the
|
|
369
|
+
// manifest that would have opened one never arrived.
|
|
370
|
+
if (this.#askedAt === null || now - this.#askedAt < ASSEMBLY_DEADLINE_MS)
|
|
371
|
+
return [];
|
|
372
|
+
if (this.#state !== 'joining' && this.#state !== 'installing')
|
|
373
|
+
return [];
|
|
374
|
+
if (this.#reasks >= MAX_JOIN_REASKS) {
|
|
375
|
+
// Asked and re-asked, and nothing answered. The caller gets a
|
|
376
|
+
// terminal answer rather than more silence: `#clearView` is
|
|
377
|
+
// what carries the error to `getStatus()`, and the fence is
|
|
378
|
+
// what makes `ready()` settle.
|
|
379
|
+
this.#askedAt = null;
|
|
380
|
+
this.#state = 'fenced';
|
|
381
|
+
this.#slot = null;
|
|
382
|
+
this.#retireAssembly();
|
|
383
|
+
this.#clearView('failed', new StoreError('timeout',
|
|
384
|
+
// NAMES THE STORE. A mistyped address settles typed, but
|
|
385
|
+
// after the full ladder — and "the store never answered"
|
|
386
|
+
// sends the reader looking at the network when the only
|
|
387
|
+
// evidence of the real cause is a `join-other-store`
|
|
388
|
+
// counter on a host they may not be able to read.
|
|
389
|
+
`no store named ${this.deps.store} answered the join`));
|
|
390
|
+
return [];
|
|
391
|
+
}
|
|
392
|
+
// `join` resets the ladder, because a join is normally the CALLER
|
|
393
|
+
// asking afresh. This one is the deadline's, so the count is
|
|
394
|
+
// carried across it deliberately.
|
|
395
|
+
const spent = this.#reasks;
|
|
396
|
+
const again = this.join();
|
|
397
|
+
this.#reasks = spent + 1;
|
|
398
|
+
return [again];
|
|
399
|
+
}
|
|
400
|
+
/** The caller gives up this subscription. Fences it; no view. */
|
|
401
|
+
cancel() {
|
|
402
|
+
if (this.#state === 'closed')
|
|
403
|
+
return;
|
|
404
|
+
this.#epoch += 1;
|
|
405
|
+
this.#retireAssembly();
|
|
406
|
+
this.#slot = null;
|
|
407
|
+
this.#state = 'fenced';
|
|
408
|
+
// Recovery knowledge from a cancelled epoch must never make the
|
|
409
|
+
// next installation ask for work the caller cancelled. `join`
|
|
410
|
+
// clears it through `#reset`; `setAudience` clears it itself,
|
|
411
|
+
// because it keeps the handle and so does not reset; and `resume`
|
|
412
|
+
// deliberately does NOT, because a reconnect resumes the same
|
|
413
|
+
// subscription and a skip recorded before the session was lost is
|
|
414
|
+
// still news the owner has moved on.
|
|
415
|
+
this.#skipped = null;
|
|
416
|
+
this.#behind = false;
|
|
417
|
+
this.#clearView('failed');
|
|
418
|
+
}
|
|
419
|
+
#manifest(message) {
|
|
420
|
+
const g = decimalValue(message.g);
|
|
421
|
+
// Solicited or not is decided by the slot, and the answer changes
|
|
422
|
+
// which states may admit it.
|
|
423
|
+
const solicited = message.q !== undefined && message.q === this.#slot;
|
|
424
|
+
if (message.q !== undefined && !solicited)
|
|
425
|
+
return this.#drop('stale-correlation');
|
|
426
|
+
// The watermark goes FIRST, before any state-dependent branch. A
|
|
427
|
+
// generation at or below it is spent — a duplicate of the
|
|
428
|
+
// manifest being assembled, or one admitted and then abandoned —
|
|
429
|
+
// and it is neither a skipped refresh nor news of anything. Asking
|
|
430
|
+
// "am I mid-transition?" first reported a duplicate as a skip,
|
|
431
|
+
// which would have provoked a resynchronization for a generation
|
|
432
|
+
// the replica had already been told about.
|
|
433
|
+
if (g <= this.#retired)
|
|
434
|
+
return this.#drop('stale-generation');
|
|
435
|
+
if (!solicited) {
|
|
436
|
+
// An unsolicited refresh. `ready` accepts one; a replica
|
|
437
|
+
// mid-transition records the skip so the owner's newer state is
|
|
438
|
+
// recoverable rather than stranded; a fenced one records
|
|
439
|
+
// nothing, because the fence is the caller's to lift.
|
|
440
|
+
if (this.#state === 'fenced')
|
|
441
|
+
return this.#drop('fenced');
|
|
442
|
+
if (this.#state !== 'ready') {
|
|
443
|
+
this.#skipped = this.#skipped === null ? g : max(this.#skipped, g);
|
|
444
|
+
return this.#drop('mid-transition');
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
else if (this.#state !== 'joining' && this.#state !== 'installing' && this.#state !== 'fenced') {
|
|
448
|
+
return this.#drop('unexpected-manifest');
|
|
449
|
+
}
|
|
450
|
+
if (this.#handle === null) {
|
|
451
|
+
// The handle is LEARNED here, from the manifest that answered the
|
|
452
|
+
// join — the one moment a replica acquires one.
|
|
453
|
+
this.#handle = message.h;
|
|
454
|
+
this.#incarnation = message.inc;
|
|
455
|
+
}
|
|
456
|
+
else if (this.#incarnation !== null && message.inc !== this.#incarnation) {
|
|
457
|
+
// A different incarnation is a different document, not a
|
|
458
|
+
// refresh of this one.
|
|
459
|
+
return this.#drop('foreign-incarnation');
|
|
460
|
+
}
|
|
461
|
+
// The previous assembly goes first: opening the replacement and
|
|
462
|
+
// THEN retiring by handle reclaims the one just opened, and the
|
|
463
|
+
// next chunk arrives at a closed assembly.
|
|
464
|
+
this.#retireAssembly();
|
|
465
|
+
// Admitted, so the watermark moves whatever happens next. A
|
|
466
|
+
// generation this replica could not take must not be reopened by a
|
|
467
|
+
// delayed manifest either.
|
|
468
|
+
this.#retired = g;
|
|
469
|
+
const manifest = {
|
|
470
|
+
h: message.h,
|
|
471
|
+
g: message.g,
|
|
472
|
+
r: message.r,
|
|
473
|
+
n: message.n,
|
|
474
|
+
bytes: message.bytes,
|
|
475
|
+
};
|
|
476
|
+
const opened = this.#assemblies.open(manifest, this.deps.now());
|
|
477
|
+
if (!('accept' in opened)) {
|
|
478
|
+
// An admissible manifest this replica cannot hold — over the
|
|
479
|
+
// byte bound, or the table is full. The generation is consumed,
|
|
480
|
+
// so recovery is a new one, not a retry of this.
|
|
481
|
+
return { out: this.#abandon(opened.reason), dropped: opened.reason };
|
|
482
|
+
}
|
|
483
|
+
this.#assembling = g;
|
|
484
|
+
this.#assembly = opened;
|
|
485
|
+
this.#state = 'installing';
|
|
486
|
+
this.deps.core.setStatus({ phase: 'syncing', stale: this.#published() });
|
|
487
|
+
return { out: [], dropped: null };
|
|
488
|
+
}
|
|
489
|
+
#chunk(message) {
|
|
490
|
+
const assembly = this.#assembly;
|
|
491
|
+
// No open assembly, no chunk: every supersession path — a newer
|
|
492
|
+
// manifest, cancellation, the deadline, a refusal — retires the
|
|
493
|
+
// assembly, so a chunk for a superseded installation arrives here
|
|
494
|
+
// with nothing to join and cannot publish.
|
|
495
|
+
if (assembly === null)
|
|
496
|
+
return this.#drop('no-open-assembly');
|
|
497
|
+
// The chunk's generation is NOT re-checked here. `Assembly.accept`
|
|
498
|
+
// admits a chunk only when its whole `(h, g, r, n)` matches the
|
|
499
|
+
// manifest it was opened with, and that manifest's `g` is what
|
|
500
|
+
// `assembling` holds — so identity is the assembler's property
|
|
501
|
+
// (slice B1) and a second comparison here would be a claim that
|
|
502
|
+
// cannot fail rather than a check that can.
|
|
503
|
+
const epoch = this.#epoch;
|
|
504
|
+
const outcome = assembly.accept({ h: message.h, g: message.g, r: message.r, n: message.n, i: message.i, d: message.d }, raw => this.deps.definition.state(raw), this.deps.now());
|
|
505
|
+
// A cancellation from the VALIDATOR, noticed before anything is
|
|
506
|
+
// published. `#install`'s own epoch check runs only AFTER
|
|
507
|
+
// `applySnapshot` has notified subscribers, so leaving this to it
|
|
508
|
+
// handed every subscriber the cancelled world and then an empty
|
|
509
|
+
// one. That went unobserved for as long as the owner's projection
|
|
510
|
+
// happened to call the shared validator first and spent the
|
|
511
|
+
// cancellation there; once an identity projection stopped
|
|
512
|
+
// re-validating, "stays fenced when the definition cancels while
|
|
513
|
+
// validating" saw `[5, 0]`.
|
|
514
|
+
if (this.#epoch !== epoch)
|
|
515
|
+
return this.#drop('superseded-installation');
|
|
516
|
+
if (!outcome.ok) {
|
|
517
|
+
// A fatal refusal has destroyed the assembly; a non-fatal one
|
|
518
|
+
// (a duplicate) leaves it open and is only counted.
|
|
519
|
+
if (!outcome.fatal)
|
|
520
|
+
return this.#drop(outcome.reason);
|
|
521
|
+
return { out: this.#abandon(outcome.reason), dropped: outcome.reason };
|
|
522
|
+
}
|
|
523
|
+
if (!outcome.done)
|
|
524
|
+
return { out: [], dropped: null };
|
|
525
|
+
return this.#install(outcome.document, message.g, message.r, epoch);
|
|
526
|
+
}
|
|
527
|
+
#install(document, g, r, epoch) {
|
|
528
|
+
// Reached only from a completed assembly, which the assembler
|
|
529
|
+
// opened for this generation and which every supersession path
|
|
530
|
+
// retires — so `g` is `assembling` by construction and there is
|
|
531
|
+
// nothing left here to fence against. The fence is the retirement,
|
|
532
|
+
// witnessed on each path that supersedes ("publishes nothing after
|
|
533
|
+
// cancellation mid-assembly"), not a comparison at the end.
|
|
534
|
+
this.deps.core.applySnapshot(document);
|
|
535
|
+
this.#assembling = null;
|
|
536
|
+
this.#assembly = null;
|
|
537
|
+
this.#assemblies.reclaim(this.#handle, g);
|
|
538
|
+
if (this.#epoch !== epoch) {
|
|
539
|
+
// A SUBSCRIBER cancelled while this document was being
|
|
540
|
+
// published. Its `cancel` already cleared the view; saying
|
|
541
|
+
// `ready` over that is the worst of both — an empty world
|
|
542
|
+
// reported as current.
|
|
543
|
+
this.#installed = null;
|
|
544
|
+
this.#revision = null;
|
|
545
|
+
this.#clearView('failed');
|
|
546
|
+
return this.#drop('superseded-installation');
|
|
547
|
+
}
|
|
548
|
+
this.#installed = decimalValue(g);
|
|
549
|
+
this.#revision = decimalValue(r);
|
|
550
|
+
this.#slot = null;
|
|
551
|
+
this.#state = 'ready';
|
|
552
|
+
this.deps.core.setStatus({ phase: 'ready', stale: false, error: null });
|
|
553
|
+
// Then §1.8's skipped-refresh recovery: a refresh dropped while
|
|
554
|
+
// mid-transition would otherwise make every later delta
|
|
555
|
+
// inadmissible, and owner and replica would never converge. One
|
|
556
|
+
// coalesced request, however many were dropped.
|
|
557
|
+
return { out: this.#recoverIfBehind(), dropped: null };
|
|
558
|
+
}
|
|
559
|
+
#delta(message) {
|
|
560
|
+
if (this.#state !== 'ready') {
|
|
561
|
+
// Mid-transition the owner has moved past the revision being
|
|
562
|
+
// assembled. One flag, coalesced into the same recovery.
|
|
563
|
+
this.#behind = true;
|
|
564
|
+
return this.#drop('not-ready');
|
|
565
|
+
}
|
|
566
|
+
const g = decimalValue(message.g);
|
|
567
|
+
const installed = this.#installed;
|
|
568
|
+
if (installed === null || g !== installed) {
|
|
569
|
+
// A delta for a generation this replica never installed. BELOW the
|
|
570
|
+
// installed one is a genuinely stale frame and is dropped. ABOVE it
|
|
571
|
+
// means the owner-initiated manifest that would have installed that
|
|
572
|
+
// generation never arrived — the lost-datagram case §1.8 names, and
|
|
573
|
+
// the owner's own overflow fallback emits exactly such a manifest —
|
|
574
|
+
// and every later delta carries the same new `g`. Dropping without
|
|
575
|
+
// asking strands this replica at the old generation for good while
|
|
576
|
+
// `getStatus()` keeps reporting `ready`, because `tick`'s ladder runs
|
|
577
|
+
// only in `joining`/`installing` and this check fires before the
|
|
578
|
+
// `base` gap check below that would otherwise have asked.
|
|
579
|
+
if (installed !== null && g > installed) {
|
|
580
|
+
return { out: this.#resync('generation-ahead'), dropped: 'generation-ahead' };
|
|
581
|
+
}
|
|
582
|
+
return this.#drop('stale-generation');
|
|
583
|
+
}
|
|
584
|
+
if (this.#revision === null || decimalValue(message.base) !== this.#revision) {
|
|
585
|
+
// A gap. The delta is not applied to the wrong base — that would
|
|
586
|
+
// publish a document neither side believes in.
|
|
587
|
+
return { out: this.#resync('gap'), dropped: 'gap' };
|
|
588
|
+
}
|
|
589
|
+
const outcome = this.deps.core.applyDelta(message.ops);
|
|
590
|
+
if (!outcome.ok) {
|
|
591
|
+
// A patch that does not apply or does not validate is a
|
|
592
|
+
// divergence, so the view is refetched rather than guessed at.
|
|
593
|
+
return { out: this.#resync(outcome.reason), dropped: outcome.reason };
|
|
594
|
+
}
|
|
595
|
+
this.#revision = decimalValue(message.r);
|
|
596
|
+
return { out: [], dropped: null };
|
|
597
|
+
}
|
|
598
|
+
#refused(code, q) {
|
|
599
|
+
if (code === 'owner-lost') {
|
|
600
|
+
// Terminal: the incarnation that held the document is gone, so
|
|
601
|
+
// there is nothing to rejoin.
|
|
602
|
+
this.#retireAssembly();
|
|
603
|
+
this.#state = 'closed';
|
|
604
|
+
this.#handle = null;
|
|
605
|
+
this.#clearView('closed');
|
|
606
|
+
return { out: [], dropped: 'owner-lost' };
|
|
607
|
+
}
|
|
608
|
+
if (code === 'closed') {
|
|
609
|
+
// The handle is dead, and this is handle news however it was
|
|
610
|
+
// provoked. `errors.ts` defines `closed` as terminal for the handle
|
|
611
|
+
// AND for any action in flight on it, and `receive`'s foreign-handle
|
|
612
|
+
// gate has already discarded any `closed` naming a different handle —
|
|
613
|
+
// so one reaching here is always about THIS handle. The
|
|
614
|
+
// `q`-of-a-dead-question guard below protects refusals that answer a
|
|
615
|
+
// request; it cannot apply here, because every owner-side handle-death
|
|
616
|
+
// answer carries the provoking request's `q` (`owner.ts` answers an
|
|
617
|
+
// `alive` or `act` on a swept handle with `no {q: <that request>, …,
|
|
618
|
+
// closed}`) and a 20 s keepalive's `q` is never the live slot.
|
|
619
|
+
// Dropping that as `stale-correlation` holds a dead handle forever:
|
|
620
|
+
// every later `alive` is answered the same way and counted the same
|
|
621
|
+
// way, the view stays published as `ready`, and no rejoin ever runs.
|
|
622
|
+
const wasFenced = this.#state === 'fenced';
|
|
623
|
+
this.#reset();
|
|
624
|
+
if (wasFenced) {
|
|
625
|
+
// An expiry notice is no more the caller's consent than an
|
|
626
|
+
// owner refresh was: the fence stays.
|
|
627
|
+
this.#clearView('failed');
|
|
628
|
+
return { out: [], dropped: 'closed-while-fenced' };
|
|
629
|
+
}
|
|
630
|
+
this.#clearView('connecting');
|
|
631
|
+
return { out: [this.join()], dropped: 'closed' };
|
|
632
|
+
}
|
|
633
|
+
if (q === undefined || q !== this.#slot)
|
|
634
|
+
return this.#drop('stale-correlation');
|
|
635
|
+
// The REQUEST was refused, not the subscription: the handle
|
|
636
|
+
// survives, and the caller's next request lifts the fence.
|
|
637
|
+
this.#retireAssembly();
|
|
638
|
+
this.#slot = null;
|
|
639
|
+
this.#state = 'fenced';
|
|
640
|
+
this.#clearView('failed', new StoreError(code, `the request was refused: ${code}`));
|
|
641
|
+
return { out: [], dropped: `refused-${code}` };
|
|
642
|
+
}
|
|
643
|
+
/** Abandon the open assembly and ask for the view again. */
|
|
644
|
+
#abandon(reason) {
|
|
645
|
+
this.#retireAssembly();
|
|
646
|
+
// `retired` KEEPS the generation: the abandoned one must not be
|
|
647
|
+
// reopened by a delayed manifest.
|
|
648
|
+
this.#assembling = null;
|
|
649
|
+
this.#state = 'installing';
|
|
650
|
+
this.deps.core.setStatus({ phase: 'syncing', stale: this.#published() });
|
|
651
|
+
return this.#resync(reason);
|
|
652
|
+
}
|
|
653
|
+
#resync(_reason) {
|
|
654
|
+
const h = this.#handle;
|
|
655
|
+
if (h === null)
|
|
656
|
+
return [];
|
|
657
|
+
const q = this.deps.newQ();
|
|
658
|
+
this.#slot = q;
|
|
659
|
+
this.#askedAt = this.deps.now();
|
|
660
|
+
this.#state = 'installing';
|
|
661
|
+
this.#skipped = null;
|
|
662
|
+
this.#behind = false;
|
|
663
|
+
// The published view is known to be behind — that is why this is
|
|
664
|
+
// being sent. Retained, because the audience has not changed, but
|
|
665
|
+
// NOT current, and the application is told so: reporting `ready`
|
|
666
|
+
// here means every consumer believes a stale world is live.
|
|
667
|
+
this.deps.core.setStatus({
|
|
668
|
+
phase: this.#published() ? 'reconnecting' : 'syncing',
|
|
669
|
+
stale: this.#published(),
|
|
670
|
+
});
|
|
671
|
+
// `(g, have)` are ADVISORY historical position — what this replica
|
|
672
|
+
// actually has, never a claim about the owner's current
|
|
673
|
+
// generation. A replica installed at A whose replacement B timed
|
|
674
|
+
// out can only honestly name A, and that is exactly the replica
|
|
675
|
+
// that most needs recovering (§1.8).
|
|
676
|
+
return [
|
|
677
|
+
{
|
|
678
|
+
kind: 'resync',
|
|
679
|
+
q,
|
|
680
|
+
frame: encodeMessage({
|
|
681
|
+
k: 'resync',
|
|
682
|
+
q,
|
|
683
|
+
h,
|
|
684
|
+
g: this.#installed === null ? '0' : this.#installed.toString(),
|
|
685
|
+
have: this.#revision === null ? '0' : this.#revision.toString(),
|
|
686
|
+
}),
|
|
687
|
+
},
|
|
688
|
+
];
|
|
689
|
+
}
|
|
690
|
+
#recoverIfBehind() {
|
|
691
|
+
const skipped = this.#skipped;
|
|
692
|
+
const behind = this.#behind;
|
|
693
|
+
if (!behind && (skipped === null || this.#installed === null || skipped <= this.#installed)) {
|
|
694
|
+
this.#skipped = null;
|
|
695
|
+
this.#behind = false;
|
|
696
|
+
return [];
|
|
697
|
+
}
|
|
698
|
+
return this.#resync(behind ? 'behind' : 'skipped-refresh');
|
|
699
|
+
}
|
|
700
|
+
/** Discard everything scoped to one handle's subscription. */
|
|
701
|
+
#reset() {
|
|
702
|
+
this.#retireAssembly();
|
|
703
|
+
this.#handle = null;
|
|
704
|
+
this.#incarnation = null;
|
|
705
|
+
this.#installed = null;
|
|
706
|
+
this.#revision = null;
|
|
707
|
+
this.#retired = 0n;
|
|
708
|
+
this.#skipped = null;
|
|
709
|
+
this.#behind = false;
|
|
710
|
+
this.#slot = null;
|
|
711
|
+
}
|
|
712
|
+
#retireAssembly() {
|
|
713
|
+
if (this.#assembly !== null) {
|
|
714
|
+
this.#assembly.reclaim();
|
|
715
|
+
this.#assembly = null;
|
|
716
|
+
}
|
|
717
|
+
if (this.#handle !== null)
|
|
718
|
+
this.#assemblies.reclaimHandle(this.#handle);
|
|
719
|
+
this.#assembling = null;
|
|
720
|
+
}
|
|
721
|
+
#published() {
|
|
722
|
+
return this.#installed !== null;
|
|
723
|
+
}
|
|
724
|
+
#clearView(phase, error = null) {
|
|
725
|
+
this.#installed = null;
|
|
726
|
+
this.#revision = null;
|
|
727
|
+
this.deps.core.applySnapshot(this.deps.definition.empty());
|
|
728
|
+
this.deps.core.setStatus({ phase, stale: false, error });
|
|
729
|
+
}
|
|
730
|
+
#drop(reason) {
|
|
731
|
+
this.#dropped[reason] = (this.#dropped[reason] ?? 0) + 1;
|
|
732
|
+
return { out: [], dropped: reason };
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
function max(a, b) {
|
|
736
|
+
return a > b ? a : b;
|
|
737
|
+
}
|
|
738
|
+
function sameAudience(a, b) {
|
|
739
|
+
return a.length === b.length && a.every((label, index) => label === b[index]);
|
|
740
|
+
}
|