@hla4ts/session 0.1.0 → 0.1.1
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 +377 -377
- package/package.json +3 -3
- package/src/errors.ts +98 -98
- package/src/index.ts +147 -147
- package/src/messages.ts +329 -329
- package/src/sequence-number.ts +200 -200
- package/src/session.ts +976 -976
- package/src/timeout-timer.ts +204 -204
- package/src/types.ts +235 -235
package/src/session.ts
CHANGED
|
@@ -1,976 +1,976 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Federate Protocol Session
|
|
3
|
-
*
|
|
4
|
-
* Manages the lifecycle of a Federate Protocol session, including:
|
|
5
|
-
* - Connection establishment and termination
|
|
6
|
-
* - Session state machine
|
|
7
|
-
* - Request/response correlation
|
|
8
|
-
* - Heartbeat/keepalive
|
|
9
|
-
* - Session resumption after disconnection
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import {
|
|
13
|
-
type Transport,
|
|
14
|
-
type ReceivedMessage,
|
|
15
|
-
MessageType,
|
|
16
|
-
NO_SESSION_ID,
|
|
17
|
-
NewSessionStatus,
|
|
18
|
-
} from "@hla4ts/transport";
|
|
19
|
-
|
|
20
|
-
import {
|
|
21
|
-
SessionState,
|
|
22
|
-
isValidTransition,
|
|
23
|
-
isOperationalState,
|
|
24
|
-
type SessionOptions,
|
|
25
|
-
type HlaCallbackRequestListener,
|
|
26
|
-
type SessionStateListener,
|
|
27
|
-
type MessageSentListener,
|
|
28
|
-
DEFAULT_SESSION_OPTIONS,
|
|
29
|
-
} from "./types.ts";
|
|
30
|
-
|
|
31
|
-
import {
|
|
32
|
-
SequenceNumber,
|
|
33
|
-
AtomicSequenceNumber,
|
|
34
|
-
INITIAL_SEQUENCE_NUMBER,
|
|
35
|
-
NO_SEQUENCE_NUMBER,
|
|
36
|
-
isValidSequenceNumber,
|
|
37
|
-
nextSequenceNumber,
|
|
38
|
-
} from "./sequence-number.ts";
|
|
39
|
-
|
|
40
|
-
import { TimeoutTimer } from "./timeout-timer.ts";
|
|
41
|
-
|
|
42
|
-
import {
|
|
43
|
-
createNewSessionMessage,
|
|
44
|
-
decodeNewSessionStatus,
|
|
45
|
-
createResumeRequestMessage,
|
|
46
|
-
decodeResumeStatus,
|
|
47
|
-
ResumeStatusCode,
|
|
48
|
-
createHeartbeatMessage,
|
|
49
|
-
decodeHeartbeatResponse,
|
|
50
|
-
createTerminateSessionMessage,
|
|
51
|
-
createHlaCallRequestMessage,
|
|
52
|
-
decodeHlaCallResponse,
|
|
53
|
-
decodeHlaCallbackRequest,
|
|
54
|
-
createHlaCallbackResponseMessage,
|
|
55
|
-
} from "./messages.ts";
|
|
56
|
-
|
|
57
|
-
import {
|
|
58
|
-
SessionLostError,
|
|
59
|
-
SessionAlreadyTerminatedError,
|
|
60
|
-
SessionIllegalStateError,
|
|
61
|
-
BadMessageError,
|
|
62
|
-
ConnectionTimeoutError,
|
|
63
|
-
} from "./errors.ts";
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Deferred promise helper for request/response correlation
|
|
67
|
-
*/
|
|
68
|
-
interface DeferredPromise<T> {
|
|
69
|
-
promise: Promise<T>;
|
|
70
|
-
resolve: (value: T) => void;
|
|
71
|
-
reject: (reason: Error) => void;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function createDeferred<T>(): DeferredPromise<T> {
|
|
75
|
-
let resolve!: (value: T) => void;
|
|
76
|
-
let reject!: (reason: Error) => void;
|
|
77
|
-
const promise = new Promise<T>((res, rej) => {
|
|
78
|
-
resolve = res;
|
|
79
|
-
reject = rej;
|
|
80
|
-
});
|
|
81
|
-
return { promise, resolve, reject };
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Federate Protocol Session
|
|
86
|
-
*
|
|
87
|
-
* Manages a session with an HLA 4 RTI using the Federate Protocol.
|
|
88
|
-
*
|
|
89
|
-
* @example
|
|
90
|
-
* ```ts
|
|
91
|
-
* const transport = new TlsTransport({ host: 'rti.example.com', port: 15165 });
|
|
92
|
-
* const session = new Session(transport);
|
|
93
|
-
*
|
|
94
|
-
* session.addStateListener((oldState, newState, reason) => {
|
|
95
|
-
* console.log(`State: ${oldState} -> ${newState} (${reason})`);
|
|
96
|
-
* });
|
|
97
|
-
*
|
|
98
|
-
* await session.start({
|
|
99
|
-
* onHlaCallbackRequest: (seqNum, callback) => {
|
|
100
|
-
* // Handle callback
|
|
101
|
-
* session.sendHlaCallbackResponse(seqNum, response);
|
|
102
|
-
* }
|
|
103
|
-
* });
|
|
104
|
-
*
|
|
105
|
-
* // Send HLA calls
|
|
106
|
-
* const response = await session.sendHlaCallRequest(encodedCall);
|
|
107
|
-
*
|
|
108
|
-
* // When done
|
|
109
|
-
* await session.terminate();
|
|
110
|
-
* ```
|
|
111
|
-
*/
|
|
112
|
-
export class Session {
|
|
113
|
-
private readonly _transport: Transport;
|
|
114
|
-
private readonly _options: Required<SessionOptions>;
|
|
115
|
-
|
|
116
|
-
// Session state
|
|
117
|
-
private _state: SessionState = SessionState.NEW;
|
|
118
|
-
private _sessionId: bigint = NO_SESSION_ID;
|
|
119
|
-
|
|
120
|
-
// Sequence number tracking
|
|
121
|
-
private readonly _nextOutgoingSeqNum = new SequenceNumber(INITIAL_SEQUENCE_NUMBER);
|
|
122
|
-
private readonly _lastReceivedSeqNum = new AtomicSequenceNumber(NO_SEQUENCE_NUMBER);
|
|
123
|
-
|
|
124
|
-
// Request/response correlation
|
|
125
|
-
private readonly _pendingRequests = new Map<number, DeferredPromise<Uint8Array>>();
|
|
126
|
-
private _terminationDeferred: DeferredPromise<void> | null = null;
|
|
127
|
-
|
|
128
|
-
// Timers
|
|
129
|
-
private _sessionTimeoutTimer: TimeoutTimer | null = null;
|
|
130
|
-
private _connectionTimeoutTimer: TimeoutTimer | null = null;
|
|
131
|
-
private _heartbeatIntervalHandle: ReturnType<typeof setInterval> | null = null;
|
|
132
|
-
|
|
133
|
-
// Callbacks
|
|
134
|
-
private _hlaCallbackListener: HlaCallbackRequestListener | null = null;
|
|
135
|
-
private readonly _stateListeners: SessionStateListener[] = [];
|
|
136
|
-
private _messageSentListener: MessageSentListener | null = null;
|
|
137
|
-
|
|
138
|
-
// Message history for resumption
|
|
139
|
-
private readonly _sentMessageHistory: Map<number, Uint8Array> = new Map();
|
|
140
|
-
private _oldestHistorySeqNum: number = INITIAL_SEQUENCE_NUMBER;
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Create a new session.
|
|
144
|
-
*
|
|
145
|
-
* @param transport - The transport to use for communication
|
|
146
|
-
* @param options - Session configuration options
|
|
147
|
-
*/
|
|
148
|
-
constructor(transport: Transport, options: SessionOptions = {}) {
|
|
149
|
-
this._transport = transport;
|
|
150
|
-
this._options = { ...DEFAULT_SESSION_OPTIONS, ...options };
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// ==========================================================================
|
|
154
|
-
// Public API
|
|
155
|
-
// ==========================================================================
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Get the session ID (0 if not yet established)
|
|
159
|
-
*/
|
|
160
|
-
get id(): bigint {
|
|
161
|
-
return this._sessionId;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Get the current session state
|
|
166
|
-
*/
|
|
167
|
-
get state(): SessionState {
|
|
168
|
-
return this._state;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Check if the session is in a state where operations are allowed
|
|
173
|
-
*/
|
|
174
|
-
get isOperational(): boolean {
|
|
175
|
-
return isOperationalState(this._state);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/**
|
|
179
|
-
* Add a state change listener
|
|
180
|
-
*/
|
|
181
|
-
addStateListener(listener: SessionStateListener): void {
|
|
182
|
-
this._stateListeners.push(listener);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Set the message sent listener
|
|
187
|
-
*/
|
|
188
|
-
setMessageSentListener(listener: MessageSentListener): void {
|
|
189
|
-
this._messageSentListener = listener;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* Start the session by connecting to the RTI and establishing a new session.
|
|
194
|
-
*
|
|
195
|
-
* @param callbackListener - Handler for incoming HLA callbacks
|
|
196
|
-
* @throws SessionLostError if the connection cannot be established
|
|
197
|
-
* @throws SessionIllegalStateError if not in NEW state
|
|
198
|
-
*/
|
|
199
|
-
async start(callbackListener: HlaCallbackRequestListener): Promise<void> {
|
|
200
|
-
// Validate state
|
|
201
|
-
const beforeState = this._compareAndSetState(
|
|
202
|
-
SessionState.NEW,
|
|
203
|
-
SessionState.STARTING,
|
|
204
|
-
"Starting session"
|
|
205
|
-
);
|
|
206
|
-
|
|
207
|
-
if (beforeState === SessionState.TERMINATED) {
|
|
208
|
-
throw new SessionAlreadyTerminatedError("Cannot start a terminated session");
|
|
209
|
-
}
|
|
210
|
-
if (beforeState !== SessionState.NEW) {
|
|
211
|
-
throw new SessionIllegalStateError(beforeState, "start");
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
this._hlaCallbackListener = callbackListener;
|
|
215
|
-
|
|
216
|
-
try {
|
|
217
|
-
await this._performConnect();
|
|
218
|
-
} catch (error) {
|
|
219
|
-
this._compareAndSetState(
|
|
220
|
-
SessionState.STARTING,
|
|
221
|
-
SessionState.TERMINATED,
|
|
222
|
-
`Connection failed: ${error}`
|
|
223
|
-
);
|
|
224
|
-
throw error;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
// Start session timeout timer
|
|
228
|
-
this._sessionTimeoutTimer = TimeoutTimer.createLazy(this._options.responseTimeout);
|
|
229
|
-
this._sessionTimeoutTimer.start(() => this._handleSessionTimeout());
|
|
230
|
-
|
|
231
|
-
// Start periodic heartbeat sending
|
|
232
|
-
this._startHeartbeatTimer();
|
|
233
|
-
|
|
234
|
-
// Transition to running
|
|
235
|
-
this._compareAndSetState(SessionState.STARTING, SessionState.RUNNING, "Session established");
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* Resume a dropped session.
|
|
240
|
-
*
|
|
241
|
-
* @returns true if resume succeeded
|
|
242
|
-
* @throws SessionLostError if the server refuses to resume
|
|
243
|
-
* @throws SessionIllegalStateError if not in DROPPED state
|
|
244
|
-
*/
|
|
245
|
-
async resume(): Promise<boolean> {
|
|
246
|
-
const beforeState = this._compareAndSetState(
|
|
247
|
-
SessionState.DROPPED,
|
|
248
|
-
SessionState.RESUMING,
|
|
249
|
-
"Attempting resume"
|
|
250
|
-
);
|
|
251
|
-
|
|
252
|
-
if (beforeState === SessionState.TERMINATED) {
|
|
253
|
-
throw new SessionAlreadyTerminatedError("Cannot resume a terminated session");
|
|
254
|
-
}
|
|
255
|
-
if (beforeState !== SessionState.DROPPED) {
|
|
256
|
-
throw new SessionIllegalStateError(beforeState, "resume");
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
try {
|
|
260
|
-
await this._performResume();
|
|
261
|
-
// Restart heartbeat timer after resume
|
|
262
|
-
this._startHeartbeatTimer();
|
|
263
|
-
this._compareAndSetState(SessionState.RESUMING, SessionState.RUNNING, "Session resumed");
|
|
264
|
-
return true;
|
|
265
|
-
} catch (error) {
|
|
266
|
-
if (error instanceof SessionLostError) {
|
|
267
|
-
this._compareAndSetState(
|
|
268
|
-
SessionState.RESUMING,
|
|
269
|
-
SessionState.TERMINATED,
|
|
270
|
-
`Resume failed: ${error.message}`
|
|
271
|
-
);
|
|
272
|
-
throw error;
|
|
273
|
-
}
|
|
274
|
-
// Network error - back to dropped state
|
|
275
|
-
this._compareAndSetState(
|
|
276
|
-
SessionState.RESUMING,
|
|
277
|
-
SessionState.DROPPED,
|
|
278
|
-
`Resume connection failed: ${error}`
|
|
279
|
-
);
|
|
280
|
-
throw error;
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
/**
|
|
285
|
-
* Send a heartbeat message.
|
|
286
|
-
*
|
|
287
|
-
* @returns Promise that resolves when the heartbeat response is received
|
|
288
|
-
* @throws SessionIllegalStateError if not in operational state
|
|
289
|
-
*/
|
|
290
|
-
async sendHeartbeat(): Promise<void> {
|
|
291
|
-
this._validateOperationalState("send heartbeat");
|
|
292
|
-
|
|
293
|
-
const seqNum = this._nextOutgoingSeqNum.getAndIncrement();
|
|
294
|
-
const message = createHeartbeatMessage(
|
|
295
|
-
seqNum,
|
|
296
|
-
this._sessionId,
|
|
297
|
-
this._lastReceivedSeqNum.get()
|
|
298
|
-
);
|
|
299
|
-
|
|
300
|
-
const deferred = createDeferred<Uint8Array>();
|
|
301
|
-
this._pendingRequests.set(seqNum, deferred);
|
|
302
|
-
|
|
303
|
-
try {
|
|
304
|
-
await this._sendMessage(message, seqNum);
|
|
305
|
-
await deferred.promise;
|
|
306
|
-
} finally {
|
|
307
|
-
this._pendingRequests.delete(seqNum);
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
/**
|
|
312
|
-
* Send an HLA call request.
|
|
313
|
-
*
|
|
314
|
-
* @param encodedHlaCall - Protobuf-encoded HLA call request
|
|
315
|
-
* @returns Promise that resolves with the protobuf-encoded response
|
|
316
|
-
* @throws SessionIllegalStateError if not in operational state
|
|
317
|
-
*/
|
|
318
|
-
async sendHlaCallRequest(encodedHlaCall: Uint8Array): Promise<Uint8Array> {
|
|
319
|
-
this._validateOperationalState("send HLA call");
|
|
320
|
-
|
|
321
|
-
const seqNum = this._nextOutgoingSeqNum.getAndIncrement();
|
|
322
|
-
const message = createHlaCallRequestMessage(
|
|
323
|
-
seqNum,
|
|
324
|
-
this._sessionId,
|
|
325
|
-
this._lastReceivedSeqNum.get(),
|
|
326
|
-
encodedHlaCall
|
|
327
|
-
);
|
|
328
|
-
|
|
329
|
-
const deferred = createDeferred<Uint8Array>();
|
|
330
|
-
this._pendingRequests.set(seqNum, deferred);
|
|
331
|
-
|
|
332
|
-
try {
|
|
333
|
-
await this._sendMessage(message, seqNum);
|
|
334
|
-
return await deferred.promise;
|
|
335
|
-
} catch (error) {
|
|
336
|
-
this._pendingRequests.delete(seqNum);
|
|
337
|
-
throw error;
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
/**
|
|
342
|
-
* Send an HLA callback response.
|
|
343
|
-
*
|
|
344
|
-
* @param responseToSequenceNumber - Sequence number of the callback request
|
|
345
|
-
* @param encodedResponse - Protobuf-encoded callback response
|
|
346
|
-
* @throws SessionIllegalStateError if not in operational state
|
|
347
|
-
*/
|
|
348
|
-
async sendHlaCallbackResponse(
|
|
349
|
-
responseToSequenceNumber: number,
|
|
350
|
-
encodedResponse: Uint8Array
|
|
351
|
-
): Promise<void> {
|
|
352
|
-
this._validateOperationalState("send callback response");
|
|
353
|
-
|
|
354
|
-
const seqNum = this._nextOutgoingSeqNum.getAndIncrement();
|
|
355
|
-
const message = createHlaCallbackResponseMessage(
|
|
356
|
-
seqNum,
|
|
357
|
-
this._sessionId,
|
|
358
|
-
this._lastReceivedSeqNum.get(),
|
|
359
|
-
responseToSequenceNumber,
|
|
360
|
-
encodedResponse
|
|
361
|
-
);
|
|
362
|
-
|
|
363
|
-
await this._sendMessage(message, seqNum);
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
/**
|
|
367
|
-
* Terminate the session gracefully.
|
|
368
|
-
*
|
|
369
|
-
* @param timeoutMs - Timeout for termination (default: responseTimeout)
|
|
370
|
-
* @throws SessionLostError if termination times out
|
|
371
|
-
* @throws SessionIllegalStateError if in invalid state
|
|
372
|
-
*/
|
|
373
|
-
async terminate(timeoutMs?: number): Promise<void> {
|
|
374
|
-
const timeout = timeoutMs ?? this._options.responseTimeout;
|
|
375
|
-
|
|
376
|
-
// Wait if resuming
|
|
377
|
-
if (this._state === SessionState.RESUMING) {
|
|
378
|
-
await this._waitForStateChange();
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
// If already terminated, nothing to do
|
|
382
|
-
if (this._state === SessionState.TERMINATED) {
|
|
383
|
-
return;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
const beforeState = this._compareAndSetState(
|
|
387
|
-
SessionState.RUNNING,
|
|
388
|
-
SessionState.TERMINATING,
|
|
389
|
-
"Terminating session"
|
|
390
|
-
);
|
|
391
|
-
|
|
392
|
-
// Handle DROPPED state
|
|
393
|
-
if (beforeState === SessionState.DROPPED) {
|
|
394
|
-
this._compareAndSetState(
|
|
395
|
-
SessionState.DROPPED,
|
|
396
|
-
SessionState.TERMINATED,
|
|
397
|
-
"Terminated dropped session"
|
|
398
|
-
);
|
|
399
|
-
return;
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
if (beforeState !== SessionState.RUNNING) {
|
|
403
|
-
throw new SessionIllegalStateError(beforeState, "terminate");
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
// Send termination message
|
|
407
|
-
this._terminationDeferred = createDeferred<void>();
|
|
408
|
-
const seqNum = this._nextOutgoingSeqNum.getAndIncrement();
|
|
409
|
-
const message = createTerminateSessionMessage(
|
|
410
|
-
seqNum,
|
|
411
|
-
this._sessionId,
|
|
412
|
-
this._lastReceivedSeqNum.get()
|
|
413
|
-
);
|
|
414
|
-
|
|
415
|
-
try {
|
|
416
|
-
await this._sendMessage(message, seqNum);
|
|
417
|
-
|
|
418
|
-
// Wait for termination response with timeout
|
|
419
|
-
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
420
|
-
setTimeout(
|
|
421
|
-
() => reject(new SessionLostError("Termination timed out")),
|
|
422
|
-
timeout
|
|
423
|
-
);
|
|
424
|
-
});
|
|
425
|
-
|
|
426
|
-
await Promise.race([this._terminationDeferred.promise, timeoutPromise]);
|
|
427
|
-
} catch (error) {
|
|
428
|
-
// Session is terminated either way
|
|
429
|
-
throw error;
|
|
430
|
-
} finally {
|
|
431
|
-
this._compareAndSetState(
|
|
432
|
-
SessionState.TERMINATING,
|
|
433
|
-
SessionState.TERMINATED,
|
|
434
|
-
"Session terminated"
|
|
435
|
-
);
|
|
436
|
-
this._cleanup();
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
// ==========================================================================
|
|
441
|
-
// Connection and Session Establishment
|
|
442
|
-
// ==========================================================================
|
|
443
|
-
|
|
444
|
-
/**
|
|
445
|
-
* Perform the initial connection and session establishment
|
|
446
|
-
*/
|
|
447
|
-
private async _performConnect(): Promise<void> {
|
|
448
|
-
// Set up transport handlers
|
|
449
|
-
this._transport.setEventHandlers({
|
|
450
|
-
onMessage: (msg) => this._handleMessage(msg),
|
|
451
|
-
onClose: (hadError) => this._handleDisconnect(hadError),
|
|
452
|
-
onError: (err) => this._handleTransportError(err),
|
|
453
|
-
});
|
|
454
|
-
|
|
455
|
-
// Connect with timeout
|
|
456
|
-
const connectionTimeout = this._options.connectionTimeout;
|
|
457
|
-
this._connectionTimeoutTimer = TimeoutTimer.createLazy(connectionTimeout);
|
|
458
|
-
|
|
459
|
-
let connectionPromise: Promise<void>;
|
|
460
|
-
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
461
|
-
this._connectionTimeoutTimer!.start(() => {
|
|
462
|
-
reject(new ConnectionTimeoutError(connectionTimeout));
|
|
463
|
-
});
|
|
464
|
-
});
|
|
465
|
-
|
|
466
|
-
try {
|
|
467
|
-
// Connect to transport
|
|
468
|
-
connectionPromise = this._transport.connect();
|
|
469
|
-
await Promise.race([connectionPromise, timeoutPromise]);
|
|
470
|
-
|
|
471
|
-
// Send new session message
|
|
472
|
-
const newSessionMsg = createNewSessionMessage();
|
|
473
|
-
await this._transport.send(newSessionMsg);
|
|
474
|
-
|
|
475
|
-
// Wait for new session status response
|
|
476
|
-
const statusResponse = await this._waitForNewSessionStatus();
|
|
477
|
-
|
|
478
|
-
if (statusResponse.status !== NewSessionStatus.SUCCESS) {
|
|
479
|
-
throw new SessionLostError(
|
|
480
|
-
`Server unable to create session, reason: ${statusResponse.status}`
|
|
481
|
-
);
|
|
482
|
-
}
|
|
483
|
-
} finally {
|
|
484
|
-
this._connectionTimeoutTimer?.cancel();
|
|
485
|
-
this._connectionTimeoutTimer = null;
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
/**
|
|
490
|
-
* Wait for the CTRL_NEW_SESSION_STATUS response
|
|
491
|
-
*/
|
|
492
|
-
private _waitForNewSessionStatus(): Promise<{ status: number; sessionId: bigint }> {
|
|
493
|
-
return new Promise((resolve, reject) => {
|
|
494
|
-
const restoreHandlers = () => {
|
|
495
|
-
// Restore normal message handlers after handshake
|
|
496
|
-
this._transport.setEventHandlers({
|
|
497
|
-
onMessage: (msg) => this._handleMessage(msg),
|
|
498
|
-
onClose: (hadError) => this._handleDisconnect(hadError),
|
|
499
|
-
onError: (err) => this._handleTransportError(err),
|
|
500
|
-
});
|
|
501
|
-
};
|
|
502
|
-
|
|
503
|
-
const handleStatus = (msg: ReceivedMessage) => {
|
|
504
|
-
if (msg.header.messageType === MessageType.CTRL_NEW_SESSION_STATUS) {
|
|
505
|
-
const status = decodeNewSessionStatus(msg.payload);
|
|
506
|
-
this._sessionId = msg.header.sessionId;
|
|
507
|
-
this._extendSessionTimer();
|
|
508
|
-
restoreHandlers(); // Restore handlers before resolving
|
|
509
|
-
resolve({ status, sessionId: msg.header.sessionId });
|
|
510
|
-
} else {
|
|
511
|
-
restoreHandlers(); // Restore handlers before rejecting
|
|
512
|
-
reject(new BadMessageError(
|
|
513
|
-
`Expected CTRL_NEW_SESSION_STATUS, got ${MessageType[msg.header.messageType]}`
|
|
514
|
-
));
|
|
515
|
-
}
|
|
516
|
-
};
|
|
517
|
-
|
|
518
|
-
// Temporarily replace handler for handshake
|
|
519
|
-
this._transport.setEventHandlers({
|
|
520
|
-
onMessage: handleStatus,
|
|
521
|
-
onClose: () => {
|
|
522
|
-
restoreHandlers();
|
|
523
|
-
reject(new SessionLostError("Connection closed during handshake"));
|
|
524
|
-
},
|
|
525
|
-
onError: (err) => {
|
|
526
|
-
restoreHandlers();
|
|
527
|
-
reject(new SessionLostError(`Transport error: ${err.message}`, err));
|
|
528
|
-
},
|
|
529
|
-
});
|
|
530
|
-
});
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
/**
|
|
534
|
-
* Perform session resumption
|
|
535
|
-
*/
|
|
536
|
-
private async _performResume(): Promise<void> {
|
|
537
|
-
const lastReceivedRtiMessage = this._lastReceivedSeqNum.get();
|
|
538
|
-
const oldestAvailableFederateMessage = this._oldestHistorySeqNum;
|
|
539
|
-
|
|
540
|
-
// Reconnect transport
|
|
541
|
-
await this._transport.connect();
|
|
542
|
-
|
|
543
|
-
// Send resume request
|
|
544
|
-
const resumeMsg = createResumeRequestMessage(
|
|
545
|
-
this._sessionId,
|
|
546
|
-
lastReceivedRtiMessage,
|
|
547
|
-
oldestAvailableFederateMessage
|
|
548
|
-
);
|
|
549
|
-
await this._transport.send(resumeMsg);
|
|
550
|
-
|
|
551
|
-
// Resume session timer
|
|
552
|
-
this._sessionTimeoutTimer?.resume();
|
|
553
|
-
|
|
554
|
-
// Wait for resume status
|
|
555
|
-
const statusResponse = await this._waitForResumeStatus();
|
|
556
|
-
|
|
557
|
-
if (statusResponse.status !== ResumeStatusCode.OK_TO_RESUME) {
|
|
558
|
-
throw new SessionLostError(
|
|
559
|
-
`Server refused to resume session, status: ${statusResponse.status}`
|
|
560
|
-
);
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
// Retransmit messages that the server hasn't received
|
|
564
|
-
const resumeFromNumber = nextSequenceNumber(statusResponse.lastReceivedFederateSequenceNumber);
|
|
565
|
-
await this._retransmitMessages(resumeFromNumber);
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
/**
|
|
569
|
-
* Wait for CTRL_RESUME_STATUS response
|
|
570
|
-
*/
|
|
571
|
-
private _waitForResumeStatus(): Promise<{ status: number; lastReceivedFederateSequenceNumber: number }> {
|
|
572
|
-
return new Promise((resolve, reject) => {
|
|
573
|
-
const restoreHandlers = () => {
|
|
574
|
-
// Restore normal message handlers after resume handshake
|
|
575
|
-
this._transport.setEventHandlers({
|
|
576
|
-
onMessage: (msg) => this._handleMessage(msg),
|
|
577
|
-
onClose: (hadError) => this._handleDisconnect(hadError),
|
|
578
|
-
onError: (err) => this._handleTransportError(err),
|
|
579
|
-
});
|
|
580
|
-
};
|
|
581
|
-
|
|
582
|
-
const handleStatus = (msg: ReceivedMessage) => {
|
|
583
|
-
if (msg.header.messageType === MessageType.CTRL_RESUME_STATUS) {
|
|
584
|
-
this._extendSessionTimer();
|
|
585
|
-
const result = decodeResumeStatus(msg.payload);
|
|
586
|
-
restoreHandlers(); // Restore handlers before resolving
|
|
587
|
-
resolve(result);
|
|
588
|
-
} else {
|
|
589
|
-
restoreHandlers(); // Restore handlers before rejecting
|
|
590
|
-
reject(new BadMessageError(
|
|
591
|
-
`Expected CTRL_RESUME_STATUS, got ${MessageType[msg.header.messageType]}`
|
|
592
|
-
));
|
|
593
|
-
}
|
|
594
|
-
};
|
|
595
|
-
|
|
596
|
-
this._transport.setEventHandlers({
|
|
597
|
-
onMessage: handleStatus,
|
|
598
|
-
onClose: () => {
|
|
599
|
-
restoreHandlers();
|
|
600
|
-
reject(new SessionLostError("Connection closed during resume"));
|
|
601
|
-
},
|
|
602
|
-
onError: (err) => {
|
|
603
|
-
restoreHandlers();
|
|
604
|
-
reject(new SessionLostError(`Transport error: ${err.message}`, err));
|
|
605
|
-
},
|
|
606
|
-
});
|
|
607
|
-
});
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
/**
|
|
611
|
-
* Retransmit messages from the history
|
|
612
|
-
*/
|
|
613
|
-
private async _retransmitMessages(fromSeqNum: number): Promise<void> {
|
|
614
|
-
if (!isValidSequenceNumber(fromSeqNum)) {
|
|
615
|
-
// Retransmit all
|
|
616
|
-
for (const [_, message] of this._sentMessageHistory) {
|
|
617
|
-
await this._transport.send(message);
|
|
618
|
-
}
|
|
619
|
-
return;
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
// Find and retransmit starting from the specified sequence number
|
|
623
|
-
for (const [seqNum, message] of this._sentMessageHistory) {
|
|
624
|
-
if (seqNum >= fromSeqNum) {
|
|
625
|
-
await this._transport.send(message);
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
// ==========================================================================
|
|
631
|
-
// Message Handling
|
|
632
|
-
// ==========================================================================
|
|
633
|
-
|
|
634
|
-
/**
|
|
635
|
-
* Handle an incoming message from the transport
|
|
636
|
-
*/
|
|
637
|
-
private _handleMessage(msg: ReceivedMessage): void {
|
|
638
|
-
const { header, payload } = msg;
|
|
639
|
-
|
|
640
|
-
// Validate session ID (except for CTRL_NEW_SESSION_STATUS which sets it)
|
|
641
|
-
if (
|
|
642
|
-
header.messageType !== MessageType.CTRL_NEW_SESSION_STATUS &&
|
|
643
|
-
header.sessionId !== this._sessionId
|
|
644
|
-
) {
|
|
645
|
-
this._handleBadMessage(`Wrong session ID: ${header.sessionId}, expected ${this._sessionId}`);
|
|
646
|
-
return;
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
// Validate sequence number for non-control messages
|
|
650
|
-
if (
|
|
651
|
-
header.messageType >= MessageType.HLA_CALL_REQUEST &&
|
|
652
|
-
!isValidSequenceNumber(header.sequenceNumber)
|
|
653
|
-
) {
|
|
654
|
-
this._handleBadMessage(`Invalid sequence number: ${header.sequenceNumber}`);
|
|
655
|
-
return;
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
switch (header.messageType) {
|
|
659
|
-
case MessageType.CTRL_HEARTBEAT_RESPONSE:
|
|
660
|
-
this._handleHeartbeatResponse(payload);
|
|
661
|
-
break;
|
|
662
|
-
|
|
663
|
-
case MessageType.CTRL_SESSION_TERMINATED:
|
|
664
|
-
this._handleSessionTerminated();
|
|
665
|
-
break;
|
|
666
|
-
|
|
667
|
-
case MessageType.HLA_CALL_RESPONSE:
|
|
668
|
-
this._handleHlaCallResponse(header.sequenceNumber, payload);
|
|
669
|
-
break;
|
|
670
|
-
|
|
671
|
-
case MessageType.HLA_CALLBACK_REQUEST:
|
|
672
|
-
this._handleHlaCallbackRequest(header.sequenceNumber, payload);
|
|
673
|
-
break;
|
|
674
|
-
|
|
675
|
-
default:
|
|
676
|
-
this._handleBadMessage(`Unexpected message type: ${header.messageType}`);
|
|
677
|
-
}
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
/**
|
|
681
|
-
* Handle heartbeat response
|
|
682
|
-
*/
|
|
683
|
-
private _handleHeartbeatResponse(payload: Uint8Array): void {
|
|
684
|
-
this._extendSessionTimer();
|
|
685
|
-
const responseToSeqNum = decodeHeartbeatResponse(payload);
|
|
686
|
-
const deferred = this._pendingRequests.get(responseToSeqNum);
|
|
687
|
-
if (deferred) {
|
|
688
|
-
this._pendingRequests.delete(responseToSeqNum);
|
|
689
|
-
deferred.resolve(new Uint8Array(0));
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
/**
|
|
694
|
-
* Handle session terminated message
|
|
695
|
-
*/
|
|
696
|
-
private _handleSessionTerminated(): void {
|
|
697
|
-
this._extendSessionTimer();
|
|
698
|
-
if (this._terminationDeferred) {
|
|
699
|
-
this._terminationDeferred.resolve();
|
|
700
|
-
this._terminationDeferred = null;
|
|
701
|
-
}
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
/**
|
|
705
|
-
* Handle HLA call response
|
|
706
|
-
*/
|
|
707
|
-
private _handleHlaCallResponse(sequenceNumber: number, payload: Uint8Array): void {
|
|
708
|
-
this._trackHlaMessageReceived(sequenceNumber);
|
|
709
|
-
const response = decodeHlaCallResponse(payload);
|
|
710
|
-
const deferred = this._pendingRequests.get(response.responseToSequenceNumber);
|
|
711
|
-
if (deferred) {
|
|
712
|
-
this._pendingRequests.delete(response.responseToSequenceNumber);
|
|
713
|
-
deferred.resolve(response.hlaServiceReturnValueOrException);
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
/**
|
|
718
|
-
* Handle HLA callback request
|
|
719
|
-
*/
|
|
720
|
-
private _handleHlaCallbackRequest(sequenceNumber: number, payload: Uint8Array): void {
|
|
721
|
-
const callback = decodeHlaCallbackRequest(payload);
|
|
722
|
-
this._hlaCallbackListener?.onHlaCallbackRequest(
|
|
723
|
-
sequenceNumber,
|
|
724
|
-
callback.hlaServiceCallbackWithParams
|
|
725
|
-
);
|
|
726
|
-
this._trackHlaMessageReceived(sequenceNumber);
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
/**
|
|
730
|
-
* Handle a bad message
|
|
731
|
-
*/
|
|
732
|
-
private _handleBadMessage(reason: string): void {
|
|
733
|
-
console.error(`[Session ${this._sessionId}] Bad message: ${reason}`);
|
|
734
|
-
|
|
735
|
-
// Schedule best-effort termination
|
|
736
|
-
this.terminate(0).catch(() => {
|
|
737
|
-
// Ignore termination errors
|
|
738
|
-
});
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
/**
|
|
742
|
-
* Track that we received an HLA message
|
|
743
|
-
*/
|
|
744
|
-
private _trackHlaMessageReceived(sequenceNumber: number): void {
|
|
745
|
-
this._extendSessionTimer();
|
|
746
|
-
if (isValidSequenceNumber(sequenceNumber)) {
|
|
747
|
-
this._lastReceivedSeqNum.set(sequenceNumber);
|
|
748
|
-
}
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
// ==========================================================================
|
|
752
|
-
// Transport Events
|
|
753
|
-
// ==========================================================================
|
|
754
|
-
|
|
755
|
-
/**
|
|
756
|
-
* Handle transport disconnection
|
|
757
|
-
*/
|
|
758
|
-
private _handleDisconnect(hadError: boolean): void {
|
|
759
|
-
// If we're in TERMINATING state and get an EOF, complete the termination
|
|
760
|
-
if (this._state === SessionState.TERMINATING) {
|
|
761
|
-
this._terminationDeferred?.resolve();
|
|
762
|
-
return;
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
// Transition to DROPPED if running
|
|
766
|
-
const oldState = this._compareAndSetState(
|
|
767
|
-
SessionState.RUNNING,
|
|
768
|
-
SessionState.DROPPED,
|
|
769
|
-
hadError ? "Connection lost with error" : "Connection closed"
|
|
770
|
-
);
|
|
771
|
-
|
|
772
|
-
if (oldState === SessionState.RUNNING) {
|
|
773
|
-
this._sessionTimeoutTimer?.pause();
|
|
774
|
-
this._stopHeartbeatTimer();
|
|
775
|
-
}
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
/**
|
|
779
|
-
* Handle transport error
|
|
780
|
-
*/
|
|
781
|
-
private _handleTransportError(error: Error): void {
|
|
782
|
-
console.error(`[Session ${this._sessionId}] Transport error:`, error);
|
|
783
|
-
this._handleDisconnect(true);
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
// ==========================================================================
|
|
787
|
-
// Timer Management
|
|
788
|
-
// ==========================================================================
|
|
789
|
-
|
|
790
|
-
/**
|
|
791
|
-
* Handle session timeout
|
|
792
|
-
*/
|
|
793
|
-
private _handleSessionTimeout(): void {
|
|
794
|
-
console.error(
|
|
795
|
-
`[Session ${this._sessionId}] Session timed out after ${this._options.responseTimeout}ms`
|
|
796
|
-
);
|
|
797
|
-
this._transport.disconnect().catch(() => {
|
|
798
|
-
// Ignore disconnect errors
|
|
799
|
-
});
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
/**
|
|
803
|
-
* Extend the session timeout timer
|
|
804
|
-
*/
|
|
805
|
-
private _extendSessionTimer(): void {
|
|
806
|
-
this._sessionTimeoutTimer?.extend();
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
/**
|
|
810
|
-
* Start the periodic heartbeat timer
|
|
811
|
-
*/
|
|
812
|
-
private _startHeartbeatTimer(): void {
|
|
813
|
-
// Clear any existing timer
|
|
814
|
-
this._stopHeartbeatTimer();
|
|
815
|
-
|
|
816
|
-
// Only start if heartbeat interval is configured and > 0
|
|
817
|
-
if (this._options.heartbeatInterval <= 0) {
|
|
818
|
-
return;
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
// Send heartbeats at regular intervals
|
|
822
|
-
this._heartbeatIntervalHandle = setInterval(() => {
|
|
823
|
-
// Only send heartbeat if we're in an operational state
|
|
824
|
-
if (this.isOperational) {
|
|
825
|
-
this.sendHeartbeat().catch((error) => {
|
|
826
|
-
// Log but don't throw - heartbeat failures shouldn't crash the session
|
|
827
|
-
// The timeout timer will handle detecting if the connection is truly dead
|
|
828
|
-
console.error(`[Session ${this._sessionId}] Heartbeat failed:`, error);
|
|
829
|
-
});
|
|
830
|
-
}
|
|
831
|
-
}, this._options.heartbeatInterval);
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
/**
|
|
835
|
-
* Stop the periodic heartbeat timer
|
|
836
|
-
*/
|
|
837
|
-
private _stopHeartbeatTimer(): void {
|
|
838
|
-
if (this._heartbeatIntervalHandle !== null) {
|
|
839
|
-
clearInterval(this._heartbeatIntervalHandle);
|
|
840
|
-
this._heartbeatIntervalHandle = null;
|
|
841
|
-
}
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
// ==========================================================================
|
|
845
|
-
// State Management
|
|
846
|
-
// ==========================================================================
|
|
847
|
-
|
|
848
|
-
/**
|
|
849
|
-
* Compare and set state atomically
|
|
850
|
-
*/
|
|
851
|
-
private _compareAndSetState(
|
|
852
|
-
expectedState: SessionState,
|
|
853
|
-
newState: SessionState,
|
|
854
|
-
reason: string
|
|
855
|
-
): SessionState {
|
|
856
|
-
const oldState = this._state;
|
|
857
|
-
if (oldState !== expectedState) {
|
|
858
|
-
return oldState;
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
if (!isValidTransition(oldState, newState)) {
|
|
862
|
-
console.warn(`Invalid state transition: ${oldState} -> ${newState}`);
|
|
863
|
-
return oldState;
|
|
864
|
-
}
|
|
865
|
-
|
|
866
|
-
this._state = newState;
|
|
867
|
-
this._fireStateTransition(oldState, newState, reason);
|
|
868
|
-
|
|
869
|
-
// Handle cleanup on terminal state
|
|
870
|
-
if (newState === SessionState.TERMINATED) {
|
|
871
|
-
this._cleanup();
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
return oldState;
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
/**
|
|
878
|
-
* Fire state transition to listeners
|
|
879
|
-
*/
|
|
880
|
-
private _fireStateTransition(
|
|
881
|
-
oldState: SessionState,
|
|
882
|
-
newState: SessionState,
|
|
883
|
-
reason: string
|
|
884
|
-
): void {
|
|
885
|
-
for (const listener of this._stateListeners) {
|
|
886
|
-
try {
|
|
887
|
-
listener.onStateTransition(oldState, newState, reason);
|
|
888
|
-
} catch (error) {
|
|
889
|
-
console.error("Error in state listener:", error);
|
|
890
|
-
}
|
|
891
|
-
}
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
/**
|
|
895
|
-
* Validate that we're in an operational state
|
|
896
|
-
*/
|
|
897
|
-
private _validateOperationalState(operation: string): void {
|
|
898
|
-
if (this._state === SessionState.TERMINATED) {
|
|
899
|
-
throw new SessionAlreadyTerminatedError();
|
|
900
|
-
}
|
|
901
|
-
if (!isOperationalState(this._state)) {
|
|
902
|
-
throw new SessionIllegalStateError(this._state, operation);
|
|
903
|
-
}
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
/**
|
|
907
|
-
* Wait for a state change
|
|
908
|
-
*/
|
|
909
|
-
private _waitForStateChange(): Promise<SessionState> {
|
|
910
|
-
return new Promise((resolve) => {
|
|
911
|
-
const listener: SessionStateListener = {
|
|
912
|
-
onStateTransition: (_, newState) => {
|
|
913
|
-
resolve(newState);
|
|
914
|
-
},
|
|
915
|
-
};
|
|
916
|
-
this._stateListeners.push(listener);
|
|
917
|
-
});
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
// ==========================================================================
|
|
921
|
-
// Utilities
|
|
922
|
-
// ==========================================================================
|
|
923
|
-
|
|
924
|
-
/**
|
|
925
|
-
* Send a message and store in history for potential retransmission
|
|
926
|
-
*/
|
|
927
|
-
private async _sendMessage(message: Uint8Array, seqNum: number): Promise<void> {
|
|
928
|
-
// Store in history
|
|
929
|
-
this._sentMessageHistory.set(seqNum, message);
|
|
930
|
-
|
|
931
|
-
// Trim old history entries if needed (keep last 1000)
|
|
932
|
-
const maxHistorySize = 1000;
|
|
933
|
-
if (this._sentMessageHistory.size > maxHistorySize) {
|
|
934
|
-
const oldestToKeep = seqNum - maxHistorySize;
|
|
935
|
-
for (const [histSeqNum] of this._sentMessageHistory) {
|
|
936
|
-
if (histSeqNum < oldestToKeep) {
|
|
937
|
-
this._sentMessageHistory.delete(histSeqNum);
|
|
938
|
-
this._oldestHistorySeqNum = Math.max(this._oldestHistorySeqNum, histSeqNum + 1);
|
|
939
|
-
}
|
|
940
|
-
}
|
|
941
|
-
}
|
|
942
|
-
|
|
943
|
-
await this._transport.send(message);
|
|
944
|
-
this._messageSentListener?.onMessageSent();
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
/**
|
|
948
|
-
* Clean up resources on termination
|
|
949
|
-
*/
|
|
950
|
-
private _cleanup(): void {
|
|
951
|
-
// Stop heartbeat timer
|
|
952
|
-
this._stopHeartbeatTimer();
|
|
953
|
-
|
|
954
|
-
// Cancel timers
|
|
955
|
-
this._sessionTimeoutTimer?.cancel();
|
|
956
|
-
this._sessionTimeoutTimer = null;
|
|
957
|
-
this._connectionTimeoutTimer?.cancel();
|
|
958
|
-
this._connectionTimeoutTimer = null;
|
|
959
|
-
|
|
960
|
-
// Fail all pending requests
|
|
961
|
-
const error = new SessionLostError("Session terminated");
|
|
962
|
-
for (const [_, deferred] of this._pendingRequests) {
|
|
963
|
-
deferred.reject(error);
|
|
964
|
-
}
|
|
965
|
-
this._pendingRequests.clear();
|
|
966
|
-
|
|
967
|
-
// Clear history
|
|
968
|
-
this._sentMessageHistory.clear();
|
|
969
|
-
|
|
970
|
-
// Disconnect transport
|
|
971
|
-
this._transport.disconnect().catch(() => {
|
|
972
|
-
// Ignore disconnect errors
|
|
973
|
-
});
|
|
974
|
-
}
|
|
975
|
-
|
|
976
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Federate Protocol Session
|
|
3
|
+
*
|
|
4
|
+
* Manages the lifecycle of a Federate Protocol session, including:
|
|
5
|
+
* - Connection establishment and termination
|
|
6
|
+
* - Session state machine
|
|
7
|
+
* - Request/response correlation
|
|
8
|
+
* - Heartbeat/keepalive
|
|
9
|
+
* - Session resumption after disconnection
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
type Transport,
|
|
14
|
+
type ReceivedMessage,
|
|
15
|
+
MessageType,
|
|
16
|
+
NO_SESSION_ID,
|
|
17
|
+
NewSessionStatus,
|
|
18
|
+
} from "@hla4ts/transport";
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
SessionState,
|
|
22
|
+
isValidTransition,
|
|
23
|
+
isOperationalState,
|
|
24
|
+
type SessionOptions,
|
|
25
|
+
type HlaCallbackRequestListener,
|
|
26
|
+
type SessionStateListener,
|
|
27
|
+
type MessageSentListener,
|
|
28
|
+
DEFAULT_SESSION_OPTIONS,
|
|
29
|
+
} from "./types.ts";
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
SequenceNumber,
|
|
33
|
+
AtomicSequenceNumber,
|
|
34
|
+
INITIAL_SEQUENCE_NUMBER,
|
|
35
|
+
NO_SEQUENCE_NUMBER,
|
|
36
|
+
isValidSequenceNumber,
|
|
37
|
+
nextSequenceNumber,
|
|
38
|
+
} from "./sequence-number.ts";
|
|
39
|
+
|
|
40
|
+
import { TimeoutTimer } from "./timeout-timer.ts";
|
|
41
|
+
|
|
42
|
+
import {
|
|
43
|
+
createNewSessionMessage,
|
|
44
|
+
decodeNewSessionStatus,
|
|
45
|
+
createResumeRequestMessage,
|
|
46
|
+
decodeResumeStatus,
|
|
47
|
+
ResumeStatusCode,
|
|
48
|
+
createHeartbeatMessage,
|
|
49
|
+
decodeHeartbeatResponse,
|
|
50
|
+
createTerminateSessionMessage,
|
|
51
|
+
createHlaCallRequestMessage,
|
|
52
|
+
decodeHlaCallResponse,
|
|
53
|
+
decodeHlaCallbackRequest,
|
|
54
|
+
createHlaCallbackResponseMessage,
|
|
55
|
+
} from "./messages.ts";
|
|
56
|
+
|
|
57
|
+
import {
|
|
58
|
+
SessionLostError,
|
|
59
|
+
SessionAlreadyTerminatedError,
|
|
60
|
+
SessionIllegalStateError,
|
|
61
|
+
BadMessageError,
|
|
62
|
+
ConnectionTimeoutError,
|
|
63
|
+
} from "./errors.ts";
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Deferred promise helper for request/response correlation
|
|
67
|
+
*/
|
|
68
|
+
interface DeferredPromise<T> {
|
|
69
|
+
promise: Promise<T>;
|
|
70
|
+
resolve: (value: T) => void;
|
|
71
|
+
reject: (reason: Error) => void;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function createDeferred<T>(): DeferredPromise<T> {
|
|
75
|
+
let resolve!: (value: T) => void;
|
|
76
|
+
let reject!: (reason: Error) => void;
|
|
77
|
+
const promise = new Promise<T>((res, rej) => {
|
|
78
|
+
resolve = res;
|
|
79
|
+
reject = rej;
|
|
80
|
+
});
|
|
81
|
+
return { promise, resolve, reject };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Federate Protocol Session
|
|
86
|
+
*
|
|
87
|
+
* Manages a session with an HLA 4 RTI using the Federate Protocol.
|
|
88
|
+
*
|
|
89
|
+
* @example
|
|
90
|
+
* ```ts
|
|
91
|
+
* const transport = new TlsTransport({ host: 'rti.example.com', port: 15165 });
|
|
92
|
+
* const session = new Session(transport);
|
|
93
|
+
*
|
|
94
|
+
* session.addStateListener((oldState, newState, reason) => {
|
|
95
|
+
* console.log(`State: ${oldState} -> ${newState} (${reason})`);
|
|
96
|
+
* });
|
|
97
|
+
*
|
|
98
|
+
* await session.start({
|
|
99
|
+
* onHlaCallbackRequest: (seqNum, callback) => {
|
|
100
|
+
* // Handle callback
|
|
101
|
+
* session.sendHlaCallbackResponse(seqNum, response);
|
|
102
|
+
* }
|
|
103
|
+
* });
|
|
104
|
+
*
|
|
105
|
+
* // Send HLA calls
|
|
106
|
+
* const response = await session.sendHlaCallRequest(encodedCall);
|
|
107
|
+
*
|
|
108
|
+
* // When done
|
|
109
|
+
* await session.terminate();
|
|
110
|
+
* ```
|
|
111
|
+
*/
|
|
112
|
+
export class Session {
|
|
113
|
+
private readonly _transport: Transport;
|
|
114
|
+
private readonly _options: Required<SessionOptions>;
|
|
115
|
+
|
|
116
|
+
// Session state
|
|
117
|
+
private _state: SessionState = SessionState.NEW;
|
|
118
|
+
private _sessionId: bigint = NO_SESSION_ID;
|
|
119
|
+
|
|
120
|
+
// Sequence number tracking
|
|
121
|
+
private readonly _nextOutgoingSeqNum = new SequenceNumber(INITIAL_SEQUENCE_NUMBER);
|
|
122
|
+
private readonly _lastReceivedSeqNum = new AtomicSequenceNumber(NO_SEQUENCE_NUMBER);
|
|
123
|
+
|
|
124
|
+
// Request/response correlation
|
|
125
|
+
private readonly _pendingRequests = new Map<number, DeferredPromise<Uint8Array>>();
|
|
126
|
+
private _terminationDeferred: DeferredPromise<void> | null = null;
|
|
127
|
+
|
|
128
|
+
// Timers
|
|
129
|
+
private _sessionTimeoutTimer: TimeoutTimer | null = null;
|
|
130
|
+
private _connectionTimeoutTimer: TimeoutTimer | null = null;
|
|
131
|
+
private _heartbeatIntervalHandle: ReturnType<typeof setInterval> | null = null;
|
|
132
|
+
|
|
133
|
+
// Callbacks
|
|
134
|
+
private _hlaCallbackListener: HlaCallbackRequestListener | null = null;
|
|
135
|
+
private readonly _stateListeners: SessionStateListener[] = [];
|
|
136
|
+
private _messageSentListener: MessageSentListener | null = null;
|
|
137
|
+
|
|
138
|
+
// Message history for resumption
|
|
139
|
+
private readonly _sentMessageHistory: Map<number, Uint8Array> = new Map();
|
|
140
|
+
private _oldestHistorySeqNum: number = INITIAL_SEQUENCE_NUMBER;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Create a new session.
|
|
144
|
+
*
|
|
145
|
+
* @param transport - The transport to use for communication
|
|
146
|
+
* @param options - Session configuration options
|
|
147
|
+
*/
|
|
148
|
+
constructor(transport: Transport, options: SessionOptions = {}) {
|
|
149
|
+
this._transport = transport;
|
|
150
|
+
this._options = { ...DEFAULT_SESSION_OPTIONS, ...options };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ==========================================================================
|
|
154
|
+
// Public API
|
|
155
|
+
// ==========================================================================
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Get the session ID (0 if not yet established)
|
|
159
|
+
*/
|
|
160
|
+
get id(): bigint {
|
|
161
|
+
return this._sessionId;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Get the current session state
|
|
166
|
+
*/
|
|
167
|
+
get state(): SessionState {
|
|
168
|
+
return this._state;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Check if the session is in a state where operations are allowed
|
|
173
|
+
*/
|
|
174
|
+
get isOperational(): boolean {
|
|
175
|
+
return isOperationalState(this._state);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Add a state change listener
|
|
180
|
+
*/
|
|
181
|
+
addStateListener(listener: SessionStateListener): void {
|
|
182
|
+
this._stateListeners.push(listener);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Set the message sent listener
|
|
187
|
+
*/
|
|
188
|
+
setMessageSentListener(listener: MessageSentListener): void {
|
|
189
|
+
this._messageSentListener = listener;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Start the session by connecting to the RTI and establishing a new session.
|
|
194
|
+
*
|
|
195
|
+
* @param callbackListener - Handler for incoming HLA callbacks
|
|
196
|
+
* @throws SessionLostError if the connection cannot be established
|
|
197
|
+
* @throws SessionIllegalStateError if not in NEW state
|
|
198
|
+
*/
|
|
199
|
+
async start(callbackListener: HlaCallbackRequestListener): Promise<void> {
|
|
200
|
+
// Validate state
|
|
201
|
+
const beforeState = this._compareAndSetState(
|
|
202
|
+
SessionState.NEW,
|
|
203
|
+
SessionState.STARTING,
|
|
204
|
+
"Starting session"
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
if (beforeState === SessionState.TERMINATED) {
|
|
208
|
+
throw new SessionAlreadyTerminatedError("Cannot start a terminated session");
|
|
209
|
+
}
|
|
210
|
+
if (beforeState !== SessionState.NEW) {
|
|
211
|
+
throw new SessionIllegalStateError(beforeState, "start");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
this._hlaCallbackListener = callbackListener;
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
await this._performConnect();
|
|
218
|
+
} catch (error) {
|
|
219
|
+
this._compareAndSetState(
|
|
220
|
+
SessionState.STARTING,
|
|
221
|
+
SessionState.TERMINATED,
|
|
222
|
+
`Connection failed: ${error}`
|
|
223
|
+
);
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Start session timeout timer
|
|
228
|
+
this._sessionTimeoutTimer = TimeoutTimer.createLazy(this._options.responseTimeout);
|
|
229
|
+
this._sessionTimeoutTimer.start(() => this._handleSessionTimeout());
|
|
230
|
+
|
|
231
|
+
// Start periodic heartbeat sending
|
|
232
|
+
this._startHeartbeatTimer();
|
|
233
|
+
|
|
234
|
+
// Transition to running
|
|
235
|
+
this._compareAndSetState(SessionState.STARTING, SessionState.RUNNING, "Session established");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Resume a dropped session.
|
|
240
|
+
*
|
|
241
|
+
* @returns true if resume succeeded
|
|
242
|
+
* @throws SessionLostError if the server refuses to resume
|
|
243
|
+
* @throws SessionIllegalStateError if not in DROPPED state
|
|
244
|
+
*/
|
|
245
|
+
async resume(): Promise<boolean> {
|
|
246
|
+
const beforeState = this._compareAndSetState(
|
|
247
|
+
SessionState.DROPPED,
|
|
248
|
+
SessionState.RESUMING,
|
|
249
|
+
"Attempting resume"
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
if (beforeState === SessionState.TERMINATED) {
|
|
253
|
+
throw new SessionAlreadyTerminatedError("Cannot resume a terminated session");
|
|
254
|
+
}
|
|
255
|
+
if (beforeState !== SessionState.DROPPED) {
|
|
256
|
+
throw new SessionIllegalStateError(beforeState, "resume");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
try {
|
|
260
|
+
await this._performResume();
|
|
261
|
+
// Restart heartbeat timer after resume
|
|
262
|
+
this._startHeartbeatTimer();
|
|
263
|
+
this._compareAndSetState(SessionState.RESUMING, SessionState.RUNNING, "Session resumed");
|
|
264
|
+
return true;
|
|
265
|
+
} catch (error) {
|
|
266
|
+
if (error instanceof SessionLostError) {
|
|
267
|
+
this._compareAndSetState(
|
|
268
|
+
SessionState.RESUMING,
|
|
269
|
+
SessionState.TERMINATED,
|
|
270
|
+
`Resume failed: ${error.message}`
|
|
271
|
+
);
|
|
272
|
+
throw error;
|
|
273
|
+
}
|
|
274
|
+
// Network error - back to dropped state
|
|
275
|
+
this._compareAndSetState(
|
|
276
|
+
SessionState.RESUMING,
|
|
277
|
+
SessionState.DROPPED,
|
|
278
|
+
`Resume connection failed: ${error}`
|
|
279
|
+
);
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Send a heartbeat message.
|
|
286
|
+
*
|
|
287
|
+
* @returns Promise that resolves when the heartbeat response is received
|
|
288
|
+
* @throws SessionIllegalStateError if not in operational state
|
|
289
|
+
*/
|
|
290
|
+
async sendHeartbeat(): Promise<void> {
|
|
291
|
+
this._validateOperationalState("send heartbeat");
|
|
292
|
+
|
|
293
|
+
const seqNum = this._nextOutgoingSeqNum.getAndIncrement();
|
|
294
|
+
const message = createHeartbeatMessage(
|
|
295
|
+
seqNum,
|
|
296
|
+
this._sessionId,
|
|
297
|
+
this._lastReceivedSeqNum.get()
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
const deferred = createDeferred<Uint8Array>();
|
|
301
|
+
this._pendingRequests.set(seqNum, deferred);
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
await this._sendMessage(message, seqNum);
|
|
305
|
+
await deferred.promise;
|
|
306
|
+
} finally {
|
|
307
|
+
this._pendingRequests.delete(seqNum);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Send an HLA call request.
|
|
313
|
+
*
|
|
314
|
+
* @param encodedHlaCall - Protobuf-encoded HLA call request
|
|
315
|
+
* @returns Promise that resolves with the protobuf-encoded response
|
|
316
|
+
* @throws SessionIllegalStateError if not in operational state
|
|
317
|
+
*/
|
|
318
|
+
async sendHlaCallRequest(encodedHlaCall: Uint8Array): Promise<Uint8Array> {
|
|
319
|
+
this._validateOperationalState("send HLA call");
|
|
320
|
+
|
|
321
|
+
const seqNum = this._nextOutgoingSeqNum.getAndIncrement();
|
|
322
|
+
const message = createHlaCallRequestMessage(
|
|
323
|
+
seqNum,
|
|
324
|
+
this._sessionId,
|
|
325
|
+
this._lastReceivedSeqNum.get(),
|
|
326
|
+
encodedHlaCall
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
const deferred = createDeferred<Uint8Array>();
|
|
330
|
+
this._pendingRequests.set(seqNum, deferred);
|
|
331
|
+
|
|
332
|
+
try {
|
|
333
|
+
await this._sendMessage(message, seqNum);
|
|
334
|
+
return await deferred.promise;
|
|
335
|
+
} catch (error) {
|
|
336
|
+
this._pendingRequests.delete(seqNum);
|
|
337
|
+
throw error;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Send an HLA callback response.
|
|
343
|
+
*
|
|
344
|
+
* @param responseToSequenceNumber - Sequence number of the callback request
|
|
345
|
+
* @param encodedResponse - Protobuf-encoded callback response
|
|
346
|
+
* @throws SessionIllegalStateError if not in operational state
|
|
347
|
+
*/
|
|
348
|
+
async sendHlaCallbackResponse(
|
|
349
|
+
responseToSequenceNumber: number,
|
|
350
|
+
encodedResponse: Uint8Array
|
|
351
|
+
): Promise<void> {
|
|
352
|
+
this._validateOperationalState("send callback response");
|
|
353
|
+
|
|
354
|
+
const seqNum = this._nextOutgoingSeqNum.getAndIncrement();
|
|
355
|
+
const message = createHlaCallbackResponseMessage(
|
|
356
|
+
seqNum,
|
|
357
|
+
this._sessionId,
|
|
358
|
+
this._lastReceivedSeqNum.get(),
|
|
359
|
+
responseToSequenceNumber,
|
|
360
|
+
encodedResponse
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
await this._sendMessage(message, seqNum);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Terminate the session gracefully.
|
|
368
|
+
*
|
|
369
|
+
* @param timeoutMs - Timeout for termination (default: responseTimeout)
|
|
370
|
+
* @throws SessionLostError if termination times out
|
|
371
|
+
* @throws SessionIllegalStateError if in invalid state
|
|
372
|
+
*/
|
|
373
|
+
async terminate(timeoutMs?: number): Promise<void> {
|
|
374
|
+
const timeout = timeoutMs ?? this._options.responseTimeout;
|
|
375
|
+
|
|
376
|
+
// Wait if resuming
|
|
377
|
+
if (this._state === SessionState.RESUMING) {
|
|
378
|
+
await this._waitForStateChange();
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// If already terminated, nothing to do
|
|
382
|
+
if (this._state === SessionState.TERMINATED) {
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const beforeState = this._compareAndSetState(
|
|
387
|
+
SessionState.RUNNING,
|
|
388
|
+
SessionState.TERMINATING,
|
|
389
|
+
"Terminating session"
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
// Handle DROPPED state
|
|
393
|
+
if (beforeState === SessionState.DROPPED) {
|
|
394
|
+
this._compareAndSetState(
|
|
395
|
+
SessionState.DROPPED,
|
|
396
|
+
SessionState.TERMINATED,
|
|
397
|
+
"Terminated dropped session"
|
|
398
|
+
);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (beforeState !== SessionState.RUNNING) {
|
|
403
|
+
throw new SessionIllegalStateError(beforeState, "terminate");
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Send termination message
|
|
407
|
+
this._terminationDeferred = createDeferred<void>();
|
|
408
|
+
const seqNum = this._nextOutgoingSeqNum.getAndIncrement();
|
|
409
|
+
const message = createTerminateSessionMessage(
|
|
410
|
+
seqNum,
|
|
411
|
+
this._sessionId,
|
|
412
|
+
this._lastReceivedSeqNum.get()
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
try {
|
|
416
|
+
await this._sendMessage(message, seqNum);
|
|
417
|
+
|
|
418
|
+
// Wait for termination response with timeout
|
|
419
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
420
|
+
setTimeout(
|
|
421
|
+
() => reject(new SessionLostError("Termination timed out")),
|
|
422
|
+
timeout
|
|
423
|
+
);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
await Promise.race([this._terminationDeferred.promise, timeoutPromise]);
|
|
427
|
+
} catch (error) {
|
|
428
|
+
// Session is terminated either way
|
|
429
|
+
throw error;
|
|
430
|
+
} finally {
|
|
431
|
+
this._compareAndSetState(
|
|
432
|
+
SessionState.TERMINATING,
|
|
433
|
+
SessionState.TERMINATED,
|
|
434
|
+
"Session terminated"
|
|
435
|
+
);
|
|
436
|
+
this._cleanup();
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ==========================================================================
|
|
441
|
+
// Connection and Session Establishment
|
|
442
|
+
// ==========================================================================
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Perform the initial connection and session establishment
|
|
446
|
+
*/
|
|
447
|
+
private async _performConnect(): Promise<void> {
|
|
448
|
+
// Set up transport handlers
|
|
449
|
+
this._transport.setEventHandlers({
|
|
450
|
+
onMessage: (msg) => this._handleMessage(msg),
|
|
451
|
+
onClose: (hadError) => this._handleDisconnect(hadError),
|
|
452
|
+
onError: (err) => this._handleTransportError(err),
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
// Connect with timeout
|
|
456
|
+
const connectionTimeout = this._options.connectionTimeout;
|
|
457
|
+
this._connectionTimeoutTimer = TimeoutTimer.createLazy(connectionTimeout);
|
|
458
|
+
|
|
459
|
+
let connectionPromise: Promise<void>;
|
|
460
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
461
|
+
this._connectionTimeoutTimer!.start(() => {
|
|
462
|
+
reject(new ConnectionTimeoutError(connectionTimeout));
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
try {
|
|
467
|
+
// Connect to transport
|
|
468
|
+
connectionPromise = this._transport.connect();
|
|
469
|
+
await Promise.race([connectionPromise, timeoutPromise]);
|
|
470
|
+
|
|
471
|
+
// Send new session message
|
|
472
|
+
const newSessionMsg = createNewSessionMessage();
|
|
473
|
+
await this._transport.send(newSessionMsg);
|
|
474
|
+
|
|
475
|
+
// Wait for new session status response
|
|
476
|
+
const statusResponse = await this._waitForNewSessionStatus();
|
|
477
|
+
|
|
478
|
+
if (statusResponse.status !== NewSessionStatus.SUCCESS) {
|
|
479
|
+
throw new SessionLostError(
|
|
480
|
+
`Server unable to create session, reason: ${statusResponse.status}`
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
} finally {
|
|
484
|
+
this._connectionTimeoutTimer?.cancel();
|
|
485
|
+
this._connectionTimeoutTimer = null;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Wait for the CTRL_NEW_SESSION_STATUS response
|
|
491
|
+
*/
|
|
492
|
+
private _waitForNewSessionStatus(): Promise<{ status: number; sessionId: bigint }> {
|
|
493
|
+
return new Promise((resolve, reject) => {
|
|
494
|
+
const restoreHandlers = () => {
|
|
495
|
+
// Restore normal message handlers after handshake
|
|
496
|
+
this._transport.setEventHandlers({
|
|
497
|
+
onMessage: (msg) => this._handleMessage(msg),
|
|
498
|
+
onClose: (hadError) => this._handleDisconnect(hadError),
|
|
499
|
+
onError: (err) => this._handleTransportError(err),
|
|
500
|
+
});
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
const handleStatus = (msg: ReceivedMessage) => {
|
|
504
|
+
if (msg.header.messageType === MessageType.CTRL_NEW_SESSION_STATUS) {
|
|
505
|
+
const status = decodeNewSessionStatus(msg.payload);
|
|
506
|
+
this._sessionId = msg.header.sessionId;
|
|
507
|
+
this._extendSessionTimer();
|
|
508
|
+
restoreHandlers(); // Restore handlers before resolving
|
|
509
|
+
resolve({ status, sessionId: msg.header.sessionId });
|
|
510
|
+
} else {
|
|
511
|
+
restoreHandlers(); // Restore handlers before rejecting
|
|
512
|
+
reject(new BadMessageError(
|
|
513
|
+
`Expected CTRL_NEW_SESSION_STATUS, got ${MessageType[msg.header.messageType]}`
|
|
514
|
+
));
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
// Temporarily replace handler for handshake
|
|
519
|
+
this._transport.setEventHandlers({
|
|
520
|
+
onMessage: handleStatus,
|
|
521
|
+
onClose: () => {
|
|
522
|
+
restoreHandlers();
|
|
523
|
+
reject(new SessionLostError("Connection closed during handshake"));
|
|
524
|
+
},
|
|
525
|
+
onError: (err) => {
|
|
526
|
+
restoreHandlers();
|
|
527
|
+
reject(new SessionLostError(`Transport error: ${err.message}`, err));
|
|
528
|
+
},
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Perform session resumption
|
|
535
|
+
*/
|
|
536
|
+
private async _performResume(): Promise<void> {
|
|
537
|
+
const lastReceivedRtiMessage = this._lastReceivedSeqNum.get();
|
|
538
|
+
const oldestAvailableFederateMessage = this._oldestHistorySeqNum;
|
|
539
|
+
|
|
540
|
+
// Reconnect transport
|
|
541
|
+
await this._transport.connect();
|
|
542
|
+
|
|
543
|
+
// Send resume request
|
|
544
|
+
const resumeMsg = createResumeRequestMessage(
|
|
545
|
+
this._sessionId,
|
|
546
|
+
lastReceivedRtiMessage,
|
|
547
|
+
oldestAvailableFederateMessage
|
|
548
|
+
);
|
|
549
|
+
await this._transport.send(resumeMsg);
|
|
550
|
+
|
|
551
|
+
// Resume session timer
|
|
552
|
+
this._sessionTimeoutTimer?.resume();
|
|
553
|
+
|
|
554
|
+
// Wait for resume status
|
|
555
|
+
const statusResponse = await this._waitForResumeStatus();
|
|
556
|
+
|
|
557
|
+
if (statusResponse.status !== ResumeStatusCode.OK_TO_RESUME) {
|
|
558
|
+
throw new SessionLostError(
|
|
559
|
+
`Server refused to resume session, status: ${statusResponse.status}`
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Retransmit messages that the server hasn't received
|
|
564
|
+
const resumeFromNumber = nextSequenceNumber(statusResponse.lastReceivedFederateSequenceNumber);
|
|
565
|
+
await this._retransmitMessages(resumeFromNumber);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Wait for CTRL_RESUME_STATUS response
|
|
570
|
+
*/
|
|
571
|
+
private _waitForResumeStatus(): Promise<{ status: number; lastReceivedFederateSequenceNumber: number }> {
|
|
572
|
+
return new Promise((resolve, reject) => {
|
|
573
|
+
const restoreHandlers = () => {
|
|
574
|
+
// Restore normal message handlers after resume handshake
|
|
575
|
+
this._transport.setEventHandlers({
|
|
576
|
+
onMessage: (msg) => this._handleMessage(msg),
|
|
577
|
+
onClose: (hadError) => this._handleDisconnect(hadError),
|
|
578
|
+
onError: (err) => this._handleTransportError(err),
|
|
579
|
+
});
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
const handleStatus = (msg: ReceivedMessage) => {
|
|
583
|
+
if (msg.header.messageType === MessageType.CTRL_RESUME_STATUS) {
|
|
584
|
+
this._extendSessionTimer();
|
|
585
|
+
const result = decodeResumeStatus(msg.payload);
|
|
586
|
+
restoreHandlers(); // Restore handlers before resolving
|
|
587
|
+
resolve(result);
|
|
588
|
+
} else {
|
|
589
|
+
restoreHandlers(); // Restore handlers before rejecting
|
|
590
|
+
reject(new BadMessageError(
|
|
591
|
+
`Expected CTRL_RESUME_STATUS, got ${MessageType[msg.header.messageType]}`
|
|
592
|
+
));
|
|
593
|
+
}
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
this._transport.setEventHandlers({
|
|
597
|
+
onMessage: handleStatus,
|
|
598
|
+
onClose: () => {
|
|
599
|
+
restoreHandlers();
|
|
600
|
+
reject(new SessionLostError("Connection closed during resume"));
|
|
601
|
+
},
|
|
602
|
+
onError: (err) => {
|
|
603
|
+
restoreHandlers();
|
|
604
|
+
reject(new SessionLostError(`Transport error: ${err.message}`, err));
|
|
605
|
+
},
|
|
606
|
+
});
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Retransmit messages from the history
|
|
612
|
+
*/
|
|
613
|
+
private async _retransmitMessages(fromSeqNum: number): Promise<void> {
|
|
614
|
+
if (!isValidSequenceNumber(fromSeqNum)) {
|
|
615
|
+
// Retransmit all
|
|
616
|
+
for (const [_, message] of this._sentMessageHistory) {
|
|
617
|
+
await this._transport.send(message);
|
|
618
|
+
}
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Find and retransmit starting from the specified sequence number
|
|
623
|
+
for (const [seqNum, message] of this._sentMessageHistory) {
|
|
624
|
+
if (seqNum >= fromSeqNum) {
|
|
625
|
+
await this._transport.send(message);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// ==========================================================================
|
|
631
|
+
// Message Handling
|
|
632
|
+
// ==========================================================================
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Handle an incoming message from the transport
|
|
636
|
+
*/
|
|
637
|
+
private _handleMessage(msg: ReceivedMessage): void {
|
|
638
|
+
const { header, payload } = msg;
|
|
639
|
+
|
|
640
|
+
// Validate session ID (except for CTRL_NEW_SESSION_STATUS which sets it)
|
|
641
|
+
if (
|
|
642
|
+
header.messageType !== MessageType.CTRL_NEW_SESSION_STATUS &&
|
|
643
|
+
header.sessionId !== this._sessionId
|
|
644
|
+
) {
|
|
645
|
+
this._handleBadMessage(`Wrong session ID: ${header.sessionId}, expected ${this._sessionId}`);
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Validate sequence number for non-control messages
|
|
650
|
+
if (
|
|
651
|
+
header.messageType >= MessageType.HLA_CALL_REQUEST &&
|
|
652
|
+
!isValidSequenceNumber(header.sequenceNumber)
|
|
653
|
+
) {
|
|
654
|
+
this._handleBadMessage(`Invalid sequence number: ${header.sequenceNumber}`);
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
switch (header.messageType) {
|
|
659
|
+
case MessageType.CTRL_HEARTBEAT_RESPONSE:
|
|
660
|
+
this._handleHeartbeatResponse(payload);
|
|
661
|
+
break;
|
|
662
|
+
|
|
663
|
+
case MessageType.CTRL_SESSION_TERMINATED:
|
|
664
|
+
this._handleSessionTerminated();
|
|
665
|
+
break;
|
|
666
|
+
|
|
667
|
+
case MessageType.HLA_CALL_RESPONSE:
|
|
668
|
+
this._handleHlaCallResponse(header.sequenceNumber, payload);
|
|
669
|
+
break;
|
|
670
|
+
|
|
671
|
+
case MessageType.HLA_CALLBACK_REQUEST:
|
|
672
|
+
this._handleHlaCallbackRequest(header.sequenceNumber, payload);
|
|
673
|
+
break;
|
|
674
|
+
|
|
675
|
+
default:
|
|
676
|
+
this._handleBadMessage(`Unexpected message type: ${header.messageType}`);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Handle heartbeat response
|
|
682
|
+
*/
|
|
683
|
+
private _handleHeartbeatResponse(payload: Uint8Array): void {
|
|
684
|
+
this._extendSessionTimer();
|
|
685
|
+
const responseToSeqNum = decodeHeartbeatResponse(payload);
|
|
686
|
+
const deferred = this._pendingRequests.get(responseToSeqNum);
|
|
687
|
+
if (deferred) {
|
|
688
|
+
this._pendingRequests.delete(responseToSeqNum);
|
|
689
|
+
deferred.resolve(new Uint8Array(0));
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Handle session terminated message
|
|
695
|
+
*/
|
|
696
|
+
private _handleSessionTerminated(): void {
|
|
697
|
+
this._extendSessionTimer();
|
|
698
|
+
if (this._terminationDeferred) {
|
|
699
|
+
this._terminationDeferred.resolve();
|
|
700
|
+
this._terminationDeferred = null;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Handle HLA call response
|
|
706
|
+
*/
|
|
707
|
+
private _handleHlaCallResponse(sequenceNumber: number, payload: Uint8Array): void {
|
|
708
|
+
this._trackHlaMessageReceived(sequenceNumber);
|
|
709
|
+
const response = decodeHlaCallResponse(payload);
|
|
710
|
+
const deferred = this._pendingRequests.get(response.responseToSequenceNumber);
|
|
711
|
+
if (deferred) {
|
|
712
|
+
this._pendingRequests.delete(response.responseToSequenceNumber);
|
|
713
|
+
deferred.resolve(response.hlaServiceReturnValueOrException);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Handle HLA callback request
|
|
719
|
+
*/
|
|
720
|
+
private _handleHlaCallbackRequest(sequenceNumber: number, payload: Uint8Array): void {
|
|
721
|
+
const callback = decodeHlaCallbackRequest(payload);
|
|
722
|
+
this._hlaCallbackListener?.onHlaCallbackRequest(
|
|
723
|
+
sequenceNumber,
|
|
724
|
+
callback.hlaServiceCallbackWithParams
|
|
725
|
+
);
|
|
726
|
+
this._trackHlaMessageReceived(sequenceNumber);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Handle a bad message
|
|
731
|
+
*/
|
|
732
|
+
private _handleBadMessage(reason: string): void {
|
|
733
|
+
console.error(`[Session ${this._sessionId}] Bad message: ${reason}`);
|
|
734
|
+
|
|
735
|
+
// Schedule best-effort termination
|
|
736
|
+
this.terminate(0).catch(() => {
|
|
737
|
+
// Ignore termination errors
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Track that we received an HLA message
|
|
743
|
+
*/
|
|
744
|
+
private _trackHlaMessageReceived(sequenceNumber: number): void {
|
|
745
|
+
this._extendSessionTimer();
|
|
746
|
+
if (isValidSequenceNumber(sequenceNumber)) {
|
|
747
|
+
this._lastReceivedSeqNum.set(sequenceNumber);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// ==========================================================================
|
|
752
|
+
// Transport Events
|
|
753
|
+
// ==========================================================================
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Handle transport disconnection
|
|
757
|
+
*/
|
|
758
|
+
private _handleDisconnect(hadError: boolean): void {
|
|
759
|
+
// If we're in TERMINATING state and get an EOF, complete the termination
|
|
760
|
+
if (this._state === SessionState.TERMINATING) {
|
|
761
|
+
this._terminationDeferred?.resolve();
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Transition to DROPPED if running
|
|
766
|
+
const oldState = this._compareAndSetState(
|
|
767
|
+
SessionState.RUNNING,
|
|
768
|
+
SessionState.DROPPED,
|
|
769
|
+
hadError ? "Connection lost with error" : "Connection closed"
|
|
770
|
+
);
|
|
771
|
+
|
|
772
|
+
if (oldState === SessionState.RUNNING) {
|
|
773
|
+
this._sessionTimeoutTimer?.pause();
|
|
774
|
+
this._stopHeartbeatTimer();
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Handle transport error
|
|
780
|
+
*/
|
|
781
|
+
private _handleTransportError(error: Error): void {
|
|
782
|
+
console.error(`[Session ${this._sessionId}] Transport error:`, error);
|
|
783
|
+
this._handleDisconnect(true);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// ==========================================================================
|
|
787
|
+
// Timer Management
|
|
788
|
+
// ==========================================================================
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Handle session timeout
|
|
792
|
+
*/
|
|
793
|
+
private _handleSessionTimeout(): void {
|
|
794
|
+
console.error(
|
|
795
|
+
`[Session ${this._sessionId}] Session timed out after ${this._options.responseTimeout}ms`
|
|
796
|
+
);
|
|
797
|
+
this._transport.disconnect().catch(() => {
|
|
798
|
+
// Ignore disconnect errors
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Extend the session timeout timer
|
|
804
|
+
*/
|
|
805
|
+
private _extendSessionTimer(): void {
|
|
806
|
+
this._sessionTimeoutTimer?.extend();
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* Start the periodic heartbeat timer
|
|
811
|
+
*/
|
|
812
|
+
private _startHeartbeatTimer(): void {
|
|
813
|
+
// Clear any existing timer
|
|
814
|
+
this._stopHeartbeatTimer();
|
|
815
|
+
|
|
816
|
+
// Only start if heartbeat interval is configured and > 0
|
|
817
|
+
if (this._options.heartbeatInterval <= 0) {
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// Send heartbeats at regular intervals
|
|
822
|
+
this._heartbeatIntervalHandle = setInterval(() => {
|
|
823
|
+
// Only send heartbeat if we're in an operational state
|
|
824
|
+
if (this.isOperational) {
|
|
825
|
+
this.sendHeartbeat().catch((error) => {
|
|
826
|
+
// Log but don't throw - heartbeat failures shouldn't crash the session
|
|
827
|
+
// The timeout timer will handle detecting if the connection is truly dead
|
|
828
|
+
console.error(`[Session ${this._sessionId}] Heartbeat failed:`, error);
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
}, this._options.heartbeatInterval);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* Stop the periodic heartbeat timer
|
|
836
|
+
*/
|
|
837
|
+
private _stopHeartbeatTimer(): void {
|
|
838
|
+
if (this._heartbeatIntervalHandle !== null) {
|
|
839
|
+
clearInterval(this._heartbeatIntervalHandle);
|
|
840
|
+
this._heartbeatIntervalHandle = null;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// ==========================================================================
|
|
845
|
+
// State Management
|
|
846
|
+
// ==========================================================================
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* Compare and set state atomically
|
|
850
|
+
*/
|
|
851
|
+
private _compareAndSetState(
|
|
852
|
+
expectedState: SessionState,
|
|
853
|
+
newState: SessionState,
|
|
854
|
+
reason: string
|
|
855
|
+
): SessionState {
|
|
856
|
+
const oldState = this._state;
|
|
857
|
+
if (oldState !== expectedState) {
|
|
858
|
+
return oldState;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
if (!isValidTransition(oldState, newState)) {
|
|
862
|
+
console.warn(`Invalid state transition: ${oldState} -> ${newState}`);
|
|
863
|
+
return oldState;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
this._state = newState;
|
|
867
|
+
this._fireStateTransition(oldState, newState, reason);
|
|
868
|
+
|
|
869
|
+
// Handle cleanup on terminal state
|
|
870
|
+
if (newState === SessionState.TERMINATED) {
|
|
871
|
+
this._cleanup();
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
return oldState;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* Fire state transition to listeners
|
|
879
|
+
*/
|
|
880
|
+
private _fireStateTransition(
|
|
881
|
+
oldState: SessionState,
|
|
882
|
+
newState: SessionState,
|
|
883
|
+
reason: string
|
|
884
|
+
): void {
|
|
885
|
+
for (const listener of this._stateListeners) {
|
|
886
|
+
try {
|
|
887
|
+
listener.onStateTransition(oldState, newState, reason);
|
|
888
|
+
} catch (error) {
|
|
889
|
+
console.error("Error in state listener:", error);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* Validate that we're in an operational state
|
|
896
|
+
*/
|
|
897
|
+
private _validateOperationalState(operation: string): void {
|
|
898
|
+
if (this._state === SessionState.TERMINATED) {
|
|
899
|
+
throw new SessionAlreadyTerminatedError();
|
|
900
|
+
}
|
|
901
|
+
if (!isOperationalState(this._state)) {
|
|
902
|
+
throw new SessionIllegalStateError(this._state, operation);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* Wait for a state change
|
|
908
|
+
*/
|
|
909
|
+
private _waitForStateChange(): Promise<SessionState> {
|
|
910
|
+
return new Promise((resolve) => {
|
|
911
|
+
const listener: SessionStateListener = {
|
|
912
|
+
onStateTransition: (_, newState) => {
|
|
913
|
+
resolve(newState);
|
|
914
|
+
},
|
|
915
|
+
};
|
|
916
|
+
this._stateListeners.push(listener);
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// ==========================================================================
|
|
921
|
+
// Utilities
|
|
922
|
+
// ==========================================================================
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Send a message and store in history for potential retransmission
|
|
926
|
+
*/
|
|
927
|
+
private async _sendMessage(message: Uint8Array, seqNum: number): Promise<void> {
|
|
928
|
+
// Store in history
|
|
929
|
+
this._sentMessageHistory.set(seqNum, message);
|
|
930
|
+
|
|
931
|
+
// Trim old history entries if needed (keep last 1000)
|
|
932
|
+
const maxHistorySize = 1000;
|
|
933
|
+
if (this._sentMessageHistory.size > maxHistorySize) {
|
|
934
|
+
const oldestToKeep = seqNum - maxHistorySize;
|
|
935
|
+
for (const [histSeqNum] of this._sentMessageHistory) {
|
|
936
|
+
if (histSeqNum < oldestToKeep) {
|
|
937
|
+
this._sentMessageHistory.delete(histSeqNum);
|
|
938
|
+
this._oldestHistorySeqNum = Math.max(this._oldestHistorySeqNum, histSeqNum + 1);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
await this._transport.send(message);
|
|
944
|
+
this._messageSentListener?.onMessageSent();
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Clean up resources on termination
|
|
949
|
+
*/
|
|
950
|
+
private _cleanup(): void {
|
|
951
|
+
// Stop heartbeat timer
|
|
952
|
+
this._stopHeartbeatTimer();
|
|
953
|
+
|
|
954
|
+
// Cancel timers
|
|
955
|
+
this._sessionTimeoutTimer?.cancel();
|
|
956
|
+
this._sessionTimeoutTimer = null;
|
|
957
|
+
this._connectionTimeoutTimer?.cancel();
|
|
958
|
+
this._connectionTimeoutTimer = null;
|
|
959
|
+
|
|
960
|
+
// Fail all pending requests
|
|
961
|
+
const error = new SessionLostError("Session terminated");
|
|
962
|
+
for (const [_, deferred] of this._pendingRequests) {
|
|
963
|
+
deferred.reject(error);
|
|
964
|
+
}
|
|
965
|
+
this._pendingRequests.clear();
|
|
966
|
+
|
|
967
|
+
// Clear history
|
|
968
|
+
this._sentMessageHistory.clear();
|
|
969
|
+
|
|
970
|
+
// Disconnect transport
|
|
971
|
+
this._transport.disconnect().catch(() => {
|
|
972
|
+
// Ignore disconnect errors
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
}
|