@rivetkit/engine-runner 2.0.22-rc.2 → 2.0.23

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.
@@ -0,0 +1,184 @@
1
+ import type * as protocol from "@rivetkit/engine-runner-protocol";
2
+
3
+ /**
4
+ * Helper function to stringify ArrayBuffer for logging
5
+ */
6
+ function stringifyArrayBuffer(buffer: ArrayBuffer): string {
7
+ return `ArrayBuffer(${buffer.byteLength})`;
8
+ }
9
+
10
+ /**
11
+ * Helper function to stringify bigint for logging
12
+ */
13
+ function stringifyBigInt(value: bigint): string {
14
+ return `${value}n`;
15
+ }
16
+
17
+ /**
18
+ * Helper function to stringify Map for logging
19
+ */
20
+ function stringifyMap(map: ReadonlyMap<string, string>): string {
21
+ const entries = Array.from(map.entries())
22
+ .map(([k, v]) => `"${k}": "${v}"`)
23
+ .join(", ");
24
+ return `Map(${map.size}){${entries}}`;
25
+ }
26
+
27
+ /**
28
+ * Stringify ToServerTunnelMessageKind for logging
29
+ * Handles ArrayBuffers, BigInts, and Maps that can't be JSON.stringified
30
+ */
31
+ export function stringifyToServerTunnelMessageKind(
32
+ kind: protocol.ToServerTunnelMessageKind,
33
+ ): string {
34
+ switch (kind.tag) {
35
+ case "TunnelAck":
36
+ return "TunnelAck";
37
+ case "ToServerResponseStart": {
38
+ const { status, headers, body, stream } = kind.val;
39
+ const bodyStr = body === null ? "null" : stringifyArrayBuffer(body);
40
+ return `ToServerResponseStart{status: ${status}, headers: ${stringifyMap(headers)}, body: ${bodyStr}, stream: ${stream}}`;
41
+ }
42
+ case "ToServerResponseChunk": {
43
+ const { body, finish } = kind.val;
44
+ return `ToServerResponseChunk{body: ${stringifyArrayBuffer(body)}, finish: ${finish}}`;
45
+ }
46
+ case "ToServerResponseAbort":
47
+ return "ToServerResponseAbort";
48
+ case "ToServerWebSocketOpen": {
49
+ const { canHibernate, lastMsgIndex } = kind.val;
50
+ return `ToServerWebSocketOpen{canHibernate: ${canHibernate}, lastMsgIndex: ${stringifyBigInt(lastMsgIndex)}}`;
51
+ }
52
+ case "ToServerWebSocketMessage": {
53
+ const { data, binary } = kind.val;
54
+ return `ToServerWebSocketMessage{data: ${stringifyArrayBuffer(data)}, binary: ${binary}}`;
55
+ }
56
+ case "ToServerWebSocketMessageAck": {
57
+ const { index } = kind.val;
58
+ return `ToServerWebSocketMessageAck{index: ${index}}`;
59
+ }
60
+ case "ToServerWebSocketClose": {
61
+ const { code, reason, retry } = kind.val;
62
+ const codeStr = code === null ? "null" : code.toString();
63
+ const reasonStr = reason === null ? "null" : `"${reason}"`;
64
+ return `ToServerWebSocketClose{code: ${codeStr}, reason: ${reasonStr}, retry: ${retry}}`;
65
+ }
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Stringify ToClientTunnelMessageKind for logging
71
+ * Handles ArrayBuffers, BigInts, and Maps that can't be JSON.stringified
72
+ */
73
+ export function stringifyToClientTunnelMessageKind(
74
+ kind: protocol.ToClientTunnelMessageKind,
75
+ ): string {
76
+ switch (kind.tag) {
77
+ case "TunnelAck":
78
+ return "TunnelAck";
79
+ case "ToClientRequestStart": {
80
+ const { actorId, method, path, headers, body, stream } = kind.val;
81
+ const bodyStr = body === null ? "null" : stringifyArrayBuffer(body);
82
+ return `ToClientRequestStart{actorId: "${actorId}", method: "${method}", path: "${path}", headers: ${stringifyMap(headers)}, body: ${bodyStr}, stream: ${stream}}`;
83
+ }
84
+ case "ToClientRequestChunk": {
85
+ const { body, finish } = kind.val;
86
+ return `ToClientRequestChunk{body: ${stringifyArrayBuffer(body)}, finish: ${finish}}`;
87
+ }
88
+ case "ToClientRequestAbort":
89
+ return "ToClientRequestAbort";
90
+ case "ToClientWebSocketOpen": {
91
+ const { actorId, path, headers } = kind.val;
92
+ return `ToClientWebSocketOpen{actorId: "${actorId}", path: "${path}", headers: ${stringifyMap(headers)}}`;
93
+ }
94
+ case "ToClientWebSocketMessage": {
95
+ const { index, data, binary } = kind.val;
96
+ return `ToClientWebSocketMessage{index: ${index}, data: ${stringifyArrayBuffer(data)}, binary: ${binary}}`;
97
+ }
98
+ case "ToClientWebSocketClose": {
99
+ const { code, reason } = kind.val;
100
+ const codeStr = code === null ? "null" : code.toString();
101
+ const reasonStr = reason === null ? "null" : `"${reason}"`;
102
+ return `ToClientWebSocketClose{code: ${codeStr}, reason: ${reasonStr}}`;
103
+ }
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Stringify Command for logging
109
+ * Handles ArrayBuffers, BigInts, and Maps that can't be JSON.stringified
110
+ */
111
+ export function stringifyCommand(command: protocol.Command): string {
112
+ switch (command.tag) {
113
+ case "CommandStartActor": {
114
+ const { actorId, generation, config } = command.val;
115
+ const keyStr = config.key === null ? "null" : `"${config.key}"`;
116
+ const inputStr =
117
+ config.input === null
118
+ ? "null"
119
+ : stringifyArrayBuffer(config.input);
120
+ return `CommandStartActor{actorId: "${actorId}", generation: ${generation}, config: {name: "${config.name}", key: ${keyStr}, createTs: ${stringifyBigInt(config.createTs)}, input: ${inputStr}}}`;
121
+ }
122
+ case "CommandStopActor": {
123
+ const { actorId, generation } = command.val;
124
+ return `CommandStopActor{actorId: "${actorId}", generation: ${generation}}`;
125
+ }
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Stringify CommandWrapper for logging
131
+ * Handles ArrayBuffers, BigInts, and Maps that can't be JSON.stringified
132
+ */
133
+ export function stringifyCommandWrapper(
134
+ wrapper: protocol.CommandWrapper,
135
+ ): string {
136
+ return `CommandWrapper{index: ${stringifyBigInt(wrapper.index)}, inner: ${stringifyCommand(wrapper.inner)}}`;
137
+ }
138
+
139
+ /**
140
+ * Stringify Event for logging
141
+ * Handles ArrayBuffers, BigInts, and Maps that can't be JSON.stringified
142
+ */
143
+ export function stringifyEvent(event: protocol.Event): string {
144
+ switch (event.tag) {
145
+ case "EventActorIntent": {
146
+ const { actorId, generation, intent } = event.val;
147
+ const intentStr =
148
+ intent.tag === "ActorIntentSleep"
149
+ ? "Sleep"
150
+ : intent.tag === "ActorIntentStop"
151
+ ? "Stop"
152
+ : "Unknown";
153
+ return `EventActorIntent{actorId: "${actorId}", generation: ${generation}, intent: ${intentStr}}`;
154
+ }
155
+ case "EventActorStateUpdate": {
156
+ const { actorId, generation, state } = event.val;
157
+ let stateStr: string;
158
+ if (state.tag === "ActorStateRunning") {
159
+ stateStr = "Running";
160
+ } else if (state.tag === "ActorStateStopped") {
161
+ const { code, message } = state.val;
162
+ const messageStr = message === null ? "null" : `"${message}"`;
163
+ stateStr = `Stopped{code: ${code}, message: ${messageStr}}`;
164
+ } else {
165
+ stateStr = "Unknown";
166
+ }
167
+ return `EventActorStateUpdate{actorId: "${actorId}", generation: ${generation}, state: ${stateStr}}`;
168
+ }
169
+ case "EventActorSetAlarm": {
170
+ const { actorId, generation, alarmTs } = event.val;
171
+ const alarmTsStr =
172
+ alarmTs === null ? "null" : stringifyBigInt(alarmTs);
173
+ return `EventActorSetAlarm{actorId: "${actorId}", generation: ${generation}, alarmTs: ${alarmTsStr}}`;
174
+ }
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Stringify EventWrapper for logging
180
+ * Handles ArrayBuffers, BigInts, and Maps that can't be JSON.stringified
181
+ */
182
+ export function stringifyEventWrapper(wrapper: protocol.EventWrapper): string {
183
+ return `EventWrapper{index: ${stringifyBigInt(wrapper.index)}, inner: ${stringifyEvent(wrapper.inner)}}`;
184
+ }
package/src/tunnel.ts CHANGED
@@ -1,8 +1,13 @@
1
1
  import type * as protocol from "@rivetkit/engine-runner-protocol";
2
2
  import type { MessageId, RequestId } from "@rivetkit/engine-runner-protocol";
3
+ import type { Logger } from "pino";
3
4
  import { stringify as uuidstringify, v4 as uuidv4 } from "uuid";
4
5
  import { logger } from "./log";
5
6
  import type { ActorInstance, Runner } from "./mod";
7
+ import {
8
+ stringifyToClientTunnelMessageKind,
9
+ stringifyToServerTunnelMessageKind,
10
+ } from "./stringify";
6
11
  import { unreachable } from "./utils";
7
12
  import { WebSocketTunnelAdapter } from "./websocket-tunnel-adapter";
8
13
 
@@ -22,6 +27,12 @@ interface PendingTunnelMessage {
22
27
  requestIdStr: string;
23
28
  }
24
29
 
30
+ class RunnerShutdownError extends Error {
31
+ constructor() {
32
+ super("Runner shut down");
33
+ }
34
+ }
35
+
25
36
  export class Tunnel {
26
37
  #runner: Runner;
27
38
 
@@ -35,6 +46,10 @@ export class Tunnel {
35
46
 
36
47
  #gcInterval?: NodeJS.Timeout;
37
48
 
49
+ get log(): Logger | undefined {
50
+ return this.#runner.log;
51
+ }
52
+
38
53
  constructor(runner: Runner) {
39
54
  this.#runner = runner;
40
55
  }
@@ -44,20 +59,34 @@ export class Tunnel {
44
59
  }
45
60
 
46
61
  shutdown() {
62
+ // NOTE: Pegboard WS already closed at this point, cannot send
63
+ // anything. All teardown logic is handled by pegboard-runner.
64
+
47
65
  if (this.#gcInterval) {
48
66
  clearInterval(this.#gcInterval);
49
67
  this.#gcInterval = undefined;
50
68
  }
51
69
 
52
70
  // Reject all pending requests
71
+ //
72
+ // RunnerShutdownError will be explicitly ignored
53
73
  for (const [_, request] of this.#actorPendingRequests) {
54
- request.reject(new Error("Tunnel shutting down"));
74
+ request.reject(new RunnerShutdownError());
55
75
  }
56
76
  this.#actorPendingRequests.clear();
57
77
 
58
78
  // Close all WebSockets
79
+ //
80
+ // The WebSocket close event with retry is automatically sent when the
81
+ // runner WS closes, so we only need to notify the client that the WS
82
+ // closed:
83
+ // https://github.com/rivet-dev/rivet/blob/00d4f6a22da178a6f8115e5db50d96c6f8387c2e/engine/packages/pegboard-runner/src/lib.rs#L157
59
84
  for (const [_, ws] of this.#actorWebSockets) {
60
- ws.__closeWithRetry();
85
+ // Only close non-hibernatable websockets to prevent sending
86
+ // unnecessary close messages for websockets that will be hibernated
87
+ if (!ws.canHibernate) {
88
+ ws.__closeWithoutCallback(1000, "ws.tunnel_shutdown");
89
+ }
61
90
  }
62
91
  this.#actorWebSockets.clear();
63
92
  }
@@ -68,9 +97,11 @@ export class Tunnel {
68
97
  ) {
69
98
  // TODO: Switch this with runner WS
70
99
  if (!this.#runner.__webSocketReady()) {
71
- logger()?.warn(
72
- "cannot send tunnel message, socket not connected to engine",
73
- );
100
+ this.log?.warn({
101
+ msg: "cannot send tunnel message, socket not connected to engine",
102
+ requestId: idToStr(requestId),
103
+ message: stringifyToServerTunnelMessageKind(messageKind),
104
+ });
74
105
  return;
75
106
  }
76
107
 
@@ -84,11 +115,11 @@ export class Tunnel {
84
115
  requestIdStr,
85
116
  });
86
117
 
87
- logger()?.debug({
118
+ this.log?.debug({
88
119
  msg: "send tunnel msg",
89
120
  requestId: requestIdStr,
90
121
  messageId: messageIdStr,
91
- message: messageKind,
122
+ message: stringifyToServerTunnelMessageKind(messageKind),
92
123
  });
93
124
 
94
125
  // Send message
@@ -117,7 +148,7 @@ export class Tunnel {
117
148
  },
118
149
  };
119
150
 
120
- logger()?.debug({
151
+ this.log?.debug({
121
152
  msg: "ack tunnel msg",
122
153
  requestId: idToStr(requestId),
123
154
  messageId: idToStr(messageId),
@@ -184,7 +215,7 @@ export class Tunnel {
184
215
 
185
216
  // Remove timed out messages
186
217
  if (messagesToDelete.length > 0) {
187
- logger()?.warn({
218
+ this.log?.warn({
188
219
  msg: "purging unacked tunnel messages, this indicates that the Rivet Engine is disconnected or not responding",
189
220
  count: messagesToDelete.length,
190
221
  });
@@ -208,11 +239,11 @@ export class Tunnel {
208
239
  actor.requests.clear();
209
240
 
210
241
  // Flush acks and close all WebSockets for this actor
211
- for (const webSocketId of actor.webSockets) {
212
- const ws = this.#actorWebSockets.get(webSocketId);
242
+ for (const requestIdStr of actor.webSockets) {
243
+ const ws = this.#actorWebSockets.get(requestIdStr);
213
244
  if (ws) {
214
245
  ws.__closeWithRetry(1000, "Actor stopped");
215
- this.#actorWebSockets.delete(webSocketId);
246
+ this.#actorWebSockets.delete(requestIdStr);
216
247
  }
217
248
  }
218
249
  actor.webSockets.clear();
@@ -225,7 +256,7 @@ export class Tunnel {
225
256
  ): Promise<Response> {
226
257
  // Validate actor exists
227
258
  if (!this.#runner.hasActor(actorId)) {
228
- logger()?.warn({
259
+ this.log?.warn({
229
260
  msg: "ignoring request for unknown actor",
230
261
  actorId,
231
262
  });
@@ -257,11 +288,11 @@ export class Tunnel {
257
288
  async handleTunnelMessage(message: protocol.ToClientTunnelMessage) {
258
289
  const requestIdStr = idToStr(message.requestId);
259
290
  const messageIdStr = idToStr(message.messageId);
260
- logger()?.debug({
291
+ this.log?.debug({
261
292
  msg: "receive tunnel msg",
262
293
  requestId: requestIdStr,
263
294
  messageId: messageIdStr,
264
- message: message.messageKind,
295
+ message: stringifyToClientTunnelMessageKind(message.messageKind),
265
296
  });
266
297
 
267
298
  if (message.messageKind.tag === "TunnelAck") {
@@ -271,7 +302,7 @@ export class Tunnel {
271
302
  const didDelete =
272
303
  this.#pendingTunnelMessages.delete(messageIdStr);
273
304
  if (!didDelete) {
274
- logger()?.warn({
305
+ this.log?.warn({
275
306
  msg: "received tunnel ack for nonexistent message",
276
307
  requestId: requestIdStr,
277
308
  messageId: messageIdStr,
@@ -402,8 +433,16 @@ export class Tunnel {
402
433
  await this.#sendResponse(requestId, response);
403
434
  }
404
435
  } catch (error) {
405
- logger()?.error({ msg: "error handling request", error });
406
- this.#sendResponseError(requestId, 500, "Internal Server Error");
436
+ if (error instanceof RunnerShutdownError) {
437
+ this.log?.debug({ msg: "catught runner shutdown error" });
438
+ } else {
439
+ this.log?.error({ msg: "error handling request", error });
440
+ this.#sendResponseError(
441
+ requestId,
442
+ 500,
443
+ "Internal Server Error",
444
+ );
445
+ }
407
446
  } finally {
408
447
  // Clean up request tracking
409
448
  const actor = this.#runner.getActor(req.actorId);
@@ -493,11 +532,12 @@ export class Tunnel {
493
532
  requestId: protocol.RequestId,
494
533
  open: protocol.ToClientWebSocketOpen,
495
534
  ) {
496
- const webSocketId = idToStr(requestId);
535
+ const requestIdStr = idToStr(requestId);
536
+
497
537
  // Validate actor exists
498
538
  const actor = this.#runner.getActor(open.actorId);
499
539
  if (!actor) {
500
- logger()?.warn({
540
+ this.log?.warn({
501
541
  msg: "ignoring websocket for unknown actor",
502
542
  actorId: open.actorId,
503
543
  });
@@ -521,7 +561,7 @@ export class Tunnel {
521
561
  const websocketHandler = this.#runner.config.websocket;
522
562
 
523
563
  if (!websocketHandler) {
524
- logger()?.error({
564
+ this.log?.error({
525
565
  msg: "no websocket handler configured for tunnel",
526
566
  });
527
567
  // Send close immediately
@@ -536,15 +576,33 @@ export class Tunnel {
536
576
  return;
537
577
  }
538
578
 
579
+ // Close existing WebSocket if one already exists for this request ID.
580
+ // There should always be a close message sent before another open
581
+ // message for the same message ID.
582
+ //
583
+ // This should never occur if all is functioning correctly, but this
584
+ // prevents any edge case that would result in duplicate WebSockets for
585
+ // the same request.
586
+ const existingAdapter = this.#actorWebSockets.get(requestIdStr);
587
+ if (existingAdapter) {
588
+ this.log?.warn({
589
+ msg: "closing existing websocket for duplicate open event for the same request id",
590
+ requestId: requestIdStr,
591
+ });
592
+ // Close without sending a message through the tunnel since the server
593
+ // already knows about the new connection
594
+ existingAdapter.__closeWithoutCallback(1000, "ws.duplicate_open");
595
+ }
596
+
539
597
  // Track this WebSocket for the actor
540
598
  if (actor) {
541
- actor.webSockets.add(webSocketId);
599
+ actor.webSockets.add(requestIdStr);
542
600
  }
543
601
 
544
602
  try {
545
603
  // Create WebSocket adapter
546
604
  const adapter = new WebSocketTunnelAdapter(
547
- webSocketId,
605
+ requestIdStr,
548
606
  (data: ArrayBuffer | string, isBinary: boolean) => {
549
607
  // Send message through tunnel
550
608
  const dataBuffer =
@@ -573,17 +631,17 @@ export class Tunnel {
573
631
  });
574
632
 
575
633
  // Remove from map
576
- this.#actorWebSockets.delete(webSocketId);
634
+ this.#actorWebSockets.delete(requestIdStr);
577
635
 
578
636
  // Clean up actor tracking
579
637
  if (actor) {
580
- actor.webSockets.delete(webSocketId);
638
+ actor.webSockets.delete(requestIdStr);
581
639
  }
582
640
  },
583
641
  );
584
642
 
585
643
  // Store adapter
586
- this.#actorWebSockets.set(webSocketId, adapter);
644
+ this.#actorWebSockets.set(requestIdStr, adapter);
587
645
 
588
646
  // Convert headers to map
589
647
  //
@@ -613,6 +671,8 @@ export class Tunnel {
613
671
  requestId,
614
672
  request,
615
673
  );
674
+ adapter.canHibernate = hibernationConfig.enabled;
675
+
616
676
  this.#sendMessage(requestId, {
617
677
  tag: "ToServerWebSocketOpen",
618
678
  val: {
@@ -633,7 +693,7 @@ export class Tunnel {
633
693
  request,
634
694
  );
635
695
  } catch (error) {
636
- logger()?.error({ msg: "error handling websocket open", error });
696
+ this.log?.error({ msg: "error handling websocket open", error });
637
697
  // Send close on error
638
698
  this.#sendMessage(requestId, {
639
699
  tag: "ToServerWebSocketClose",
@@ -644,11 +704,11 @@ export class Tunnel {
644
704
  },
645
705
  });
646
706
 
647
- this.#actorWebSockets.delete(webSocketId);
707
+ this.#actorWebSockets.delete(requestIdStr);
648
708
 
649
709
  // Clean up actor tracking
650
710
  if (actor) {
651
- actor.webSockets.delete(webSocketId);
711
+ actor.webSockets.delete(requestIdStr);
652
712
  }
653
713
  }
654
714
  }
@@ -658,8 +718,8 @@ export class Tunnel {
658
718
  requestId: ArrayBuffer,
659
719
  msg: protocol.ToClientWebSocketMessage,
660
720
  ): Promise<boolean> {
661
- const webSocketId = idToStr(requestId);
662
- const adapter = this.#actorWebSockets.get(webSocketId);
721
+ const requestIdStr = idToStr(requestId);
722
+ const adapter = this.#actorWebSockets.get(requestIdStr);
663
723
  if (adapter) {
664
724
  const data = msg.binary
665
725
  ? new Uint8Array(msg.data)
@@ -677,7 +737,7 @@ export class Tunnel {
677
737
  }
678
738
 
679
739
  __ackWebsocketMessage(requestId: ArrayBuffer, index: number) {
680
- logger()?.debug({
740
+ this.log?.debug({
681
741
  msg: "ack ws msg",
682
742
  requestId: idToStr(requestId),
683
743
  index,
package/src/utils.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { logger } from "./log";
2
+
1
3
  export function unreachable(x: never): never {
2
4
  throw `Unreachable: ${x}`;
3
5
  }
@@ -29,3 +31,36 @@ export function calculateBackoff(
29
31
 
30
32
  return Math.floor(delay);
31
33
  }
34
+
35
+ export interface ParsedCloseReason {
36
+ group: string;
37
+ error: string;
38
+ rayId?: string;
39
+ }
40
+
41
+ /**
42
+ * Parses a WebSocket close reason in the format: {group}.{error} or {group}.{error}#{ray_id}
43
+ *
44
+ * Examples:
45
+ * - "ws.eviction#t1s80so6h3irenp8ymzltfoittcl00"
46
+ * - "ws.client_closed"
47
+ *
48
+ * Returns undefined if the format is invalid
49
+ */
50
+ export function parseWebSocketCloseReason(
51
+ reason: string,
52
+ ): ParsedCloseReason | undefined {
53
+ const [mainPart, rayId] = reason.split("#");
54
+ const [group, error] = mainPart.split(".");
55
+
56
+ if (!group || !error) {
57
+ logger()?.warn({ msg: "failed to parse close reason", reason });
58
+ return undefined;
59
+ }
60
+
61
+ return {
62
+ group,
63
+ error,
64
+ rayId,
65
+ };
66
+ }
@@ -18,6 +18,7 @@ export class WebSocketTunnelAdapter {
18
18
  #url = "";
19
19
  #sendCallback: (data: ArrayBuffer | string, isBinary: boolean) => void;
20
20
  #closeCallback: (code?: number, reason?: string, retry?: boolean) => void;
21
+ #canHibernate: boolean = false;
21
22
 
22
23
  // Event buffering for events fired before listeners are attached
23
24
  #bufferedEvents: Array<{
@@ -73,6 +74,16 @@ export class WebSocketTunnelAdapter {
73
74
  return this.#url;
74
75
  }
75
76
 
77
+ /** @experimental */
78
+ get canHibernate(): boolean {
79
+ return this.#canHibernate;
80
+ }
81
+
82
+ /** @experimental */
83
+ set canHibernate(value: boolean) {
84
+ this.#canHibernate = value;
85
+ }
86
+
76
87
  get onopen(): ((this: any, ev: any) => any) | null {
77
88
  return this.#onopen;
78
89
  }
@@ -190,14 +201,23 @@ export class WebSocketTunnelAdapter {
190
201
  }
191
202
 
192
203
  close(code?: number, reason?: string): void {
193
- this.closeInner(code, reason);
204
+ this.closeInner(code, reason, false, true);
194
205
  }
195
206
 
196
207
  __closeWithRetry(code?: number, reason?: string): void {
197
- this.closeInner(code, reason, true);
208
+ this.closeInner(code, reason, true, true);
198
209
  }
199
210
 
200
- closeInner(code?: number, reason?: string, retry: boolean = false): void {
211
+ __closeWithoutCallback(code?: number, reason?: string): void {
212
+ this.closeInner(code, reason, false, false);
213
+ }
214
+
215
+ closeInner(
216
+ code: number | undefined,
217
+ reason: string | undefined,
218
+ retry: boolean,
219
+ callback: boolean,
220
+ ): void {
201
221
  if (
202
222
  this.#readyState === 2 || // CLOSING
203
223
  this.#readyState === 3 // CLOSED
@@ -208,7 +228,9 @@ export class WebSocketTunnelAdapter {
208
228
  this.#readyState = 2; // CLOSING
209
229
 
210
230
  // Send close through tunnel
211
- this.#closeCallback(code, reason, retry);
231
+ if (callback) {
232
+ this.#closeCallback(code, reason, retry);
233
+ }
212
234
 
213
235
  // Update state and fire event
214
236
  this.#readyState = 3; // CLOSED