@forgeax/engine-net 0.1.3 → 0.1.6

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 +860 -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 +88 -28
  39. package/src/session/net-session.ts +612 -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,277 @@ 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 (this.#state.kind === 'recovering') return;
296
+ const limits = this.#replica?.limits ?? DEFAULT_REPLICATION_LIMITS;
297
+ const decoded = decodeReplicationPacket(data, limits);
298
+ if (!decoded.ok) {
299
+ if (this.#replica === undefined) {
300
+ this.#queueRawMessage(peerId, data);
301
+ return;
302
+ }
303
+ errors.push(decoded.error);
304
+ this.#setFailure(decoded.error as NetErrorType);
305
+ return;
306
+ }
307
+ if (decoded.value.kind === 'session-open' || decoded.value.kind === 'session-resume') {
308
+ this.#bindSession(decoded.value.sessionId, peerId);
309
+ return;
310
+ }
311
+ if (decoded.value.kind === 'ack') {
312
+ const handled = this.#handleAck(decoded.value);
313
+ if (!handled.ok) {
314
+ errors.push(handled.error);
315
+ this.#setFailure(handled.error);
316
+ }
317
+ return;
318
+ }
319
+ if (decoded.value.kind !== 'baseline' && decoded.value.kind !== 'delta') {
320
+ if (decoded.value.kind === 'rejection') {
321
+ const failure = recoveryFailure(
322
+ `peer rejected ${decoded.value.rejectedKind}: ${decoded.value.reason}`,
323
+ );
324
+ errors.push(failure);
325
+ this.#setFailure(failure);
326
+ }
327
+ return;
328
+ }
329
+ if (this.#replica === undefined) {
330
+ this.#queueRawMessage(peerId, data);
331
+ return;
332
+ }
333
+ const applied = decodeAndApplyReplicationPacket(
334
+ this.#replica.coordinator,
335
+ data,
336
+ this.#replica.limits,
337
+ );
338
+ if (!applied.ok) {
339
+ errors.push(applied.error);
340
+ this.#setFailure(applied.error);
341
+ return;
342
+ }
343
+ const packetOutcome = this.#replica.coordinator.lastPacketOutcome;
344
+ if (packetOutcome === 'accepted') {
345
+ this.#epoch = decoded.value.epoch;
346
+ this.#sequence = decoded.value.sequence;
347
+ this.#acknowledgedSequence = decoded.value.sequence;
348
+ this.#setState({
349
+ kind: 'active',
350
+ sessionId: this.#sessionId,
351
+ epoch: this.#epoch,
352
+ sequence: this.#sequence,
353
+ });
354
+ }
355
+ if (packetOutcome === 'accepted' || packetOutcome === 'duplicate')
356
+ this.#sendReplicationAck(peerId, decoded.value);
41
357
  }
42
358
 
43
359
  receiveEvents(): readonly NetError[] {
44
360
  const errors: NetError[] = [];
45
- for (const event of this.#endpoint.poll()) {
361
+ if (this.#disposed || this.#state.kind === 'failed' || this.#state.kind === 'retired')
362
+ return errors;
363
+ for (const event of this.#endpoint?.poll() ?? []) {
46
364
  if (event.kind === 'peer-connected') {
47
365
  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.
366
+ if (this.#replica !== undefined) this.#bindSession(this.#sessionId, event.peerId);
367
+ else this.#bindSession(this.#sessionForPeer(event.peerId), event.peerId);
50
368
  this.#pendingFullPeers.add(event.peerId);
51
369
  } 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 {
370
+ this.#forgetPeer(event.peerId);
56
371
  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 });
372
+ this.#replica.coordinator.clear();
373
+ this.#beginRecovery();
374
+ this.advanceRecovery();
65
375
  }
66
- }
376
+ } else this.#receiveMessage(event.peerId, event.data, errors);
67
377
  }
68
378
  return errors;
69
379
  }
@@ -77,9 +387,92 @@ export class NetSession {
77
387
  return { peerIds, connected: peerIds.length > 0 };
78
388
  }
79
389
 
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);
390
+ getSessionSnapshot(): SessionSnapshot {
391
+ const sessionIds = [...this.#sessionPeers.keys()].sort((left, right) => left - right);
392
+ return { sessionIds, connected: sessionIds.length > 0 };
393
+ }
394
+
395
+ /** Return lifecycle, epoch, sequence, ledger, and owned-resource evidence. */
396
+ getRecoverySnapshot(): NetRecoverySnapshot {
397
+ return {
398
+ sessionId: this.#sessionId,
399
+ state: this.#state,
400
+ pendingPackets: this.#ledger.size,
401
+ maxPendingPackets: this.#policy.maxPendingPackets,
402
+ acknowledgedSequence: this.#acknowledgedSequence,
403
+ reconnectAttempts: this.#reconnectAttempts,
404
+ epoch: this.#epoch,
405
+ sequence: this.#sequence,
406
+ ...(this.#lastError === undefined ? {} : { lastError: this.#lastError }),
407
+ ownedResources: {
408
+ pendingConnects: this.#pendingConnect === undefined ? 0 : 1,
409
+ timers: this.#retryTimer === undefined ? 0 : 1,
410
+ ledgers: this.#ledger.size === 0 ? 0 : 1,
411
+ callbacks: 0,
412
+ },
413
+ };
414
+ }
415
+
416
+ getResourceSnapshot(): NetSessionResourceCounts {
417
+ return this.getRecoverySnapshot().ownedResources;
418
+ }
419
+
420
+ recover(): NetRecoveryOutcome {
421
+ if (this.#state.kind === 'retired' || this.#state.kind === 'failed')
422
+ return { kind: 'retired', sessionId: this.#sessionId };
423
+ if (this.#state.kind === 'recovering')
424
+ return { kind: 'already-recovering', sessionId: this.#sessionId };
425
+ this.#beginRecovery();
426
+ return { kind: 'started', sessionId: this.#sessionId };
427
+ }
428
+
429
+ advanceRecovery(): void {
430
+ if (this.#state.kind !== 'recovering') return;
431
+ const delay =
432
+ this.#policy.reconnectDelaysMs[
433
+ Math.min(this.#reconnectAttempts, this.#policy.reconnectDelaysMs.length - 1)
434
+ ];
435
+ if (delay === undefined || delay === 0) this.#attemptRecovery();
436
+ else {
437
+ this.#retryTimer?.cancel();
438
+ this.#retryTimer = this.#clock.schedule(delay, () => {
439
+ this.#retryTimer = undefined;
440
+ this.#attemptRecovery();
441
+ });
442
+ }
443
+ }
444
+
445
+ sendRaw(peerId: PeerId, data: Uint8Array): Result<void, EndpointError | NetError> {
446
+ if (this.#state.kind !== 'active') return err(recoveryFailure('session is not active'));
447
+ return this.#sendToPeer(peerId, data);
448
+ }
449
+
450
+ /** Send one application command through the current replica attachment. */
451
+ sendToAuthority(sessionId: SessionId, data: Uint8Array): Result<void, EndpointError | NetError> {
452
+ if (sessionId !== this.#sessionId)
453
+ return err(recoveryFailure('session id does not belong to this NetSession'));
454
+ if (
455
+ this.#state.kind === 'recovering' ||
456
+ this.#state.kind === 'failed' ||
457
+ this.#state.kind === 'retired'
458
+ )
459
+ return err(recoveryFailure('session is not connected to the authority'));
460
+ const peerId = this.#peerForSession(sessionId);
461
+ if (peerId === undefined) return err(recoveryFailure('authority peer is not connected'));
462
+ if (this.#replica !== undefined) {
463
+ const announced = this.#announceSession(peerId);
464
+ if (!announced.ok) return announced;
465
+ }
466
+ return this.#sendToPeer(peerId, data);
467
+ }
468
+
469
+ /** Send one application message to an authority-owned logical session. */
470
+ sendToSession(sessionId: SessionId, data: Uint8Array): Result<void, EndpointError | NetError> {
471
+ if (this.#state.kind === 'failed' || this.#state.kind === 'retired')
472
+ return err(recoveryFailure('session is not connected to the authority'));
473
+ const peerId = this.#peerForSession(sessionId);
474
+ if (peerId === undefined) return err(recoveryFailure('logical session is not connected'));
475
+ return this.#sendToPeer(peerId, data);
83
476
  }
84
477
 
85
478
  attachAuthority(authority: AuthorityCoordinator): void {
@@ -90,29 +483,210 @@ export class NetSession {
90
483
  if (this.#peerIds.has(peerId)) this.#pendingFullPeers.add(peerId);
91
484
  }
92
485
 
486
+ requestFullBaselineForSession(sessionId: SessionId): void {
487
+ const peerId = this.#sessionPeers.get(sessionId);
488
+ if (peerId !== undefined) this.requestFullBaseline(peerId);
489
+ }
490
+
93
491
  attachReplica(coordinator: ReplicaCoordinator, limits: ReplicationLimits): void {
94
492
  this.#replica = { coordinator, limits };
95
493
  }
96
494
 
495
+ #ledgerBoundError(): NetError {
496
+ return new NetError({
497
+ code: 'recovery-rejected',
498
+ expected: 'published packets within the configured finite ACK bound',
499
+ hint: 'wait for a cumulative ACK before publishing more packets',
500
+ detail: { reason: 'ACK ledger bound reached' },
501
+ });
502
+ }
503
+
504
+ #ensurePublicationCapacity(expectedEpoch: number): Result<void, NetError> {
505
+ if (expectedEpoch === this.#epoch && this.#ledger.size >= this.#policy.maxPendingPackets)
506
+ return err(this.#ledgerBoundError());
507
+ return ok(undefined);
508
+ }
509
+
510
+ #reservePublished(packet: PublishedPacket): Result<void, NetError> {
511
+ if (packet.epoch !== this.#epoch) {
512
+ this.#ledger.clear();
513
+ this.#acknowledgedSequence = 0;
514
+ this.#epoch = packet.epoch;
515
+ }
516
+ if (this.#ledger.size >= this.#policy.maxPendingPackets && !this.#ledger.has(packet.sequence))
517
+ return err(this.#ledgerBoundError());
518
+ this.#sequence = packet.sequence;
519
+ this.#ledger.set(packet.sequence, packet.bytes);
520
+ return ok(undefined);
521
+ }
522
+
523
+ #sendPublished(
524
+ packet: PublishedPacket,
525
+ peerIds: readonly PeerId[],
526
+ ): Result<void, EndpointError | NetError> {
527
+ const reserved = this.#reservePublished(packet);
528
+ if (!reserved.ok) return reserved;
529
+ if (this.#endpoint === undefined) return err(recoveryFailure('session has no endpoint'));
530
+ let delivered = false;
531
+ for (const peerId of peerIds) {
532
+ const sent = this.#endpoint.send(peerId, packet.bytes);
533
+ if (!sent.ok) {
534
+ if (sent.error.code === 'connection-closed') {
535
+ // A socket can close before its endpoint emits the corresponding
536
+ // disconnect event. Treat that transport race as the lifecycle
537
+ // event it represents so one stale peer cannot poison the World or
538
+ // prevent the same publication reaching live peers.
539
+ this.#forgetPeer(peerId);
540
+ continue;
541
+ }
542
+ this.#ledger.delete(packet.sequence);
543
+ return err(sent.error);
544
+ }
545
+ delivered = true;
546
+ }
547
+ if (!delivered) this.#ledger.delete(packet.sequence);
548
+ return ok(undefined);
549
+ }
550
+
97
551
  publish(): Result<void, NetError | EndpointError> {
98
- if (this.#authority === undefined) return ok(undefined);
552
+ // Do not advance the authority ledger before a peer exists. A host can
553
+ // start its fixed loop before the first socket handshake; reserving that
554
+ // empty publication would make the first connected peer wait behind an
555
+ // ACK for bytes it could never receive.
556
+ if (this.#authority === undefined || this.#endpoint === undefined || this.#peerIds.size === 0)
557
+ return ok(undefined);
99
558
  if (this.#pendingFullPeers.size > 0) {
559
+ const capacity = this.#ensurePublicationCapacity(this.#authority.nextPublicationEpoch(true));
560
+ if (!capacity.ok) return capacity;
100
561
  const published = this.#authority.publishFull();
101
562
  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
- }
563
+ const sent = this.#sendPublished(published.value, [...this.#peerIds]);
564
+ if (!sent.ok) return err(sent.error);
108
565
  this.#pendingFullPeers.clear();
566
+ // A fresh baseline is the first packet of the new epoch for every
567
+ // replica. Do not append the same-tick delta: a receiver must be able
568
+ // to observe and apply sequence 1 before any incremental publication.
569
+ return ok(undefined);
109
570
  }
571
+ const capacity = this.#ensurePublicationCapacity(this.#authority.nextPublicationEpoch());
572
+ if (!capacity.ok) return capacity;
110
573
  const published = this.#authority.publish();
111
574
  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);
575
+ const sent = this.#sendPublished(published.value, [...this.#peerIds]);
576
+ if (!sent.ok) return err(sent.error);
577
+ return ok(undefined);
578
+ }
579
+
580
+ dispose(): void {
581
+ if (this.#disposed) return;
582
+ this.#disposed = true;
583
+ this.#clearRecoveryWork();
584
+ this.#endpoint?.close();
585
+ this.#endpoint = undefined;
586
+ this.#replica?.coordinator.clear();
587
+ this.#replica = undefined;
588
+ this.#authority = undefined;
589
+ this.#peerIds.clear();
590
+ this.#sessionPeers.clear();
591
+ this.#announcedPeers.clear();
592
+ this.#sessionAnnounced = false;
593
+ this.#pendingFullPeers.clear();
594
+ this.#rawMessages = [];
595
+ if (this.#state.kind !== 'retired')
596
+ this.#setState({ kind: 'retired', sessionId: this.#sessionId, reason: 'disposed' });
597
+ }
598
+
599
+ #queueRawMessage(peerId: PeerId, data: Uint8Array): void {
600
+ if (this.#rawMessages.length >= this.#maxRawMessages) return;
601
+ this.#rawMessages.push({
602
+ peerId,
603
+ sessionId: this.#sessionForPeer(peerId),
604
+ data: new Uint8Array(data),
605
+ });
606
+ }
607
+
608
+ #sessionForPeer(peerId: PeerId): SessionId {
609
+ for (const [sessionId, mappedPeerId] of this.#sessionPeers)
610
+ if (mappedPeerId === peerId) return sessionId;
611
+ if (this.#replica !== undefined) {
612
+ this.#bindSession(this.#sessionId, peerId);
613
+ return this.#sessionId;
115
614
  }
615
+ const created = createSessionId(peerId);
616
+ const sessionId = created.ok ? created.value : this.#sessionId;
617
+ this.#bindSession(sessionId, peerId);
618
+ return sessionId;
619
+ }
620
+
621
+ #bindSession(sessionId: SessionId, peerId: PeerId): void {
622
+ for (const [mappedSessionId, mappedPeerId] of this.#sessionPeers)
623
+ if (mappedSessionId === sessionId || mappedPeerId === peerId)
624
+ this.#sessionPeers.delete(mappedSessionId);
625
+ this.#sessionPeers.set(sessionId, peerId);
626
+ }
627
+
628
+ #forgetPeer(peerId: PeerId): void {
629
+ this.#peerIds.delete(peerId);
630
+ for (const [sessionId, mappedPeerId] of this.#sessionPeers)
631
+ if (mappedPeerId === peerId) this.#sessionPeers.delete(sessionId);
632
+ this.#announcedPeers.delete(peerId);
633
+ this.#pendingFullPeers.delete(peerId);
634
+ }
635
+
636
+ #peerForSession(sessionId: SessionId): PeerId | undefined {
637
+ const mapped = this.#sessionPeers.get(sessionId);
638
+ if (mapped !== undefined && this.#peerIds.has(mapped)) return mapped;
639
+ if (this.#replica !== undefined && this.#peerIds.size === 1) {
640
+ const peerId = [...this.#peerIds][0];
641
+ if (peerId !== undefined) {
642
+ this.#bindSession(sessionId, peerId);
643
+ return peerId;
644
+ }
645
+ }
646
+ return undefined;
647
+ }
648
+
649
+ #announceSession(peerId: PeerId): Result<void, EndpointError | NetError> {
650
+ if (this.#announcedPeers.has(peerId)) return ok(undefined);
651
+ const packet: ReplicationSessionPacket = {
652
+ version: 2,
653
+ kind: this.#sessionAnnounced ? 'session-resume' : 'session-open',
654
+ sessionId: this.#sessionId,
655
+ epoch: this.#epoch,
656
+ sequence: 0,
657
+ };
658
+ const encoded = encodeReplicationPacket(packet, DEFAULT_REPLICATION_LIMITS);
659
+ if (!encoded.ok) return err(encoded.error);
660
+ const sent = this.#sendToPeer(peerId, encoded.value);
661
+ if (!sent.ok) return sent;
662
+ this.#announcedPeers.add(peerId);
663
+ this.#sessionAnnounced = true;
116
664
  return ok(undefined);
117
665
  }
666
+
667
+ #sendToPeer(peerId: PeerId, data: Uint8Array): Result<void, EndpointError | NetError> {
668
+ const result = this.#endpoint?.send(peerId, data);
669
+ if (result === undefined) return err(recoveryFailure('session has no endpoint'));
670
+ return result.ok ? ok(undefined) : err(result.error);
671
+ }
672
+
673
+ /** ACK accepted data at the session boundary; consumers should not reimplement this wire step. */
674
+ #sendReplicationAck(peerId: PeerId, packet: ReplicationDataPacket): void {
675
+ const encoded = encodeReplicationPacket(
676
+ {
677
+ version: 2,
678
+ kind: 'ack',
679
+ sessionId: packet.sessionId,
680
+ epoch: packet.epoch,
681
+ acknowledgedSequence: packet.sequence,
682
+ },
683
+ DEFAULT_REPLICATION_LIMITS,
684
+ );
685
+ if (!encoded.ok) {
686
+ this.#setFailure(encoded.error);
687
+ return;
688
+ }
689
+ const sent = this.#sendToPeer(peerId, encoded.value);
690
+ if (!sent.ok) this.#lastError = sent.error;
691
+ }
118
692
  }