@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.
- package/README.md +156 -105
- package/dist/.tsbuildinfo +1 -1
- package/dist/endpoint/endpoint.d.ts +7 -0
- package/dist/endpoint/endpoint.d.ts.map +1 -1
- package/dist/endpoint/memory.d.ts +3 -1
- package/dist/endpoint/memory.d.ts.map +1 -1
- package/dist/index.d.ts +11 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +860 -117
- package/dist/index.mjs.map +1 -1
- package/dist/replication/authority.d.ts +8 -6
- package/dist/replication/authority.d.ts.map +1 -1
- package/dist/replication/codec.d.ts +4 -22
- package/dist/replication/codec.d.ts.map +1 -1
- package/dist/replication/constants.d.ts +4 -1
- package/dist/replication/constants.d.ts.map +1 -1
- package/dist/replication/errors.d.ts +24 -4
- package/dist/replication/errors.d.ts.map +1 -1
- package/dist/replication/protocol.d.ts +63 -0
- package/dist/replication/protocol.d.ts.map +1 -0
- package/dist/replication/replica.d.ts +9 -6
- package/dist/replication/replica.d.ts.map +1 -1
- package/dist/session/net-session.d.ts +40 -6
- package/dist/session/net-session.d.ts.map +1 -1
- package/dist/session/recovery.d.ts +93 -0
- package/dist/session/recovery.d.ts.map +1 -0
- package/dist/session/session-plugin.d.ts +8 -2
- package/dist/session/session-plugin.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/endpoint/endpoint.ts +8 -0
- package/src/endpoint/memory.ts +23 -1
- package/src/index.ts +56 -12
- package/src/replication/authority.ts +55 -23
- package/src/replication/codec.ts +167 -101
- package/src/replication/constants.ts +5 -1
- package/src/replication/errors.ts +22 -4
- package/src/replication/protocol.ts +86 -0
- package/src/replication/replica.ts +88 -28
- package/src/session/net-session.ts +612 -38
- package/src/session/recovery.ts +204 -0
- 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
|
|
7
|
-
import type { AuthorityCoordinator } from '../replication/authority';
|
|
8
|
-
import
|
|
9
|
-
import type
|
|
10
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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.#
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
81
|
-
const
|
|
82
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
}
|