@earendil-works/chord 1.2.0 → 1.4.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.
Files changed (122) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +69 -14
  3. package/dist/cell.d.ts +30 -9
  4. package/dist/cell.d.ts.map +1 -1
  5. package/dist/cell.js +52 -12
  6. package/dist/cell.js.map +1 -1
  7. package/dist/cluster.d.ts +4 -3
  8. package/dist/cluster.d.ts.map +1 -1
  9. package/dist/cluster.js +58 -42
  10. package/dist/cluster.js.map +1 -1
  11. package/dist/connection.d.ts +171 -58
  12. package/dist/connection.d.ts.map +1 -1
  13. package/dist/connection.js +676 -217
  14. package/dist/connection.js.map +1 -1
  15. package/dist/context/index.d.ts +8 -0
  16. package/dist/context/index.d.ts.map +1 -1
  17. package/dist/context/index.js +15 -0
  18. package/dist/context/index.js.map +1 -1
  19. package/dist/contract.d.ts +120 -45
  20. package/dist/contract.d.ts.map +1 -1
  21. package/dist/contract.js +104 -15
  22. package/dist/contract.js.map +1 -1
  23. package/dist/declare.d.ts +11 -48
  24. package/dist/declare.d.ts.map +1 -1
  25. package/dist/declare.js +1 -21
  26. package/dist/declare.js.map +1 -1
  27. package/dist/delta/index.d.ts +2 -1
  28. package/dist/delta/index.d.ts.map +1 -1
  29. package/dist/delta/index.js +4 -0
  30. package/dist/delta/index.js.map +1 -1
  31. package/dist/errors.d.ts +4 -3
  32. package/dist/errors.d.ts.map +1 -1
  33. package/dist/errors.js +15 -0
  34. package/dist/errors.js.map +1 -1
  35. package/dist/host.d.ts +214 -120
  36. package/dist/host.d.ts.map +1 -1
  37. package/dist/host.js +446 -241
  38. package/dist/host.js.map +1 -1
  39. package/dist/index.d.ts +8 -8
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +4 -4
  42. package/dist/index.js.map +1 -1
  43. package/dist/link.d.ts +47 -12
  44. package/dist/link.d.ts.map +1 -1
  45. package/dist/link.js +200 -14
  46. package/dist/link.js.map +1 -1
  47. package/dist/loader.d.ts.map +1 -1
  48. package/dist/loader.js +7 -2
  49. package/dist/loader.js.map +1 -1
  50. package/dist/manager.d.ts.map +1 -1
  51. package/dist/manager.js +35 -9
  52. package/dist/manager.js.map +1 -1
  53. package/dist/object.d.ts +438 -0
  54. package/dist/object.d.ts.map +1 -0
  55. package/dist/{record.js → object.js} +614 -335
  56. package/dist/object.js.map +1 -0
  57. package/dist/output.d.ts +91 -0
  58. package/dist/output.d.ts.map +1 -0
  59. package/dist/output.js +360 -0
  60. package/dist/output.js.map +1 -0
  61. package/dist/registry.d.ts +3 -3
  62. package/dist/registry.d.ts.map +1 -1
  63. package/dist/registry.js +1 -1
  64. package/dist/registry.js.map +1 -1
  65. package/dist/reload.d.ts +35 -27
  66. package/dist/reload.d.ts.map +1 -1
  67. package/dist/reload.js +256 -192
  68. package/dist/reload.js.map +1 -1
  69. package/dist/remote.d.ts +130 -35
  70. package/dist/remote.d.ts.map +1 -1
  71. package/dist/remote.js +493 -189
  72. package/dist/remote.js.map +1 -1
  73. package/dist/sqlite/cloudflare.d.ts +52 -0
  74. package/dist/sqlite/cloudflare.d.ts.map +1 -0
  75. package/dist/sqlite/cloudflare.js +134 -0
  76. package/dist/sqlite/cloudflare.js.map +1 -0
  77. package/dist/sqlite/database.d.ts +37 -0
  78. package/dist/sqlite/database.d.ts.map +1 -0
  79. package/dist/sqlite/database.js +2 -0
  80. package/dist/sqlite/database.js.map +1 -0
  81. package/dist/sqlite/index.d.ts +4 -0
  82. package/dist/sqlite/index.d.ts.map +1 -0
  83. package/dist/sqlite/index.js +3 -0
  84. package/dist/sqlite/index.js.map +1 -0
  85. package/dist/sqlite/migrations.d.ts +10 -0
  86. package/dist/sqlite/migrations.d.ts.map +1 -0
  87. package/dist/sqlite/migrations.js +33 -0
  88. package/dist/sqlite/migrations.js.map +1 -0
  89. package/dist/sqlite/node.d.ts +38 -0
  90. package/dist/sqlite/node.d.ts.map +1 -0
  91. package/dist/sqlite/node.js +174 -0
  92. package/dist/sqlite/node.js.map +1 -0
  93. package/dist/sqlite/schema.d.ts +3 -0
  94. package/dist/sqlite/schema.d.ts.map +1 -0
  95. package/dist/sqlite/schema.js +10 -0
  96. package/dist/sqlite/schema.js.map +1 -0
  97. package/dist/telemetry-sqlite/index.d.ts +24 -0
  98. package/dist/telemetry-sqlite/index.d.ts.map +1 -0
  99. package/dist/telemetry-sqlite/index.js +273 -0
  100. package/dist/telemetry-sqlite/index.js.map +1 -0
  101. package/dist/telemetry-sqlite/store.d.ts +9 -0
  102. package/dist/telemetry-sqlite/store.d.ts.map +1 -0
  103. package/dist/telemetry-sqlite/store.js +110 -0
  104. package/dist/telemetry-sqlite/store.js.map +1 -0
  105. package/dist/types.d.ts +39 -28
  106. package/dist/types.d.ts.map +1 -1
  107. package/dist/types.js.map +1 -1
  108. package/dist/websocket.d.ts +4 -5
  109. package/dist/websocket.d.ts.map +1 -1
  110. package/dist/websocket.js +3 -2
  111. package/dist/websocket.js.map +1 -1
  112. package/dist/wire.d.ts +210 -129
  113. package/dist/wire.d.ts.map +1 -1
  114. package/dist/wire.js +108 -55
  115. package/dist/wire.js.map +1 -1
  116. package/docs/guide.md +267 -203
  117. package/docs/spec.md +141 -114
  118. package/docs/tutorial.md +257 -235
  119. package/package.json +27 -1
  120. package/dist/record.d.ts +0 -320
  121. package/dist/record.d.ts.map +0 -1
  122. package/dist/record.js.map +0 -1
@@ -1,39 +1,54 @@
1
- import { contractFromDescription, LOCAL_VARIANT } from "./contract.js";
1
+ import { contractFromDescription, LOCAL_VARIANT, resolveHost } from "./contract.js";
2
2
  import { encoder } from "./delta/index.js";
3
3
  import { ChordError, invalid } from "./errors.js";
4
- import { HandleImpl, kindOf } from "./record.js";
4
+ import { Packed, unpack } from "./link.js";
5
+ import { RefImpl, variantOf } from "./object.js";
5
6
  import { assertValid } from "./schema.js";
6
- import { fromWireError, isFrame, toWireError } from "./wire.js";
7
+ import { fromWireError, isFrame, PROTOCOL_VERSION, toWireError } from "./wire.js";
8
+ const DEFAULT_WINDOW = 256 * 1024;
9
+ /** The default cap of waiting state ops per link, in windows. */
10
+ const PENDING_WINDOWS = 4;
7
11
  const unavailable = (message) => new ChordError("unavailable", message);
8
12
  // ─── relay ───────────────────────────────────────────────────────────────────
9
13
  /**
10
- * A hold relayed from an origin link to a downstream link. It owns everything done through it: subscriptions and
11
- * calls queued while the route resolves, and the downstream twins once forwarded. `close` undoes all of it exactly
12
- * once (cancels calls downstream, drops subscriptions, releases the downstream hold, rejects waiters).
14
+ * A hold relayed from an origin link to a downstream link. It owns everything done through it: state and calls queued
15
+ * while the route resolves, and the downstream twins once forwarded. `close` undoes all of it exactly once (cancels
16
+ * calls downstream, drops what waits for the origin, releases the downstream hold, rejects waiters).
13
17
  */
14
- export class RelayHold {
18
+ export class RelayRef {
15
19
  origin;
16
20
  frame;
17
21
  principal;
18
- client;
22
+ /** The client connection the hold comes from (forwarded downstream); undefined for a host's own holds. */
23
+ originTag;
19
24
  /** The origin's authorization generation when the hold arrived; a revocation bumps it. */
20
25
  generation;
21
26
  down;
22
27
  downId = 0;
23
28
  closed = false;
29
+ /** The origin wants state (forwarded as `state: false`, `sub`, `unsub`). */
30
+ stateWanted;
31
+ /** The members of the first unit, still to forward past the origin's window. */
32
+ urgent;
33
+ #shaped = false;
34
+ /** Members whose ops this relay dropped: downstream ops are ignored until their reset arrives. */
35
+ awaiting = new Set();
24
36
  calls = new Map();
25
- subs = new Map();
26
37
  #waiters = new Set();
27
- constructor(origin, frame, principal, client) {
38
+ constructor(origin, frame, principal, originTag) {
28
39
  this.origin = origin;
29
40
  this.frame = frame;
30
41
  this.principal = principal;
31
- this.client = client;
42
+ this.originTag = originTag;
43
+ this.stateWanted = frame.state !== false;
32
44
  this.generation = origin.generation;
33
45
  }
34
46
  get originId() {
35
47
  return this.frame.id;
36
48
  }
49
+ get client() {
50
+ return this.originTag !== undefined;
51
+ }
37
52
  /** Still authorized as it was when it arrived. */
38
53
  get current() {
39
54
  return !this.closed && this.origin.open && this.origin.generation === this.generation;
@@ -48,50 +63,77 @@ export class RelayHold {
48
63
  this.#waiters.add({ resolve: resolve, reject });
49
64
  });
50
65
  }
51
- /** Forwards the hold (and what queued meanwhile) to `down`. */
66
+ /** Forwards the hold to `down`. */
52
67
  open(down) {
53
68
  if (this.closed)
54
69
  return;
55
70
  this.down = down;
56
71
  this.downId = down.nextId();
57
- down.relayedHolds.set(this.downId, this);
72
+ down.relayedRefs.set(this.downId, this);
73
+ const { at, token, input } = this.frame;
58
74
  down.send({
59
- ...this.frame,
75
+ t: "hold",
60
76
  id: this.downId,
77
+ at,
78
+ token,
79
+ input,
61
80
  principal: this.principal,
62
- ...(this.client ? { client: true } : {}),
81
+ ...(this.originTag === undefined ? {} : { origin: this.originTag }),
82
+ ...(this.stateWanted ? {} : { state: false }),
83
+ ...(this.frame.again === true ? { again: true } : {}),
63
84
  });
64
- for (const [originSubId, entry] of this.subs)
65
- if (entry.downId === undefined)
66
- this.#forwardSub(originSubId, entry);
67
85
  for (const waiter of this.#waiters)
68
86
  waiter.resolve(down);
69
87
  this.#waiters.clear();
70
88
  }
71
- subscribe(originSubId, member) {
72
- const entry = { member, downId: undefined };
73
- this.subs.set(originSubId, entry);
74
- this.origin.relaySubs.set(originSubId, this);
75
- if (this.down !== undefined)
76
- this.#forwardSub(originSubId, entry);
89
+ /** The origin starts or stops the hold's state. */
90
+ wantState(wanted) {
91
+ if (this.closed || wanted === this.stateWanted)
92
+ return;
93
+ this.stateWanted = wanted;
94
+ if (!wanted) {
95
+ this.origin.dropHoldState(this.originId);
96
+ this.awaiting.clear();
97
+ }
98
+ this.down?.send({ t: wanted ? "sub" : "unsub", hold: this.downId });
77
99
  }
78
- #forwardSub(originSubId, entry) {
79
- const down = this.down;
80
- entry.downId = down.nextId();
81
- down.relayedSubs.set(entry.downId, { relay: this, originSubId });
82
- down.send({ t: "sub", id: entry.downId, hold: this.downId, member: entry.member });
100
+ /**
101
+ * The origin could not keep up and dropped what waited for member `m` (or asked for it again): ask downstream for a
102
+ * reset, and ignore its ops until the reset arrives.
103
+ */
104
+ resync(m) {
105
+ if (this.closed)
106
+ return;
107
+ this.awaiting.add(m);
108
+ this.down?.send({ t: "resync", hold: this.downId, m });
83
109
  }
84
- unsubscribe(originSubId, forward = true) {
85
- const entry = this.subs.get(originSubId);
86
- if (entry === undefined)
110
+ /** A shape from downstream: forwarded at once; the members of the first unit then skip the origin's window. */
111
+ shape(frame) {
112
+ if (this.closed)
87
113
  return;
88
- this.subs.delete(originSubId);
89
- this.origin.relaySubs.delete(originSubId);
90
- if (entry.downId !== undefined && this.down !== undefined) {
91
- this.down.relayedSubs.delete(entry.downId);
92
- if (forward)
93
- this.down.send({ t: "unsub", id: entry.downId });
114
+ if (!this.#shaped) {
115
+ this.#shaped = true;
116
+ if (this.frame.again !== true)
117
+ this.urgent = new Set(frame.state);
94
118
  }
119
+ this.origin.send({ ...frame, id: this.originId });
120
+ }
121
+ /** A state frame from downstream. */
122
+ state(frame) {
123
+ if (this.closed)
124
+ return;
125
+ const m = frame.m;
126
+ if (this.awaiting.has(m)) {
127
+ if (frame.as === "ops")
128
+ return;
129
+ this.awaiting.delete(m);
130
+ }
131
+ const out = { ...frame, hold: this.originId };
132
+ if (this.urgent?.delete(m) === true) {
133
+ this.origin.send(out);
134
+ return;
135
+ }
136
+ this.origin.sendState(out, { resync: () => this.resync(m) });
95
137
  }
96
138
  /**
97
139
  * Closes the relay. `releaseDown`: the downstream hold is still there (origin released, disconnected, or was
@@ -103,12 +145,11 @@ export class RelayHold {
103
145
  this.closed = true;
104
146
  for (const call of [...this.calls.values()])
105
147
  call.cancel(error, options.releaseDown);
106
- for (const originSubId of [...this.subs.keys()])
107
- this.unsubscribe(originSubId, options.releaseDown);
148
+ this.origin.dropHoldState(this.originId);
108
149
  if (this.origin.served.get(this.originId)?.kind === "relay")
109
150
  this.origin.served.delete(this.originId);
110
151
  if (this.down !== undefined) {
111
- this.down.relayedHolds.delete(this.downId);
152
+ this.down.relayedRefs.delete(this.downId);
112
153
  if (options.releaseDown)
113
154
  this.down.send({ t: "release", id: this.downId });
114
155
  }
@@ -155,44 +196,97 @@ export class RelayCall {
155
196
  }
156
197
  }
157
198
  // ─── connection ──────────────────────────────────────────────────────────────
158
- /** One end of a link on a host: consumer of remote handles, provider for the peer's holds, and relay. */
199
+ /**
200
+ * One end of a link on a host: consumer of remote references, provider for the peer's holds, and relay.
201
+ *
202
+ * State
203
+ * -----
204
+ *
205
+ * A hold implies the state of its variant (unless it says `state: false`). The provider answers with a unit: the shape,
206
+ * then a snapshot of each state member the consumer does not have yet (`shape.state` lists them), then the members'
207
+ * ops and resets. A reload or a variant change sends another unit with the members that are new; members that left the
208
+ * variant stop. The consumer applies a shape once its snapshots are here (`RemoteBinding`).
209
+ *
210
+ * Flow control
211
+ * ------------
212
+ *
213
+ * Problem: state is pushed as fast as it changes, but a peer may read slowly (a phone behind a relay), and a relay may
214
+ * hide that: Cloudflare forwards to browsers without backpressure. Everything sent then queues in front of the next
215
+ * call result. Example: a browser opens three long transcripts (500 KiB of snapshots each) over a 5 Mbit/s path; a
216
+ * result sent next waits 2.4 s, and the snapshot of a transcript the user already left is still sent in full.
217
+ *
218
+ * Solution: the peer acknowledges what it processed (`ack`, a cumulative frame count), and the sender counts the sizes
219
+ * of the frames not yet acknowledged. While they exceed the window, state waits here in slots, flushed in creation
220
+ * order as acknowledgements come:
221
+ * - a member's stream has one slot: ops are appended (encoded batches of one stream may be concatenated, see
222
+ * `encoder`), a snapshot, reset or error replaces it;
223
+ * - a hold's unit has one slot, built lazily from the current values when it is sent, so a newer unit merges into it;
224
+ * while it waits, the members it introduces have no stream yet.
225
+ *
226
+ * The first unit of a hold that is not a re-hold (`again`) is sent at once, past the window: a `get` waits for it, and
227
+ * must not wait behind other holds' state. Results, shapes, gone, reload control and pings are sent at once too: they
228
+ * are bounded by the requests the peer makes. Hence the one ordering exception of a link: while state waits, a result
229
+ * can arrive before state changes made before it. Frames of one stream stay in order.
230
+ *
231
+ * Waiting ops are capped per link (`pendingCap`): beyond, the oldest streams are replaced by their current value (a
232
+ * reset built at flush), or, on a relay, by a reset asked of the provider (`resync`), so memory stays bounded however
233
+ * long the peer stalls. The window bounds what is unacknowledged when a frame is sent, so one frame (a large snapshot)
234
+ * may exceed it. Both hellos must announce `acks`; with an older peer nothing is acknowledged and nothing waits. Links
235
+ * that do not report sizes (IPC) never fill the window.
236
+ */
159
237
  export class Connection {
160
238
  host;
161
239
  link;
162
240
  /** `upstream`: the link to this host's parent. `downstream`: a link this host accepted. */
163
241
  role;
164
242
  peer;
243
+ /** A client connection's opaque id: holds that come over it (directly or relayed) carry it as their `origin`. */
244
+ originId;
165
245
  principal;
166
246
  restamp;
167
247
  /** Bumped when the stamped principal is revoked or re-stamped: relays from before are refused. */
168
248
  generation = 0;
169
- /** The epoch a downstream host peer runs (from its hello, then from commits). */
170
- peerEpoch;
171
- /** A downstream host peer is usable once it runs this host's current epoch (see `HostImpl.peerHello`). */
249
+ /** The code version a downstream host peer runs (from its hello, then from commits). */
250
+ peerCodeVersion;
251
+ /** A downstream host peer is usable once it runs this host's current code version (see `HostImpl.peerHello`). */
172
252
  synced;
173
- /** Epochs this child announced while behind this host (fenced): this host never ran them. */
174
- fencedEpochs = new Set();
175
- /** Roots of fenced epochs, by the epoch of this host they were admitted in. */
253
+ /** Code versions this child announced while behind this host (fenced): this host never ran them. */
254
+ fencedCodeVersions = new Set();
255
+ /** Roots of fenced code versions, by the code version of this host they were admitted in. */
176
256
  #admitted = new Map();
177
257
  #parentName;
178
258
  #syncWaiters = new Set();
179
259
  #nextId = 1;
180
260
  #lastSeen = Date.now();
181
261
  #pinger;
262
+ #maxCalls;
263
+ // flow control (see the class comment)
264
+ #window;
265
+ #pendingCap;
266
+ /** Whether the peer acknowledges frames; until its hello is here, frames are counted in case it does. */
267
+ #peerAcks;
268
+ #sent = 0;
269
+ #acked = 0;
270
+ /** Sizes of the frames sent after the last acknowledged one. */
271
+ #unacked = [];
272
+ #unackedBytes = 0;
273
+ #pending = new Map();
274
+ /** Bytes of ops waiting in `#pending`. */
275
+ #pendingBytes = 0;
276
+ #received = 0;
277
+ #ackTimer;
278
+ #onProtocolMismatch;
279
+ /** The peer speaks another protocol version: nothing it sends is processed any more. */
280
+ #mismatched = false;
182
281
  // consumer side: what this host asked of the peer
183
282
  remotes = new Map();
184
283
  calls = new Map();
185
- subs = new Map();
186
284
  // provider side: what the peer asked of this host
187
285
  served = new Map();
188
286
  incoming = new Map();
189
- feeds = new Map();
190
- /** Origin side of relays: the peer's sub ids that are relayed. */
191
- relaySubs = new Map();
192
287
  // downstream side of relays: ids this host used on this link
193
- relayedHolds = new Map();
288
+ relayedRefs = new Map();
194
289
  relayedCalls = new Map();
195
- relayedSubs = new Map();
196
290
  /** Reload votes awaited from this (child) peer. */
197
291
  votes = new Map();
198
292
  constructor(host, link, config) {
@@ -200,14 +294,29 @@ export class Connection {
200
294
  this.link = link;
201
295
  this.role = config.role;
202
296
  this.peer = config.peer;
297
+ this.originId = config.originId;
203
298
  this.principal = config.principal;
204
299
  this.restamp = config.restamp;
205
300
  this.synced = !(this.role === "downstream" && this.peer.kind === "host");
301
+ this.#window = config.window ?? DEFAULT_WINDOW;
302
+ this.#pendingCap = config.pendingCap ?? this.#window * PENDING_WINDOWS;
303
+ this.#maxCalls = config.maxCalls ?? Number.POSITIVE_INFINITY;
304
+ this.#onProtocolMismatch = config.onProtocolMismatch;
206
305
  link.onClose((reason) => this.#closed(reason));
207
306
  link.receive((frame) => this.#receive(frame));
208
- this.send({ t: "hello", name: host.name, kind: host.kind, epoch: host.current.id });
307
+ this.send({
308
+ t: "hello",
309
+ protocol: PROTOCOL_VERSION,
310
+ name: host.name,
311
+ kind: host.kind,
312
+ codeVersion: host.current.id,
313
+ acks: true,
314
+ });
209
315
  const interval = config.pingInterval ?? 15_000;
210
316
  const timeout = config.pingTimeout ?? 45_000;
317
+ // A link that was already closed closed this connection above; it needs no keepalive.
318
+ if (link.closed)
319
+ return;
211
320
  let n = 0;
212
321
  this.#pinger = setInterval(() => {
213
322
  if (Date.now() - this.#lastSeen > timeout)
@@ -225,12 +334,178 @@ export class Connection {
225
334
  return this.role === "upstream" ? this.#parentName : this.peer.name;
226
335
  }
227
336
  send(frame) {
228
- this.link.send(frame);
337
+ const size = this.link.send(frame) ?? 0;
338
+ if (this.#peerAcks === false)
339
+ return;
340
+ this.#sent++;
341
+ this.#unacked.push(size);
342
+ this.#unackedBytes += size;
343
+ }
344
+ /** Sends a state frame, or keeps it waiting (see the class comment). */
345
+ sendState(frame, overflow) {
346
+ const key = stateKey(frame.hold, frame.m);
347
+ let slot = this.#pending.get(key);
348
+ if (slot === undefined && !this.#full) {
349
+ this.send(frame);
350
+ return;
351
+ }
352
+ if (slot?.kind === "unit")
353
+ return;
354
+ if (slot === undefined) {
355
+ slot = {
356
+ kind: "state",
357
+ hold: frame.hold,
358
+ m: frame.m,
359
+ base: undefined,
360
+ ops: [],
361
+ size: 0,
362
+ lazy: false,
363
+ overflow,
364
+ };
365
+ this.#pending.set(key, slot);
366
+ }
367
+ if (frame.as !== "ops") {
368
+ this.#pendingBytes -= slot.size;
369
+ slot.base = frame;
370
+ slot.ops = [];
371
+ slot.size = 0;
372
+ slot.lazy = false;
373
+ return;
374
+ }
375
+ // A reset built at flush already covers these ops.
376
+ if (slot.lazy)
377
+ return;
378
+ const size = JSON.stringify(frame.ops).length;
379
+ if (this.#pendingBytes + size > this.#pendingCap)
380
+ this.#makeRoom(size, slot);
381
+ if (slot.lazy || this.#pending.get(key) !== slot)
382
+ return;
383
+ for (const op of frame.ops ?? [])
384
+ slot.ops.push(op);
385
+ slot.size += size;
386
+ this.#pendingBytes += size;
387
+ }
388
+ /** Replaces the oldest waiting streams by resets until `size` more bytes fit; `current` last, if needed. */
389
+ #makeRoom(size, current) {
390
+ for (const slot of [...this.#pending.values()]) {
391
+ if (this.#pendingBytes + size <= this.#pendingCap)
392
+ return;
393
+ if (slot.kind === "state" && slot !== current && slot.size > 0)
394
+ this.#drop(slot);
395
+ }
396
+ if (this.#pendingBytes + size > this.#pendingCap)
397
+ this.#drop(current);
398
+ }
399
+ /** Drops what waits for a stream: a lazy reset replaces it (provider), or the relay asks downstream for one. */
400
+ #drop(slot) {
401
+ this.#pendingBytes -= slot.size;
402
+ slot.base = undefined;
403
+ slot.ops = [];
404
+ slot.size = 0;
405
+ if ("resend" in slot.overflow) {
406
+ slot.lazy = true;
407
+ return;
408
+ }
409
+ this.#pending.delete(stateKey(slot.hold, slot.m));
410
+ slot.overflow.resync();
411
+ }
412
+ /** Sends member `m` of a served hold again as a reset, through the window (`resync`). */
413
+ #resetLater(hold, m, resend) {
414
+ const key = stateKey(hold, m);
415
+ let slot = this.#pending.get(key);
416
+ if (slot?.kind === "unit")
417
+ return;
418
+ if (slot === undefined) {
419
+ slot = { kind: "state", hold, m, base: undefined, ops: [], size: 0, lazy: true, overflow: { resend } };
420
+ this.#pending.set(key, slot);
421
+ }
422
+ else {
423
+ this.#pendingBytes -= slot.size;
424
+ slot.base = undefined;
425
+ slot.ops = [];
426
+ slot.size = 0;
427
+ slot.lazy = true;
428
+ }
429
+ this.#flush();
430
+ }
431
+ /** Queues a hold's lazy unit (a waiting one already covers this one), and sends what the window allows. */
432
+ #queueUnit(hold, flush) {
433
+ const key = unitKey(hold);
434
+ if (!this.#pending.has(key))
435
+ this.#pending.set(key, { kind: "unit", hold, flush });
436
+ this.#flush();
437
+ }
438
+ /** Forgets what waits for a stream that ended. */
439
+ dropState(hold, m) {
440
+ const slot = this.#pending.get(stateKey(hold, m));
441
+ if (slot?.kind !== "state")
442
+ return;
443
+ this.#pendingBytes -= slot.size;
444
+ this.#pending.delete(stateKey(hold, m));
445
+ }
446
+ /** Forgets everything that waits for a hold (released, closed, or its state stopped). */
447
+ dropHoldState(hold) {
448
+ for (const [key, slot] of this.#pending) {
449
+ if (slot.hold !== hold)
450
+ continue;
451
+ if (slot.kind === "state")
452
+ this.#pendingBytes -= slot.size;
453
+ this.#pending.delete(key);
454
+ }
455
+ }
456
+ /** What waits for the peer: slots and bytes of ops (for tests and diagnostics). */
457
+ get waiting() {
458
+ return { slots: this.#pending.size, bytes: this.#pendingBytes };
459
+ }
460
+ get #full() {
461
+ return this.#peerAcks === true && this.#unackedBytes >= this.#window;
462
+ }
463
+ #onAck(n) {
464
+ if (this.#peerAcks !== true || n < this.#acked || n > this.#sent) {
465
+ this.#violation(`ack ${n} outside ${this.#acked}..${this.#sent}`);
466
+ return;
467
+ }
468
+ for (const size of this.#unacked.splice(0, n - this.#acked))
469
+ this.#unackedBytes -= size;
470
+ this.#acked = n;
471
+ this.#flush();
472
+ }
473
+ /** Sends waiting slots in creation order while the window allows. */
474
+ #flush() {
475
+ for (const [key, slot] of this.#pending) {
476
+ if (this.#full)
477
+ return;
478
+ this.#pending.delete(key);
479
+ if (slot.kind === "unit") {
480
+ slot.flush();
481
+ continue;
482
+ }
483
+ this.#pendingBytes -= slot.size;
484
+ if (slot.lazy) {
485
+ if ("resend" in slot.overflow)
486
+ slot.overflow.resend();
487
+ continue;
488
+ }
489
+ if (slot.base !== undefined)
490
+ this.send(slot.base);
491
+ if (slot.ops.length > 0)
492
+ this.send({ t: "state", hold: slot.hold, m: slot.m, as: "ops", ops: slot.ops });
493
+ }
494
+ }
495
+ /** Acknowledges once the frames that arrived together are processed: one ack per burst, not per frame. */
496
+ #scheduleAck() {
497
+ if (this.#peerAcks !== true || this.#ackTimer !== undefined || !this.open)
498
+ return;
499
+ this.#ackTimer = setTimeout(() => {
500
+ this.#ackTimer = undefined;
501
+ this.link.send({ t: "ack", n: this.#received });
502
+ }, 0);
503
+ this.#ackTimer.unref?.();
229
504
  }
230
505
  nextId() {
231
506
  return this.#nextId++;
232
507
  }
233
- /** Resolves once the peer is usable (a host peer runs this host's epoch); rejects if the link closes first. */
508
+ /** Resolves once the peer is usable (a host peer runs this host's code version); rejects if the link closes first. */
234
509
  whenSynced() {
235
510
  if (!this.open)
236
511
  return Promise.reject(unavailable(`link to ${this.name ?? "peer"} closed`));
@@ -238,8 +513,8 @@ export class Connection {
238
513
  return Promise.resolve();
239
514
  return new Promise((resolve, reject) => this.#syncWaiters.add({ resolve: resolve, reject }));
240
515
  }
241
- markSynced(epoch) {
242
- this.peerEpoch = epoch;
516
+ markSynced(codeVersion) {
517
+ this.peerCodeVersion = codeVersion;
243
518
  this.synced = true;
244
519
  for (const waiter of this.#syncWaiters)
245
520
  waiter.resolve(undefined);
@@ -258,25 +533,43 @@ export class Connection {
258
533
  else
259
534
  this.principal = next;
260
535
  }
536
+ /**
537
+ * Stops talking to a peer of another protocol version: reports it and closes the link. The close reason tells the
538
+ * peer this side's version (`protocol-version <n>`); a peer that noticed first told us the same way (`#closed`).
539
+ */
540
+ #protocolMismatch(peer, close = true) {
541
+ if (this.#mismatched)
542
+ return;
543
+ this.#mismatched = true;
544
+ try {
545
+ this.#onProtocolMismatch?.({ local: PROTOCOL_VERSION, peer });
546
+ }
547
+ catch (error) {
548
+ this.host.report(error);
549
+ }
550
+ if (close)
551
+ this.link.close(`protocol-version ${PROTOCOL_VERSION}`);
552
+ }
261
553
  /** A protocol violation: the peer cannot be trusted with anything on this link any more. */
262
554
  #violation(reason) {
263
555
  this.host.report(new ChordError("invalid", `link to ${this.name ?? "peer"}: ${reason}`, { reason: "protocol" }));
264
556
  this.link.close(`protocol error: ${reason}`);
265
557
  }
266
558
  // ─── consumer side (RemoteChannel) ───
267
- hold(handle) {
559
+ hold(binding, again) {
268
560
  const id = this.nextId();
269
- this.remotes.set(id, handle);
270
- handle.bound(this, id);
271
- const principal = handle.principal;
561
+ this.remotes.set(id, binding);
562
+ binding.bound(this, id);
563
+ const principal = binding.principal;
272
564
  this.send({
273
565
  t: "hold",
274
566
  id,
275
- at: handle.at,
276
- token: handle.contract.description,
277
- input: handle.input,
567
+ at: binding.at,
568
+ token: binding.contract.description,
569
+ input: binding.input,
278
570
  ...(principal === undefined ? {} : { principal }),
279
- ...(handle.owner === "client" ? { client: true } : {}),
571
+ ...(binding.stateOn ? {} : { state: false }),
572
+ ...(again ? { again: true } : {}),
280
573
  });
281
574
  return id;
282
575
  }
@@ -284,6 +577,10 @@ export class Connection {
284
577
  this.remotes.delete(id);
285
578
  this.send({ t: "release", id });
286
579
  }
580
+ wantState(hold, wanted) {
581
+ if (this.remotes.has(hold))
582
+ this.send({ t: wanted ? "sub" : "unsub", hold });
583
+ }
287
584
  call(hold, member, args, root, signal) {
288
585
  const id = this.nextId();
289
586
  return new Promise((resolve, reject) => {
@@ -308,36 +605,34 @@ export class Connection {
308
605
  reject(error);
309
606
  },
310
607
  });
311
- // Clients never carry roots or epochs: the server starts roots for them.
608
+ // Clients never carry roots or code versions: the server starts roots for them.
312
609
  const carried = root !== undefined && this.host.kind === "host";
313
610
  if (carried)
314
611
  this.host.rootSent(root.rootId, this);
315
612
  this.send({ t: "call", id, hold, member, args, ...(carried ? { root } : {}) });
316
613
  });
317
614
  }
318
- sub(hold, member, sink) {
319
- const id = this.nextId();
320
- this.subs.set(id, sink);
321
- this.send({ t: "sub", id, hold, member });
322
- return id;
323
- }
324
- unsub(id) {
325
- if (this.subs.delete(id))
326
- this.send({ t: "unsub", id });
327
- }
328
615
  // ─── receiving ───
329
616
  #receive(frame) {
617
+ if (this.#mismatched)
618
+ return;
330
619
  this.#lastSeen = Date.now();
331
620
  if (!isFrame(frame)) {
332
621
  this.#violation("malformed frame");
333
622
  return;
334
623
  }
624
+ if (frame.t === "ack") {
625
+ this.#onAck(frame.n);
626
+ return;
627
+ }
628
+ this.#received++;
335
629
  try {
336
630
  this.#dispatch(frame);
337
631
  }
338
632
  catch (error) {
339
633
  this.host.report(error);
340
634
  }
635
+ this.#scheduleAck();
341
636
  }
342
637
  #dispatch(frame) {
343
638
  switch (frame.t) {
@@ -349,6 +644,7 @@ export class Connection {
349
644
  return;
350
645
  case "pong":
351
646
  case "bye":
647
+ case "ack":
352
648
  return;
353
649
  case "hold":
354
650
  this.#onHold(frame);
@@ -363,10 +659,11 @@ export class Connection {
363
659
  this.incoming.get(frame.id)?.abort(new ChordError("aborted", "cancelled by caller"));
364
660
  return;
365
661
  case "sub":
366
- this.#onSub(frame);
367
- return;
368
662
  case "unsub":
369
- this.#onUnsub(frame.id);
663
+ this.#onWantState(frame.hold, frame.t === "sub");
664
+ return;
665
+ case "resync":
666
+ this.#onResync(frame.hold, frame.m);
370
667
  return;
371
668
  case "shape":
372
669
  case "gone":
@@ -397,7 +694,7 @@ export class Connection {
397
694
  this.host.invalidatePrincipal(frame.pattern).catch((error) => this.host.report(error));
398
695
  }
399
696
  else if (frame.t === "retire")
400
- this.host.allowRetire(frame.epoch);
697
+ this.host.allowRetire(frame.codeVersion);
401
698
  else
402
699
  this.host.onReloadFrame(this, frame);
403
700
  return;
@@ -417,10 +714,21 @@ export class Connection {
417
714
  }
418
715
  }
419
716
  /**
420
- * The peer's hello must agree with what this side decided about it. A downstream host announces its epoch: the
421
- * host either finds it in step or fences and reconciles it (never renumbers).
717
+ * The peer's hello must agree with what this side decided about it. A downstream host announces its code version:
718
+ * the host either finds it in step or fences and reconciles it (never renumbers).
422
719
  */
423
720
  #onHello(frame) {
721
+ if ((frame.protocol ?? 1) !== PROTOCOL_VERSION) {
722
+ this.#protocolMismatch(frame.protocol ?? 1);
723
+ return;
724
+ }
725
+ if (this.#peerAcks === undefined) {
726
+ this.#peerAcks = frame.acks === true;
727
+ if (!this.#peerAcks) {
728
+ this.#unacked = [];
729
+ this.#unackedBytes = 0;
730
+ }
731
+ }
424
732
  if (this.role === "upstream") {
425
733
  if (frame.kind !== "host") {
426
734
  this.#violation("parent is not a host");
@@ -434,19 +742,23 @@ export class Connection {
434
742
  return;
435
743
  }
436
744
  if (this.peer.kind === "host")
437
- this.host.peerHello(this, frame.epoch);
745
+ this.host.peerHello(this, frame.codeVersion);
438
746
  }
439
747
  /**
440
748
  * Principal stamping: a downstream link acts as the principal it was accepted with, whatever the frame says. Only
441
- * the parent may forward a principal (relayed requests); its own requests act as `{ host: parentName }`.
749
+ * the parent may forward a principal (relayed requests); its own requests act as the principal given to `connect`.
442
750
  */
443
751
  #principalOf(frame) {
444
752
  if (this.role === "downstream")
445
753
  return this.principal;
446
- return frame.principal ?? { host: this.#parentName ?? "parent" };
754
+ return frame.principal ?? this.principal;
447
755
  }
448
- #isClient(frame) {
449
- return this.role === "downstream" ? this.peer.kind === "client" : frame.client === true;
756
+ /**
757
+ * The client connection a hold comes from: this link's own id for a client link (whatever the frame says), the id
758
+ * the parent forwarded for a relayed hold, none for a host's own holds.
759
+ */
760
+ #originOf(frame) {
761
+ return this.role === "downstream" ? this.originId : frame.origin;
450
762
  }
451
763
  #onHold(frame) {
452
764
  if (this.served.has(frame.id)) {
@@ -454,55 +766,214 @@ export class Connection {
454
766
  return;
455
767
  }
456
768
  const principal = this.#principalOf(frame);
457
- const client = this.#isClient(frame);
769
+ const origin = this.#originOf(frame);
770
+ const client = origin !== undefined;
458
771
  if (frame.at !== this.host.name) {
459
772
  if (this.role === "upstream") {
460
773
  this.send({ t: "gone", id: frame.id, error: toWireError(unavailable(`${this.host.name} does not relay`)) });
461
774
  return;
462
775
  }
463
- const relay = new RelayHold(this, frame, principal, client);
776
+ const relay = new RelayRef(this, frame, principal, origin);
464
777
  this.served.set(frame.id, { kind: "relay", relay });
465
778
  this.host.relay(relay).catch((error) => relay.close(error, { releaseDown: true, notify: true }));
466
779
  return;
467
780
  }
468
- let handle;
781
+ let ref;
469
782
  try {
470
783
  const token = contractFromDescription(frame.token);
471
784
  if (token.scope !== "remote")
472
785
  throw invalid("token", `${token.name} is local; only remote types cross links`);
786
+ // Addressed here, so the input must place it here (also by this host's contract, checked when acquiring).
787
+ if (resolveHost(token.name, token.description.host, frame.input) !== this.host.name)
788
+ throw invalid("placement", `${token.name} with this input does not live on ${this.host.name}`);
473
789
  const acquired = this.host.acquire(token, frame.input, { principal }, client ? "client" : "connection");
474
- if (!(acquired instanceof HandleImpl))
475
- throw unavailable("not a local handle");
476
- handle = acquired;
790
+ if (!(acquired instanceof RefImpl))
791
+ throw unavailable("not a local reference");
792
+ ref = acquired;
477
793
  }
478
794
  catch (error) {
479
795
  this.send({ t: "gone", id: frame.id, error: toWireError(client ? clientError(error) : error) });
480
796
  return;
481
797
  }
482
- const served = { kind: "local", handle, principal, feeds: new Set() };
483
- this.served.set(frame.id, served);
484
- const shape = () => {
485
- const current = handle.shape;
486
- if (current === undefined || this.served.get(frame.id) !== served)
487
- return;
488
- this.send({
489
- t: "shape",
490
- id: frame.id,
491
- kind: current.kind,
492
- members: current.members,
493
- contract: current.contract,
494
- epoch: current.epoch,
495
- });
798
+ const served = {
799
+ kind: "local",
800
+ id: frame.id,
801
+ ref,
802
+ principal,
803
+ origin,
804
+ wantsState: frame.state !== false,
805
+ again: frame.again === true,
806
+ unitSent: false,
807
+ feeds: new Map(),
496
808
  };
497
- handle.onShape(shape);
498
- handle.goneListeners.add((error) => {
809
+ this.served.set(frame.id, served);
810
+ ref.onShape(() => this.#unit(served));
811
+ ref.goneListeners.add((error) => {
499
812
  if (this.served.get(frame.id) !== served)
500
813
  return;
501
814
  this.#dropServed(frame.id, false);
502
815
  this.send({ t: "gone", id: frame.id, error: toWireError(error) });
503
816
  });
504
- if (handle.status === "live")
505
- shape();
817
+ if (ref.status === "live")
818
+ this.#unit(served);
819
+ }
820
+ /** The state members of `shape` the consumer's contract knows. */
821
+ #stateMembers(ref, shape) {
822
+ return shape.members.filter((name) => ref.describe(shape.variant, name)?.kind === "state");
823
+ }
824
+ /**
825
+ * The shape changed (live, reload, variant) or state was asked for: members that left lose their stream, and a unit
826
+ * goes out with the shape and the members the consumer lacks. The first unit of a hold (not a re-hold) is urgent.
827
+ */
828
+ #unit(served) {
829
+ if (this.served.get(served.id) !== served)
830
+ return;
831
+ const shape = served.ref.shape;
832
+ if (shape === undefined)
833
+ return;
834
+ const members = new Set(served.wantsState ? this.#stateMembers(served.ref, shape) : []);
835
+ for (const [name, feed] of served.feeds) {
836
+ if (members.has(name))
837
+ continue;
838
+ feed.unsubscribe();
839
+ served.feeds.delete(name);
840
+ this.dropState(served.id, name);
841
+ }
842
+ const urgent = served.feeds.size === 0 && !(served.again && !served.unitSent);
843
+ if (urgent)
844
+ this.#sendUnit(served);
845
+ else
846
+ this.#queueUnit(served.id, () => this.#sendUnit(served));
847
+ }
848
+ /** Builds and sends a unit from the current shape and values. */
849
+ #sendUnit(served) {
850
+ this.#pending.delete(unitKey(served.id));
851
+ if (this.served.get(served.id) !== served)
852
+ return;
853
+ const shape = served.ref.shape;
854
+ if (shape === undefined)
855
+ return;
856
+ served.unitSent = true;
857
+ const members = served.wantsState ? this.#stateMembers(served.ref, shape) : [];
858
+ const listed = members.filter((name) => !served.feeds.has(name));
859
+ const snaps = [];
860
+ const attached = [];
861
+ for (const name of listed) {
862
+ const attachment = this.#attach(served, name);
863
+ if (attachment === undefined)
864
+ continue;
865
+ snaps.push(attachment.frame);
866
+ attached.push([name, attachment.feed]);
867
+ }
868
+ this.send({
869
+ t: "shape",
870
+ id: served.id,
871
+ ...(shape.variant === undefined ? {} : { variant: shape.variant }),
872
+ members: shape.members,
873
+ contract: shape.contract,
874
+ state: snaps.map((frame) => frame.m),
875
+ });
876
+ for (const frame of snaps)
877
+ this.send(frame);
878
+ for (const [name, feed] of attached)
879
+ this.#replay(served, name, feed);
880
+ }
881
+ /**
882
+ * Subscribes member `name` for the consumer: its snapshot (as a frame of type `as`), and a feed whose later events
883
+ * wait in its buffer until the snapshot is sent (`#replay`). Undefined if the member is not readable state.
884
+ */
885
+ #attach(served, name, as = "snap") {
886
+ const readable = served.ref.member(name);
887
+ if (readable === undefined || typeof readable.events !== "function")
888
+ return undefined;
889
+ const feed = { unsubscribe: () => { }, encoder: encoder(), buffer: [] };
890
+ let first;
891
+ feed.unsubscribe = readable.events((event) => {
892
+ if (first === undefined)
893
+ first = event;
894
+ else if (feed.buffer !== undefined)
895
+ feed.buffer.push(event);
896
+ else
897
+ this.#feedEvent(served, name, feed, event);
898
+ });
899
+ served.feeds.set(name, feed);
900
+ return { frame: this.#snapshot(served, name, as, first?.value), feed };
901
+ }
902
+ /** Sends the events that arrived while the snapshot was built, now that it is sent. */
903
+ #replay(served, name, feed) {
904
+ const buffered = feed.buffer ?? [];
905
+ feed.buffer = undefined;
906
+ for (const event of buffered)
907
+ this.#feedEvent(served, name, feed, event);
908
+ }
909
+ /** A member's state frame for a snapshot or reset: validated against the schema the consumer is promised. */
910
+ #snapshot(served, name, as, value) {
911
+ try {
912
+ validateSnapshot(served.ref, name, value);
913
+ }
914
+ catch (error) {
915
+ return { t: "state", hold: served.id, m: name, as: "error", error: toWireError(error) };
916
+ }
917
+ return { t: "state", hold: served.id, m: name, as, ...(value === undefined ? {} : { value }) };
918
+ }
919
+ /** A later event of a member's stream: ops are encoded, a reset starts a new stream. */
920
+ #feedEvent(served, name, feed, event) {
921
+ if (served.feeds.get(name) !== feed)
922
+ return;
923
+ const overflow = { resend: () => this.#resend(served, name) };
924
+ if (event.type === "ops") {
925
+ this.sendState({ t: "state", hold: served.id, m: name, as: "ops", ops: feed.encoder.encode(event.ops) }, overflow);
926
+ return;
927
+ }
928
+ feed.encoder = encoder();
929
+ this.sendState(this.#snapshot(served, name, "reset", event.value), overflow);
930
+ }
931
+ /** Sends member `name` again, now, as a reset from its current value (a lazy slot is flushed, or `resync`). */
932
+ #resend(served, name) {
933
+ const feed = served.feeds.get(name);
934
+ if (feed === undefined || this.served.get(served.id) !== served)
935
+ return;
936
+ feed.unsubscribe();
937
+ served.feeds.delete(name);
938
+ const attachment = this.#attach(served, name, "reset");
939
+ if (attachment === undefined)
940
+ return;
941
+ this.send(attachment.frame);
942
+ this.#replay(served, name, attachment.feed);
943
+ }
944
+ /** The consumer starts (`sub`) or stops (`unsub`) the state of a hold. */
945
+ #onWantState(hold, wanted) {
946
+ const served = this.served.get(hold);
947
+ if (served === undefined)
948
+ return;
949
+ if (served.kind === "relay") {
950
+ served.relay.wantState(wanted);
951
+ return;
952
+ }
953
+ if (served.wantsState === wanted)
954
+ return;
955
+ served.wantsState = wanted;
956
+ if (wanted) {
957
+ this.#unit(served);
958
+ return;
959
+ }
960
+ for (const feed of served.feeds.values())
961
+ feed.unsubscribe();
962
+ served.feeds.clear();
963
+ this.dropHoldState(hold);
964
+ }
965
+ /** The consumer (a relay that dropped ops) asks for member `m` again as a reset. */
966
+ #onResync(hold, m) {
967
+ const served = this.served.get(hold);
968
+ if (served === undefined)
969
+ return;
970
+ if (served.kind === "relay") {
971
+ served.relay.resync(m);
972
+ return;
973
+ }
974
+ if (!served.feeds.has(m))
975
+ return;
976
+ this.#resetLater(hold, m, () => this.#resend(served, m));
506
977
  }
507
978
  /** Forgets a served hold: unsubscribes its feeds and releases it (locally or downstream). */
508
979
  #dropServed(id, release) {
@@ -517,16 +988,23 @@ export class Connection {
517
988
  });
518
989
  return;
519
990
  }
520
- for (const feed of served.feeds)
521
- this.#onUnsub(feed);
991
+ for (const feed of served.feeds.values())
992
+ feed.unsubscribe();
993
+ served.feeds.clear();
994
+ this.dropHoldState(id);
522
995
  if (release)
523
- served.handle.release();
996
+ served.ref.release();
524
997
  }
525
998
  #onCall(frame) {
526
999
  if (this.incoming.has(frame.id)) {
527
1000
  this.#violation(`duplicate call id ${frame.id}`);
528
1001
  return;
529
1002
  }
1003
+ if (this.incoming.size >= this.#maxCalls) {
1004
+ const error = new ChordError("unavailable", `Too many calls in progress on this connection (${this.#maxCalls}); wait for earlier ones to finish`, { reason: "too_many_calls" });
1005
+ this.send({ t: "result", id: frame.id, error: toWireError(error) });
1006
+ return;
1007
+ }
530
1008
  const served = this.served.get(frame.hold);
531
1009
  if (served === undefined) {
532
1010
  this.send({ t: "result", id: frame.id, error: toWireError(new ChordError("gone", "unknown hold")) });
@@ -539,107 +1017,53 @@ export class Connection {
539
1017
  const principal = served.kind === "local" ? served.principal : served.relay.principal;
540
1018
  const options = { principal, signal: controller.signal };
541
1019
  const run = served.kind === "local"
542
- ? (ctx) => served.handle.record.call(served.handle, frame.member, frame.args, ctx)
543
- : (ctx) => relayCall(served.relay, frame, { rootId: ctx.rootId, epoch: ctx.epoch }, controller.signal, this.host);
1020
+ ? (ctx) => served.ref.object.call(served.ref, frame.member, unpack(frame.args), ctx)
1021
+ : (ctx) => relayCall(served.relay, frame, { rootId: ctx.rootId, codeVersion: ctx.codeVersion },
1022
+ // The root's signal: a cancel from the origin and the root's code version retiring both reach it.
1023
+ ctx.signal, this.host);
544
1024
  const result = root === undefined
545
1025
  ? this.host.root(run, options)
546
- : this.host.joinRoot(root.rootId, root.epoch, run, { ...options, from: this });
1026
+ : this.host.joinRoot(root.rootId, root.codeVersion, run, { ...options, from: this });
547
1027
  result
548
1028
  .then((value) => this.send({ t: "result", id: frame.id, value }), (error) => this.send({ t: "result", id: frame.id, error: toWireError(error) }))
549
1029
  .finally(() => this.incoming.delete(frame.id));
550
1030
  }
551
1031
  /**
552
- * A child that connected behind this host runs roots of an epoch this host never had (it was fenced and is being,
553
- * or was, reconciled). Such a root is admitted in this host's current epoch at its first call here, and keeps that
554
- * epoch for its later calls (`superseded` once this host retired it). Other unknown epochs stay `superseded`.
1032
+ * A child that connected behind this host runs roots of a code version this host never had (it was fenced and is
1033
+ * being, or was, reconciled). Such a root is admitted in this host's current code version at its first call here,
1034
+ * and keeps that code version for its later calls (`superseded` once this host retired it). Other unknown code
1035
+ * versions stay `superseded`.
555
1036
  */
556
1037
  #admitRoot(root) {
557
- if (root === undefined || this.role !== "downstream" || !this.fencedEpochs.has(root.epoch))
1038
+ if (root === undefined || this.role !== "downstream" || !this.fencedCodeVersions.has(root.codeVersion))
558
1039
  return root;
559
- if (this.host.hasEpoch(root.epoch))
1040
+ if (this.host.hasCodeVersion(root.codeVersion))
560
1041
  return root;
561
- let epoch = this.#admitted.get(root.rootId);
562
- if (epoch === undefined) {
563
- epoch = this.host.current.id;
564
- this.#admitted.set(root.rootId, epoch);
1042
+ let codeVersion = this.#admitted.get(root.rootId);
1043
+ if (codeVersion === undefined) {
1044
+ codeVersion = this.host.current.id;
1045
+ this.#admitted.set(root.rootId, codeVersion);
565
1046
  }
566
- return { rootId: root.rootId, epoch };
567
- }
568
- #onSub(frame) {
569
- if (this.feeds.has(frame.id) || this.relaySubs.has(frame.id)) {
570
- this.#violation(`duplicate sub id ${frame.id}`);
571
- return;
572
- }
573
- const served = this.served.get(frame.hold);
574
- if (served === undefined) {
575
- this.send({ t: "state", id: frame.id, error: toWireError(new ChordError("gone", "unknown hold")) });
576
- return;
577
- }
578
- if (served.kind === "relay") {
579
- served.relay.subscribe(frame.id, frame.member);
580
- return;
581
- }
582
- const readable = served.handle.member(frame.member);
583
- if (readable === undefined || typeof readable.events !== "function") {
584
- this.send({
585
- t: "state",
586
- id: frame.id,
587
- error: toWireError(new ChordError("not_in_variant", `${frame.member} is not state`)),
588
- });
589
- return;
590
- }
591
- let stream = encoder();
592
- const send = (event) => {
593
- if (event.type === "ops") {
594
- this.send({ t: "state", id: frame.id, ops: stream.encode(event.ops) });
595
- return;
596
- }
597
- // A loading provider handle has nothing yet: the hydrated value follows as a reset.
598
- if (event.value === undefined)
599
- return;
600
- // Snapshots are validated against the effective schema once per (re)subscription; ops only in dev mode.
601
- try {
602
- validateSnapshot(served.handle, frame.member, event.value);
603
- }
604
- catch (error) {
605
- this.send({ t: "state", id: frame.id, error: toWireError(error) });
606
- return;
607
- }
608
- stream = encoder();
609
- this.send(event.type === "snap"
610
- ? { t: "state", id: frame.id, snap: event.value }
611
- : { t: "state", id: frame.id, reset: event.value });
612
- };
613
- const unsubscribe = readable.events(send);
614
- this.feeds.set(frame.id, unsubscribe);
615
- served.feeds.add(frame.id);
616
- }
617
- #onUnsub(id) {
618
- const unsubscribe = this.feeds.get(id);
619
- if (unsubscribe !== undefined) {
620
- this.feeds.delete(id);
621
- unsubscribe();
622
- }
623
- this.relaySubs.get(id)?.unsubscribe(id);
1047
+ return { rootId: root.rootId, codeVersion };
624
1048
  }
625
1049
  #onHoldReply(frame) {
626
- const relay = this.relayedHolds.get(frame.id);
1050
+ const relay = this.relayedRefs.get(frame.id);
627
1051
  if (relay !== undefined) {
628
1052
  if (frame.t === "gone")
629
1053
  relay.close(fromWireError(frame.error), { releaseDown: false, notify: true });
630
- else if (!relay.closed)
631
- relay.origin.send({ ...frame, id: relay.originId });
1054
+ else
1055
+ relay.shape(frame);
632
1056
  return;
633
1057
  }
634
- const handle = this.remotes.get(frame.id);
635
- if (handle === undefined)
1058
+ const binding = this.remotes.get(frame.id);
1059
+ if (binding === undefined)
636
1060
  return;
637
1061
  if (frame.t === "gone") {
638
1062
  this.remotes.delete(frame.id);
639
- handle.gone(fromWireError(frame.error));
1063
+ binding.gone(fromWireError(frame.error));
640
1064
  return;
641
1065
  }
642
- handle.shaped({ kind: frame.kind, members: frame.members, contract: frame.contract, epoch: frame.epoch });
1066
+ binding.shaped({ variant: frame.variant, members: frame.members, contract: frame.contract }, frame.state);
643
1067
  }
644
1068
  #onResult(frame) {
645
1069
  const relayed = this.relayedCalls.get(frame.id);
@@ -649,23 +1073,54 @@ export class Connection {
649
1073
  }
650
1074
  const pending = this.calls.get(frame.id);
651
1075
  this.calls.delete(frame.id);
652
- if (frame.error === undefined)
653
- pending?.resolve(frame.value);
654
- else
1076
+ if (frame.error !== undefined) {
655
1077
  pending?.reject(fromWireError(frame.error));
1078
+ return;
1079
+ }
1080
+ let value;
1081
+ try {
1082
+ value = unpack(frame.value);
1083
+ }
1084
+ catch (error) {
1085
+ pending?.reject(error);
1086
+ return;
1087
+ }
1088
+ pending?.resolve(value);
656
1089
  }
657
1090
  #onState(frame) {
658
- const relayed = this.relayedSubs.get(frame.id);
659
- if (relayed !== undefined) {
660
- relayed.relay.origin.send({ ...frame, id: relayed.originSubId });
1091
+ const relay = this.relayedRefs.get(frame.hold);
1092
+ if (relay !== undefined) {
1093
+ relay.state(frame);
1094
+ return;
1095
+ }
1096
+ const binding = this.remotes.get(frame.hold);
1097
+ if (binding === undefined)
661
1098
  return;
1099
+ if (!(frame.value instanceof Packed)) {
1100
+ binding.state(frame.m, frame);
1101
+ return;
1102
+ }
1103
+ let value;
1104
+ try {
1105
+ value = unpack(frame.value);
662
1106
  }
663
- this.subs.get(frame.id)?.(frame);
1107
+ catch (error) {
1108
+ binding.state(frame.m, { as: "error", error: toWireError(error) });
1109
+ return;
1110
+ }
1111
+ binding.state(frame.m, { ...frame, value });
664
1112
  }
665
1113
  // ─── lifetime ───
666
1114
  #closed(reason) {
1115
+ const told = /^protocol-version (\d+)$/.exec(reason);
1116
+ if (told !== null && Number(told[1]) !== PROTOCOL_VERSION)
1117
+ this.#protocolMismatch(Number(told[1]), false);
667
1118
  if (this.#pinger !== undefined)
668
1119
  clearInterval(this.#pinger);
1120
+ if (this.#ackTimer !== undefined)
1121
+ clearTimeout(this.#ackTimer);
1122
+ this.#pending.clear();
1123
+ this.#pendingBytes = 0;
669
1124
  const error = unavailable(`link to ${this.name ?? "peer"} closed: ${reason}`);
670
1125
  // As origin: everything the peer held here, relayed or not, is released (downstream calls cancelled).
671
1126
  for (const id of [...this.served.keys()])
@@ -676,7 +1131,6 @@ export class Connection {
676
1131
  for (const pending of this.calls.values())
677
1132
  pending.reject(error);
678
1133
  this.calls.clear();
679
- this.subs.clear();
680
1134
  for (const vote of this.votes.values())
681
1135
  vote.reject(error);
682
1136
  this.votes.clear();
@@ -684,22 +1138,24 @@ export class Connection {
684
1138
  waiter.reject(error);
685
1139
  this.#syncWaiters.clear();
686
1140
  // As downstream of relays: their origins lose the hold.
687
- for (const relay of [...this.relayedHolds.values()])
1141
+ for (const relay of [...this.relayedRefs.values()])
688
1142
  relay.close(error, { releaseDown: false, notify: true });
689
- this.relayedHolds.clear();
1143
+ this.relayedRefs.clear();
690
1144
  this.relayedCalls.clear();
691
- this.relayedSubs.clear();
692
- for (const handle of this.remotes.values()) {
1145
+ const bindings = [...this.remotes.values()];
1146
+ this.remotes.clear();
1147
+ for (const binding of bindings) {
693
1148
  // An upstream link may be replaced (reconnect); a downstream host that went away is gone.
694
1149
  if (this.role === "upstream")
695
- handle.detached();
1150
+ binding.detached();
696
1151
  else
697
- handle.gone(error);
1152
+ binding.gone(error);
698
1153
  }
699
- this.remotes.clear();
700
1154
  this.host.connectionClosed(this);
701
1155
  }
702
1156
  }
1157
+ const stateKey = (hold, m) => `s${hold}\u0000${m}`;
1158
+ const unitKey = (hold) => `u${hold}`;
703
1159
  /**
704
1160
  * A call through a relay: waits for the route, then forwards with the root it runs in. It settles exactly once; a
705
1161
  * cancel (or the origin going away) is forwarded downstream and removes both mappings.
@@ -709,6 +1165,7 @@ async function relayCall(relay, frame, root, signal, host) {
709
1165
  if (signal.aborted)
710
1166
  throw abortError();
711
1167
  let onAbort = () => { };
1168
+ let onCancel = () => { };
712
1169
  const aborted = new Promise((_, reject) => {
713
1170
  onAbort = () => reject(abortError());
714
1171
  signal.addEventListener("abort", onAbort, { once: true });
@@ -720,32 +1177,34 @@ async function relayCall(relay, frame, root, signal, host) {
720
1177
  throw new ChordError("gone", "relayed hold closed");
721
1178
  return await new Promise((resolve, reject) => {
722
1179
  const call = new RelayCall(relay, frame.id, down.nextId(), { resolve, reject });
723
- signal.addEventListener("abort", () => call.cancel(abortError(), true), { once: true });
1180
+ onCancel = () => call.cancel(abortError(), true);
1181
+ signal.addEventListener("abort", onCancel, { once: true });
724
1182
  host.rootSent(root.rootId, down);
725
1183
  down.send({ t: "call", id: call.downId, hold: relay.downId, member: frame.member, args: frame.args, root });
726
1184
  });
727
1185
  }
728
1186
  finally {
729
1187
  signal.removeEventListener("abort", onAbort);
1188
+ signal.removeEventListener("abort", onCancel);
730
1189
  }
731
1190
  }
732
1191
  /**
733
1192
  * Validates a snapshot against the schema the consumer is promised: the provider's own schema for a direct binding,
734
1193
  * the consumer's contract for one through an `accepts` adapter (the adapter's view is what the consumer gets).
735
1194
  */
736
- function validateSnapshot(handle, member, value) {
1195
+ function validateSnapshot(ref, member, value) {
737
1196
  if (value === undefined)
738
1197
  return;
739
- const installed = handle.record.currentInstalled();
1198
+ const installed = ref.object.currentInstalled();
740
1199
  if (installed === undefined)
741
1200
  return;
742
- const view = handle.currentView();
743
- const kind = view === undefined ? kindOf(installed.behavior) : kindOf(view);
744
- const description = view !== undefined && view !== installed.behavior
745
- ? handle.contract.description.variants[kind ?? LOCAL_VARIANT]?.[member]
746
- : installed.service.contract.description.variants[kindOf(installed.behavior) ?? LOCAL_VARIANT]?.[member];
1201
+ const view = ref.currentView();
1202
+ const variant = view === undefined ? variantOf(installed.implementation) : variantOf(view);
1203
+ const description = view !== undefined && view !== installed.implementation
1204
+ ? ref.contract.description.variants[variant ?? LOCAL_VARIANT]?.[member]
1205
+ : installed.service.contract.description.variants[variantOf(installed.implementation) ?? LOCAL_VARIANT]?.[member];
747
1206
  if (description?.kind === "state")
748
- assertValid("state", `${handle.address}.${member}`, description.schema, value);
1207
+ assertValid("state", `${ref.address}.${member}`, description.schema, value);
749
1208
  }
750
1209
  /** Clients (browsers) holding an outdated contract are told to reload their code. */
751
1210
  function clientError(error) {