@hla4ts/session 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/session.ts ADDED
@@ -0,0 +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
+ }