@forgeax/engine-net 0.1.4 → 0.1.7

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 (41) hide show
  1. package/README.md +156 -105
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/endpoint/endpoint.d.ts +7 -0
  4. package/dist/endpoint/endpoint.d.ts.map +1 -1
  5. package/dist/endpoint/memory.d.ts +3 -1
  6. package/dist/endpoint/memory.d.ts.map +1 -1
  7. package/dist/index.d.ts +11 -7
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.mjs +862 -117
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/replication/authority.d.ts +8 -6
  12. package/dist/replication/authority.d.ts.map +1 -1
  13. package/dist/replication/codec.d.ts +4 -22
  14. package/dist/replication/codec.d.ts.map +1 -1
  15. package/dist/replication/constants.d.ts +4 -1
  16. package/dist/replication/constants.d.ts.map +1 -1
  17. package/dist/replication/errors.d.ts +24 -4
  18. package/dist/replication/errors.d.ts.map +1 -1
  19. package/dist/replication/protocol.d.ts +63 -0
  20. package/dist/replication/protocol.d.ts.map +1 -0
  21. package/dist/replication/replica.d.ts +9 -6
  22. package/dist/replication/replica.d.ts.map +1 -1
  23. package/dist/session/net-session.d.ts +40 -6
  24. package/dist/session/net-session.d.ts.map +1 -1
  25. package/dist/session/recovery.d.ts +93 -0
  26. package/dist/session/recovery.d.ts.map +1 -0
  27. package/dist/session/session-plugin.d.ts +8 -2
  28. package/dist/session/session-plugin.d.ts.map +1 -1
  29. package/package.json +4 -4
  30. package/src/endpoint/endpoint.ts +8 -0
  31. package/src/endpoint/memory.ts +23 -1
  32. package/src/index.ts +56 -12
  33. package/src/replication/authority.ts +55 -23
  34. package/src/replication/codec.ts +167 -101
  35. package/src/replication/constants.ts +5 -1
  36. package/src/replication/errors.ts +22 -4
  37. package/src/replication/protocol.ts +86 -0
  38. package/src/replication/replica.ts +89 -28
  39. package/src/session/net-session.ts +617 -38
  40. package/src/session/recovery.ts +204 -0
  41. package/src/session/session-plugin.ts +21 -5
@@ -1,32 +1,101 @@
1
1
  // @forgeax/engine-net -- NetSession host-neutral World integration.
2
- // (requirements AC-04, plan-strategy D-1/D-3)
3
2
 
4
3
  import { err, ok, type Result } from '@forgeax/engine-types';
5
- import type { NetEndpoint, PeerId } from '../endpoint/endpoint';
6
- import type { EndpointError } from '../endpoint/errors';
7
- import type { AuthorityCoordinator } from '../replication/authority';
8
- import type { NetError } from '../replication/errors';
9
- import type { ReplicationLimits } from '../replication/profile';
10
- import { decodeAndApplyReplicaBatch, type ReplicaCoordinator } from '../replication/replica';
4
+ import type { NetEndpoint, NetEndpointConnector, PeerId } from '../endpoint/endpoint';
5
+ import { type EndpointError, isEndpointError } from '../endpoint/errors';
6
+ import type { AuthorityCoordinator, PublishedPacket } from '../replication/authority';
7
+ import { decodeReplicationPacket, encodeReplicationPacket } from '../replication/codec';
8
+ import { NetError, type NetError as NetErrorType } from '../replication/errors';
9
+ import { DEFAULT_REPLICATION_LIMITS, type ReplicationLimits } from '../replication/profile';
10
+ import type {
11
+ ReplicationAckPacket,
12
+ ReplicationDataPacket,
13
+ ReplicationSessionPacket,
14
+ } from '../replication/protocol';
15
+ import { decodeAndApplyReplicationPacket, type ReplicaCoordinator } from '../replication/replica';
16
+ import {
17
+ createSessionId,
18
+ DEFAULT_NET_RECOVERY_POLICY,
19
+ type NetRecoveryOutcome,
20
+ type NetRecoveryPolicy,
21
+ type NetRecoverySnapshot,
22
+ type NetSessionFailure,
23
+ type NetSessionState,
24
+ resolveNetRecoveryPolicy,
25
+ type SessionId,
26
+ transitionNetSessionState,
27
+ } from './recovery';
11
28
 
12
29
  export interface PeerSnapshot {
13
30
  readonly peerIds: ReadonlyArray<PeerId>;
14
31
  readonly connected: boolean;
15
32
  }
16
33
 
34
+ export interface SessionSnapshot {
35
+ readonly sessionIds: ReadonlyArray<SessionId>;
36
+ readonly connected: boolean;
37
+ }
38
+
39
+ export interface NetSessionClock {
40
+ readonly now: () => number;
41
+ readonly schedule: (delayMs: number, callback: () => void) => { cancel(): void };
42
+ }
43
+
44
+ export interface NetSessionResourceCounts {
45
+ readonly pendingConnects: number;
46
+ readonly timers: number;
47
+ readonly ledgers: number;
48
+ readonly callbacks: number;
49
+ }
50
+
17
51
  export interface NetSessionConfig {
18
- readonly endpoint: NetEndpoint;
52
+ readonly endpoint?: NetEndpoint;
53
+ readonly connector?: NetEndpointConnector;
54
+ readonly sessionId?: SessionId | number;
55
+ readonly recovery?: Partial<NetRecoveryPolicy>;
56
+ readonly clock?: NetSessionClock;
19
57
  readonly maxRawMessages: number;
20
58
  }
21
59
 
22
60
  export interface RawMessage {
23
61
  readonly peerId: PeerId;
62
+ readonly sessionId: SessionId;
24
63
  readonly data: Uint8Array;
25
64
  }
26
65
 
66
+ const defaultClock: NetSessionClock = {
67
+ now: () => Date.now(),
68
+ schedule: (delayMs, callback) => {
69
+ const id = globalThis.setTimeout(callback, delayMs);
70
+ return { cancel: () => globalThis.clearTimeout(id) };
71
+ },
72
+ };
73
+
74
+ function recoveryFailure(reason: string): NetErrorType {
75
+ return new NetError({
76
+ code: 'recovery-rejected',
77
+ expected: 'a recoverable NetSession lifecycle operation',
78
+ hint: 'inspect the current snapshot and retire the session after terminal failure',
79
+ detail: { reason },
80
+ });
81
+ }
82
+
83
+ function initialState(sessionId: SessionId, endpoint: NetEndpoint | undefined): NetSessionState {
84
+ return endpoint === undefined
85
+ ? { kind: 'connecting', sessionId }
86
+ : { kind: 'resyncing', sessionId, epoch: 0 };
87
+ }
88
+
27
89
  export class NetSession {
28
- readonly #endpoint: NetEndpoint;
90
+ #endpoint: NetEndpoint | undefined;
91
+ readonly #connector: NetEndpointConnector | undefined;
92
+ readonly #clock: NetSessionClock;
93
+ readonly #policy: NetRecoveryPolicy;
94
+ readonly #sessionId: SessionId;
29
95
  readonly #peerIds = new Set<PeerId>();
96
+ readonly #sessionPeers = new Map<SessionId, PeerId>();
97
+ readonly #announcedPeers = new Set<PeerId>();
98
+ #sessionAnnounced = false;
30
99
  #rawMessages: RawMessage[] = [];
31
100
  readonly #maxRawMessages: number;
32
101
  #authority: AuthorityCoordinator | undefined;
@@ -34,36 +103,282 @@ export class NetSession {
34
103
  #replica:
35
104
  | { readonly coordinator: ReplicaCoordinator; readonly limits: ReplicationLimits }
36
105
  | undefined;
106
+ #state: NetSessionState;
107
+ #lastError: NetSessionFailure | undefined;
108
+ #epoch = 0;
109
+ #sequence = 0;
110
+ #acknowledgedSequence = 0;
111
+ #reconnectAttempts = 0;
112
+ #pendingConnect: { readonly abort: () => void } | undefined;
113
+ #retryTimer: { cancel(): void } | undefined;
114
+ readonly #ledger = new Map<number, Uint8Array>();
115
+ #disposed = false;
37
116
 
38
117
  constructor(config: NetSessionConfig) {
39
118
  this.#endpoint = config.endpoint;
119
+ this.#connector = config.connector;
120
+ this.#clock = config.clock ?? defaultClock;
40
121
  this.#maxRawMessages = config.maxRawMessages;
122
+ const resolvedSessionId = this.#resolveSessionId(config.sessionId);
123
+ this.#sessionId = resolvedSessionId.ok ? resolvedSessionId.value : (1 as SessionId);
124
+ const policy = resolveNetRecoveryPolicy(config.recovery);
125
+ this.#policy = policy.ok ? policy.value : DEFAULT_NET_RECOVERY_POLICY;
126
+ this.#state = initialState(this.#sessionId, this.#endpoint);
127
+ if (!resolvedSessionId.ok) this.#setFailure(resolvedSessionId.error);
128
+ else if (!policy.ok) this.#setFailure(policy.error);
129
+ }
130
+
131
+ #resolveSessionId(value: SessionId | number | undefined): Result<SessionId, NetErrorType> {
132
+ return createSessionId(value ?? 1);
133
+ }
134
+
135
+ #setState(next: NetSessionState): void {
136
+ const transition = transitionNetSessionState(this.#state, next);
137
+ if (transition.ok) {
138
+ this.#state = transition.value;
139
+ return;
140
+ }
141
+ this.#setFailure(transition.error);
142
+ }
143
+
144
+ #setFailure(failure: NetSessionFailure): void {
145
+ this.#lastError = failure;
146
+ if (this.#state.kind !== 'failed' && this.#state.kind !== 'retired')
147
+ this.#setState({ kind: 'failed', sessionId: this.#sessionId, error: failure });
148
+ this.#authority = undefined;
149
+ this.#peerIds.clear();
150
+ this.#sessionPeers.clear();
151
+ this.#announcedPeers.clear();
152
+ this.#sessionAnnounced = false;
153
+ this.#pendingFullPeers.clear();
154
+ this.#rawMessages = [];
155
+ this.#clearRecoveryWork();
156
+ this.#endpoint?.close();
157
+ }
158
+
159
+ #clearRecoveryWork(): void {
160
+ this.#retryTimer?.cancel();
161
+ this.#retryTimer = undefined;
162
+ this.#pendingConnect?.abort();
163
+ this.#pendingConnect = undefined;
164
+ this.#ledger.clear();
165
+ }
166
+
167
+ #beginRecovery(): void {
168
+ const previousEndpoint = this.#endpoint;
169
+ this.#endpoint = undefined;
170
+ previousEndpoint?.close();
171
+ if (
172
+ this.#state.kind === 'connecting' ||
173
+ this.#state.kind === 'active' ||
174
+ this.#state.kind === 'resyncing'
175
+ )
176
+ this.#setState({
177
+ kind: 'recovering',
178
+ sessionId: this.#sessionId,
179
+ epoch: this.#epoch,
180
+ attempt: 0,
181
+ });
182
+ this.#ledger.clear();
183
+ this.#sequence = 0;
184
+ this.#acknowledgedSequence = 0;
185
+ this.#peerIds.clear();
186
+ this.#sessionPeers.clear();
187
+ this.#announcedPeers.clear();
188
+ this.#sessionAnnounced = false;
189
+ this.#pendingFullPeers.clear();
190
+ this.#rawMessages = [];
191
+ }
192
+
193
+ #attemptRecovery(): void {
194
+ if (this.#disposed || this.#state.kind !== 'recovering' || this.#pendingConnect) return;
195
+ if (this.#reconnectAttempts >= this.#policy.maxReconnectAttempts) {
196
+ this.#setFailure(
197
+ new NetError({
198
+ code: 'recovery-exhausted',
199
+ expected: 'reconnect attempts within the configured finite bound',
200
+ hint: 'inspect the failure and create a new session after exhaustion',
201
+ detail: {
202
+ attempts: this.#reconnectAttempts,
203
+ maxAttempts: this.#policy.maxReconnectAttempts,
204
+ },
205
+ }),
206
+ );
207
+ return;
208
+ }
209
+ this.#reconnectAttempts += 1;
210
+ this.#setState({
211
+ kind: 'recovering',
212
+ sessionId: this.#sessionId,
213
+ epoch: this.#epoch,
214
+ attempt: this.#reconnectAttempts,
215
+ });
216
+ if (this.#connector === undefined) {
217
+ if (this.#reconnectAttempts >= this.#policy.maxReconnectAttempts) this.#attemptRecovery();
218
+ return;
219
+ }
220
+ const controller = new AbortController();
221
+ this.#pendingConnect = { abort: () => controller.abort() };
222
+ void this.#connector.connect(controller.signal).then(
223
+ (result) => this.#connected(result),
224
+ (cause: unknown) => this.#connectFailed(cause),
225
+ );
226
+ }
227
+
228
+ #connected(result: Result<NetEndpoint, EndpointError>): void {
229
+ this.#pendingConnect = undefined;
230
+ if (this.#disposed || this.#state.kind !== 'recovering') {
231
+ if (result.ok) result.value.close();
232
+ return;
233
+ }
234
+ if (!result.ok) {
235
+ this.#connectFailed(result.error);
236
+ return;
237
+ }
238
+ this.#endpoint?.close();
239
+ this.#endpoint = result.value;
240
+ this.#lastError = undefined;
241
+ this.#epoch += 1;
242
+ this.#sequence = 0;
243
+ this.#acknowledgedSequence = 0;
244
+ this.#ledger.clear();
245
+ this.#setState({ kind: 'resyncing', sessionId: this.#sessionId, epoch: this.#epoch });
246
+ }
247
+
248
+ #connectFailed(cause: unknown): void {
249
+ this.#pendingConnect = undefined;
250
+ if (this.#disposed || this.#state.kind !== 'recovering') return;
251
+ const failure: NetSessionFailure =
252
+ cause instanceof NetError
253
+ ? (cause as unknown as NetErrorType)
254
+ : isEndpointError(cause)
255
+ ? cause
256
+ : recoveryFailure('connector attempt failed');
257
+ if (this.#reconnectAttempts >= this.#policy.maxReconnectAttempts) {
258
+ this.#setFailure(
259
+ new NetError({
260
+ code: 'recovery-exhausted',
261
+ expected: 'reconnect attempts within the configured finite bound',
262
+ hint: 'inspect the endpoint failure and create a new session after exhaustion',
263
+ detail: {
264
+ attempts: this.#reconnectAttempts,
265
+ maxAttempts: this.#policy.maxReconnectAttempts,
266
+ },
267
+ }),
268
+ );
269
+ return;
270
+ }
271
+ this.#lastError = failure;
272
+ this.advanceRecovery();
273
+ }
274
+
275
+ #handleAck(packet: ReplicationAckPacket): Result<void, NetError> {
276
+ if (packet.sessionId !== this.#sessionId && !this.#sessionPeers.has(packet.sessionId))
277
+ return err(
278
+ new NetError({
279
+ code: 'recovery-rejected',
280
+ expected: 'an ACK for the current SessionId',
281
+ hint: 'discard ACKs from another logical session',
282
+ detail: { reason: 'ACK SessionId does not match the current session' },
283
+ }),
284
+ );
285
+ if (packet.epoch !== this.#epoch || packet.acknowledgedSequence > this.#sequence)
286
+ return ok(undefined);
287
+ if (packet.acknowledgedSequence <= this.#acknowledgedSequence) return ok(undefined);
288
+ this.#acknowledgedSequence = packet.acknowledgedSequence;
289
+ for (const sequence of this.#ledger.keys())
290
+ if (sequence <= packet.acknowledgedSequence) this.#ledger.delete(sequence);
291
+ return ok(undefined);
292
+ }
293
+
294
+ #receiveMessage(peerId: PeerId, data: Uint8Array, errors: NetError[]): void {
295
+ if (
296
+ this.#state.kind === 'recovering' ||
297
+ this.#state.kind === 'failed' ||
298
+ this.#state.kind === 'retired'
299
+ )
300
+ return;
301
+ const limits = this.#replica?.limits ?? DEFAULT_REPLICATION_LIMITS;
302
+ const decoded = decodeReplicationPacket(data, limits);
303
+ if (!decoded.ok) {
304
+ if (this.#replica === undefined) {
305
+ this.#queueRawMessage(peerId, data);
306
+ return;
307
+ }
308
+ errors.push(decoded.error);
309
+ this.#setFailure(decoded.error as NetErrorType);
310
+ return;
311
+ }
312
+ if (decoded.value.kind === 'session-open' || decoded.value.kind === 'session-resume') {
313
+ this.#bindSession(decoded.value.sessionId, peerId);
314
+ return;
315
+ }
316
+ if (decoded.value.kind === 'ack') {
317
+ const handled = this.#handleAck(decoded.value);
318
+ if (!handled.ok) {
319
+ errors.push(handled.error);
320
+ this.#setFailure(handled.error);
321
+ }
322
+ return;
323
+ }
324
+ if (decoded.value.kind !== 'baseline' && decoded.value.kind !== 'delta') {
325
+ if (decoded.value.kind === 'rejection') {
326
+ const failure = recoveryFailure(
327
+ `peer rejected ${decoded.value.rejectedKind}: ${decoded.value.reason}`,
328
+ );
329
+ errors.push(failure);
330
+ this.#setFailure(failure);
331
+ }
332
+ return;
333
+ }
334
+ if (this.#replica === undefined) {
335
+ this.#queueRawMessage(peerId, data);
336
+ return;
337
+ }
338
+ const applied = decodeAndApplyReplicationPacket(
339
+ this.#replica.coordinator,
340
+ data,
341
+ this.#replica.limits,
342
+ );
343
+ if (!applied.ok) {
344
+ errors.push(applied.error);
345
+ this.#setFailure(applied.error);
346
+ return;
347
+ }
348
+ const packetOutcome = this.#replica.coordinator.lastPacketOutcome;
349
+ if (packetOutcome === 'accepted') {
350
+ this.#epoch = decoded.value.epoch;
351
+ this.#sequence = decoded.value.sequence;
352
+ this.#acknowledgedSequence = decoded.value.sequence;
353
+ this.#setState({
354
+ kind: 'active',
355
+ sessionId: this.#sessionId,
356
+ epoch: this.#epoch,
357
+ sequence: this.#sequence,
358
+ });
359
+ }
360
+ if (packetOutcome === 'accepted' || packetOutcome === 'duplicate')
361
+ this.#sendReplicationAck(peerId, decoded.value);
41
362
  }
42
363
 
43
364
  receiveEvents(): readonly NetError[] {
44
365
  const errors: NetError[] = [];
45
- for (const event of this.#endpoint.poll()) {
366
+ if (this.#disposed || this.#state.kind === 'failed' || this.#state.kind === 'retired')
367
+ return errors;
368
+ for (const event of this.#endpoint?.poll() ?? []) {
46
369
  if (event.kind === 'peer-connected') {
47
370
  this.#peerIds.add(event.peerId);
48
- // Transport admission must not imply application admission, but every
49
- // newly connected replica still needs the generic replication baseline.
371
+ if (this.#replica !== undefined) this.#bindSession(this.#sessionId, event.peerId);
372
+ else this.#bindSession(this.#sessionForPeer(event.peerId), event.peerId);
50
373
  this.#pendingFullPeers.add(event.peerId);
51
374
  } else if (event.kind === 'peer-disconnected') {
52
- this.#peerIds.delete(event.peerId);
53
- this.#pendingFullPeers.delete(event.peerId);
54
- this.#replica?.coordinator.clear();
55
- } else {
375
+ this.#forgetPeer(event.peerId);
56
376
  if (this.#replica !== undefined) {
57
- const result = decodeAndApplyReplicaBatch(
58
- this.#replica.coordinator,
59
- event.data,
60
- this.#replica.limits,
61
- );
62
- if (!result.ok) errors.push(result.error);
63
- } else if (this.#rawMessages.length < this.#maxRawMessages) {
64
- this.#rawMessages.push({ peerId: event.peerId, data: event.data });
377
+ this.#replica.coordinator.clear();
378
+ this.#beginRecovery();
379
+ this.advanceRecovery();
65
380
  }
66
- }
381
+ } else this.#receiveMessage(event.peerId, event.data, errors);
67
382
  }
68
383
  return errors;
69
384
  }
@@ -77,9 +392,92 @@ export class NetSession {
77
392
  return { peerIds, connected: peerIds.length > 0 };
78
393
  }
79
394
 
80
- sendRaw(peerId: PeerId, data: Uint8Array): Result<void, EndpointError> {
81
- const result = this.#endpoint.send(peerId, data);
82
- return result.ok ? ok(undefined) : err(result.error);
395
+ getSessionSnapshot(): SessionSnapshot {
396
+ const sessionIds = [...this.#sessionPeers.keys()].sort((left, right) => left - right);
397
+ return { sessionIds, connected: sessionIds.length > 0 };
398
+ }
399
+
400
+ /** Return lifecycle, epoch, sequence, ledger, and owned-resource evidence. */
401
+ getRecoverySnapshot(): NetRecoverySnapshot {
402
+ return {
403
+ sessionId: this.#sessionId,
404
+ state: this.#state,
405
+ pendingPackets: this.#ledger.size,
406
+ maxPendingPackets: this.#policy.maxPendingPackets,
407
+ acknowledgedSequence: this.#acknowledgedSequence,
408
+ reconnectAttempts: this.#reconnectAttempts,
409
+ epoch: this.#epoch,
410
+ sequence: this.#sequence,
411
+ ...(this.#lastError === undefined ? {} : { lastError: this.#lastError }),
412
+ ownedResources: {
413
+ pendingConnects: this.#pendingConnect === undefined ? 0 : 1,
414
+ timers: this.#retryTimer === undefined ? 0 : 1,
415
+ ledgers: this.#ledger.size === 0 ? 0 : 1,
416
+ callbacks: 0,
417
+ },
418
+ };
419
+ }
420
+
421
+ getResourceSnapshot(): NetSessionResourceCounts {
422
+ return this.getRecoverySnapshot().ownedResources;
423
+ }
424
+
425
+ recover(): NetRecoveryOutcome {
426
+ if (this.#state.kind === 'retired' || this.#state.kind === 'failed')
427
+ return { kind: 'retired', sessionId: this.#sessionId };
428
+ if (this.#state.kind === 'recovering')
429
+ return { kind: 'already-recovering', sessionId: this.#sessionId };
430
+ this.#beginRecovery();
431
+ return { kind: 'started', sessionId: this.#sessionId };
432
+ }
433
+
434
+ advanceRecovery(): void {
435
+ if (this.#state.kind !== 'recovering') return;
436
+ const delay =
437
+ this.#policy.reconnectDelaysMs[
438
+ Math.min(this.#reconnectAttempts, this.#policy.reconnectDelaysMs.length - 1)
439
+ ];
440
+ if (delay === undefined || delay === 0) this.#attemptRecovery();
441
+ else {
442
+ this.#retryTimer?.cancel();
443
+ this.#retryTimer = this.#clock.schedule(delay, () => {
444
+ this.#retryTimer = undefined;
445
+ this.#attemptRecovery();
446
+ });
447
+ }
448
+ }
449
+
450
+ sendRaw(peerId: PeerId, data: Uint8Array): Result<void, EndpointError | NetError> {
451
+ if (this.#state.kind !== 'active') return err(recoveryFailure('session is not active'));
452
+ return this.#sendToPeer(peerId, data);
453
+ }
454
+
455
+ /** Send one application command through the current replica attachment. */
456
+ sendToAuthority(sessionId: SessionId, data: Uint8Array): Result<void, EndpointError | NetError> {
457
+ if (sessionId !== this.#sessionId)
458
+ return err(recoveryFailure('session id does not belong to this NetSession'));
459
+ if (
460
+ this.#state.kind === 'recovering' ||
461
+ this.#state.kind === 'failed' ||
462
+ this.#state.kind === 'retired'
463
+ )
464
+ return err(recoveryFailure('session is not connected to the authority'));
465
+ const peerId = this.#peerForSession(sessionId);
466
+ if (peerId === undefined) return err(recoveryFailure('authority peer is not connected'));
467
+ if (this.#replica !== undefined) {
468
+ const announced = this.#announceSession(peerId);
469
+ if (!announced.ok) return announced;
470
+ }
471
+ return this.#sendToPeer(peerId, data);
472
+ }
473
+
474
+ /** Send one application message to an authority-owned logical session. */
475
+ sendToSession(sessionId: SessionId, data: Uint8Array): Result<void, EndpointError | NetError> {
476
+ if (this.#state.kind === 'failed' || this.#state.kind === 'retired')
477
+ return err(recoveryFailure('session is not connected to the authority'));
478
+ const peerId = this.#peerForSession(sessionId);
479
+ if (peerId === undefined) return err(recoveryFailure('logical session is not connected'));
480
+ return this.#sendToPeer(peerId, data);
83
481
  }
84
482
 
85
483
  attachAuthority(authority: AuthorityCoordinator): void {
@@ -90,29 +488,210 @@ export class NetSession {
90
488
  if (this.#peerIds.has(peerId)) this.#pendingFullPeers.add(peerId);
91
489
  }
92
490
 
491
+ requestFullBaselineForSession(sessionId: SessionId): void {
492
+ const peerId = this.#sessionPeers.get(sessionId);
493
+ if (peerId !== undefined) this.requestFullBaseline(peerId);
494
+ }
495
+
93
496
  attachReplica(coordinator: ReplicaCoordinator, limits: ReplicationLimits): void {
94
497
  this.#replica = { coordinator, limits };
95
498
  }
96
499
 
500
+ #ledgerBoundError(): NetError {
501
+ return new NetError({
502
+ code: 'recovery-rejected',
503
+ expected: 'published packets within the configured finite ACK bound',
504
+ hint: 'wait for a cumulative ACK before publishing more packets',
505
+ detail: { reason: 'ACK ledger bound reached' },
506
+ });
507
+ }
508
+
509
+ #ensurePublicationCapacity(expectedEpoch: number): Result<void, NetError> {
510
+ if (expectedEpoch === this.#epoch && this.#ledger.size >= this.#policy.maxPendingPackets)
511
+ return err(this.#ledgerBoundError());
512
+ return ok(undefined);
513
+ }
514
+
515
+ #reservePublished(packet: PublishedPacket): Result<void, NetError> {
516
+ if (packet.epoch !== this.#epoch) {
517
+ this.#ledger.clear();
518
+ this.#acknowledgedSequence = 0;
519
+ this.#epoch = packet.epoch;
520
+ }
521
+ if (this.#ledger.size >= this.#policy.maxPendingPackets && !this.#ledger.has(packet.sequence))
522
+ return err(this.#ledgerBoundError());
523
+ this.#sequence = packet.sequence;
524
+ this.#ledger.set(packet.sequence, packet.bytes);
525
+ return ok(undefined);
526
+ }
527
+
528
+ #sendPublished(
529
+ packet: PublishedPacket,
530
+ peerIds: readonly PeerId[],
531
+ ): Result<void, EndpointError | NetError> {
532
+ const reserved = this.#reservePublished(packet);
533
+ if (!reserved.ok) return reserved;
534
+ if (this.#endpoint === undefined) return err(recoveryFailure('session has no endpoint'));
535
+ let delivered = false;
536
+ for (const peerId of peerIds) {
537
+ const sent = this.#endpoint.send(peerId, packet.bytes);
538
+ if (!sent.ok) {
539
+ if (sent.error.code === 'connection-closed') {
540
+ // A socket can close before its endpoint emits the corresponding
541
+ // disconnect event. Treat that transport race as the lifecycle
542
+ // event it represents so one stale peer cannot poison the World or
543
+ // prevent the same publication reaching live peers.
544
+ this.#forgetPeer(peerId);
545
+ continue;
546
+ }
547
+ this.#ledger.delete(packet.sequence);
548
+ return err(sent.error);
549
+ }
550
+ delivered = true;
551
+ }
552
+ if (!delivered) this.#ledger.delete(packet.sequence);
553
+ return ok(undefined);
554
+ }
555
+
97
556
  publish(): Result<void, NetError | EndpointError> {
98
- if (this.#authority === undefined) return ok(undefined);
557
+ // Do not advance the authority ledger before a peer exists. A host can
558
+ // start its fixed loop before the first socket handshake; reserving that
559
+ // empty publication would make the first connected peer wait behind an
560
+ // ACK for bytes it could never receive.
561
+ if (this.#authority === undefined || this.#endpoint === undefined || this.#peerIds.size === 0)
562
+ return ok(undefined);
99
563
  if (this.#pendingFullPeers.size > 0) {
564
+ const capacity = this.#ensurePublicationCapacity(this.#authority.nextPublicationEpoch(true));
565
+ if (!capacity.ok) return capacity;
100
566
  const published = this.#authority.publishFull();
101
567
  if (!published.ok) return err(published.error);
102
- for (const peerId of this.#pendingFullPeers) {
103
- if (this.#peerIds.has(peerId)) {
104
- const sent = this.#endpoint.send(peerId, published.value.bytes);
105
- if (!sent.ok) return err(sent.error);
106
- }
107
- }
568
+ const sent = this.#sendPublished(published.value, [...this.#peerIds]);
569
+ if (!sent.ok) return err(sent.error);
108
570
  this.#pendingFullPeers.clear();
571
+ // A fresh baseline is the first packet of the new epoch for every
572
+ // replica. Do not append the same-tick delta: a receiver must be able
573
+ // to observe and apply sequence 1 before any incremental publication.
574
+ return ok(undefined);
109
575
  }
576
+ const capacity = this.#ensurePublicationCapacity(this.#authority.nextPublicationEpoch());
577
+ if (!capacity.ok) return capacity;
110
578
  const published = this.#authority.publish();
111
579
  if (!published.ok) return err(published.error);
112
- for (const peerId of this.#peerIds) {
113
- const sent = this.#endpoint.send(peerId, published.value.bytes);
114
- if (!sent.ok) return err(sent.error);
580
+ const sent = this.#sendPublished(published.value, [...this.#peerIds]);
581
+ if (!sent.ok) return err(sent.error);
582
+ return ok(undefined);
583
+ }
584
+
585
+ dispose(): void {
586
+ if (this.#disposed) return;
587
+ this.#disposed = true;
588
+ this.#clearRecoveryWork();
589
+ this.#endpoint?.close();
590
+ this.#endpoint = undefined;
591
+ this.#replica?.coordinator.clear();
592
+ this.#replica = undefined;
593
+ this.#authority = undefined;
594
+ this.#peerIds.clear();
595
+ this.#sessionPeers.clear();
596
+ this.#announcedPeers.clear();
597
+ this.#sessionAnnounced = false;
598
+ this.#pendingFullPeers.clear();
599
+ this.#rawMessages = [];
600
+ if (this.#state.kind !== 'retired')
601
+ this.#setState({ kind: 'retired', sessionId: this.#sessionId, reason: 'disposed' });
602
+ }
603
+
604
+ #queueRawMessage(peerId: PeerId, data: Uint8Array): void {
605
+ if (this.#rawMessages.length >= this.#maxRawMessages) return;
606
+ this.#rawMessages.push({
607
+ peerId,
608
+ sessionId: this.#sessionForPeer(peerId),
609
+ data: new Uint8Array(data),
610
+ });
611
+ }
612
+
613
+ #sessionForPeer(peerId: PeerId): SessionId {
614
+ for (const [sessionId, mappedPeerId] of this.#sessionPeers)
615
+ if (mappedPeerId === peerId) return sessionId;
616
+ if (this.#replica !== undefined) {
617
+ this.#bindSession(this.#sessionId, peerId);
618
+ return this.#sessionId;
115
619
  }
620
+ const created = createSessionId(peerId);
621
+ const sessionId = created.ok ? created.value : this.#sessionId;
622
+ this.#bindSession(sessionId, peerId);
623
+ return sessionId;
624
+ }
625
+
626
+ #bindSession(sessionId: SessionId, peerId: PeerId): void {
627
+ for (const [mappedSessionId, mappedPeerId] of this.#sessionPeers)
628
+ if (mappedSessionId === sessionId || mappedPeerId === peerId)
629
+ this.#sessionPeers.delete(mappedSessionId);
630
+ this.#sessionPeers.set(sessionId, peerId);
631
+ }
632
+
633
+ #forgetPeer(peerId: PeerId): void {
634
+ this.#peerIds.delete(peerId);
635
+ for (const [sessionId, mappedPeerId] of this.#sessionPeers)
636
+ if (mappedPeerId === peerId) this.#sessionPeers.delete(sessionId);
637
+ this.#announcedPeers.delete(peerId);
638
+ this.#pendingFullPeers.delete(peerId);
639
+ }
640
+
641
+ #peerForSession(sessionId: SessionId): PeerId | undefined {
642
+ const mapped = this.#sessionPeers.get(sessionId);
643
+ if (mapped !== undefined && this.#peerIds.has(mapped)) return mapped;
644
+ if (this.#replica !== undefined && this.#peerIds.size === 1) {
645
+ const peerId = [...this.#peerIds][0];
646
+ if (peerId !== undefined) {
647
+ this.#bindSession(sessionId, peerId);
648
+ return peerId;
649
+ }
650
+ }
651
+ return undefined;
652
+ }
653
+
654
+ #announceSession(peerId: PeerId): Result<void, EndpointError | NetError> {
655
+ if (this.#announcedPeers.has(peerId)) return ok(undefined);
656
+ const packet: ReplicationSessionPacket = {
657
+ version: 2,
658
+ kind: this.#sessionAnnounced ? 'session-resume' : 'session-open',
659
+ sessionId: this.#sessionId,
660
+ epoch: this.#epoch,
661
+ sequence: 0,
662
+ };
663
+ const encoded = encodeReplicationPacket(packet, DEFAULT_REPLICATION_LIMITS);
664
+ if (!encoded.ok) return err(encoded.error);
665
+ const sent = this.#sendToPeer(peerId, encoded.value);
666
+ if (!sent.ok) return sent;
667
+ this.#announcedPeers.add(peerId);
668
+ this.#sessionAnnounced = true;
116
669
  return ok(undefined);
117
670
  }
671
+
672
+ #sendToPeer(peerId: PeerId, data: Uint8Array): Result<void, EndpointError | NetError> {
673
+ const result = this.#endpoint?.send(peerId, data);
674
+ if (result === undefined) return err(recoveryFailure('session has no endpoint'));
675
+ return result.ok ? ok(undefined) : err(result.error);
676
+ }
677
+
678
+ /** ACK accepted data at the session boundary; consumers should not reimplement this wire step. */
679
+ #sendReplicationAck(peerId: PeerId, packet: ReplicationDataPacket): void {
680
+ const encoded = encodeReplicationPacket(
681
+ {
682
+ version: 2,
683
+ kind: 'ack',
684
+ sessionId: packet.sessionId,
685
+ epoch: packet.epoch,
686
+ acknowledgedSequence: packet.sequence,
687
+ },
688
+ DEFAULT_REPLICATION_LIMITS,
689
+ );
690
+ if (!encoded.ok) {
691
+ this.#setFailure(encoded.error);
692
+ return;
693
+ }
694
+ const sent = this.#sendToPeer(peerId, encoded.value);
695
+ if (!sent.ok) this.#lastError = sent.error;
696
+ }
118
697
  }