@limrun/api 0.30.0 → 0.31.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +26 -1
  2. package/index.d.mts +2 -1
  3. package/index.d.mts.map +1 -1
  4. package/index.d.ts +2 -1
  5. package/index.d.ts.map +1 -1
  6. package/index.js +7 -1
  7. package/index.js.map +1 -1
  8. package/index.mjs +2 -0
  9. package/index.mjs.map +1 -1
  10. package/package.json +11 -1
  11. package/rbe-bep.d.mts +27 -0
  12. package/rbe-bep.d.mts.map +1 -0
  13. package/rbe-bep.d.ts +27 -0
  14. package/rbe-bep.d.ts.map +1 -0
  15. package/rbe-bep.js +130 -0
  16. package/rbe-bep.js.map +1 -0
  17. package/rbe-bep.mjs +127 -0
  18. package/rbe-bep.mjs.map +1 -0
  19. package/resources/index.d.mts +1 -1
  20. package/resources/index.d.mts.map +1 -1
  21. package/resources/index.d.ts +1 -1
  22. package/resources/index.d.ts.map +1 -1
  23. package/resources/index.js +3 -1
  24. package/resources/index.js.map +1 -1
  25. package/resources/index.mjs +1 -1
  26. package/resources/index.mjs.map +1 -1
  27. package/resources/xcode-instances-helpers.d.mts +110 -1
  28. package/resources/xcode-instances-helpers.d.mts.map +1 -1
  29. package/resources/xcode-instances-helpers.d.ts +110 -1
  30. package/resources/xcode-instances-helpers.d.ts.map +1 -1
  31. package/resources/xcode-instances-helpers.js +153 -18
  32. package/resources/xcode-instances-helpers.js.map +1 -1
  33. package/resources/xcode-instances-helpers.mjs +150 -17
  34. package/resources/xcode-instances-helpers.mjs.map +1 -1
  35. package/src/index.ts +9 -0
  36. package/src/rbe-bep.ts +155 -0
  37. package/src/resources/index.ts +3 -0
  38. package/src/resources/xcode-instances-helpers.ts +284 -19
  39. package/src/tunnel.ts +62 -3
  40. package/src/version.ts +1 -1
  41. package/tunnel.d.mts.map +1 -1
  42. package/tunnel.d.ts.map +1 -1
  43. package/tunnel.js +59 -2
  44. package/tunnel.js.map +1 -1
  45. package/tunnel.mjs +59 -2
  46. package/tunnel.mjs.map +1 -1
  47. package/version.d.mts +1 -1
  48. package/version.d.ts +1 -1
  49. package/version.js +1 -1
  50. package/version.mjs +1 -1
@@ -4,6 +4,7 @@ import crypto from 'crypto';
4
4
 
5
5
  import { XcodeInstances as GeneratedXcodeInstances, type XcodeInstance } from './xcode-instances';
6
6
  import { type IosInstance } from './ios-instances';
7
+ import { parseTopLevelIpaDigest } from '../rbe-bep';
7
8
  import { exec, type ExecChildProcess, type ExecRequest } from '../exec-client';
8
9
  import {
9
10
  syncFolder as syncFolderImpl,
@@ -13,7 +14,11 @@ import {
13
14
  import { createIgnoreFn } from '../folder-sync-ignore';
14
15
  import { nodeProxyTransport } from '../internal/proxy-transport';
15
16
  import { directInstanceHttpError } from '../internal/direct-instance-errors';
17
+ import { LimrunError } from '../core/error';
16
18
  import { validateBuildSettings } from '../build-settings';
19
+ import { startTcpTunnel, type Tunnel } from '../tunnel';
20
+
21
+ export type { Tunnel } from '../tunnel';
17
22
 
18
23
  export type LogLevel = 'none' | 'error' | 'warn' | 'info' | 'debug';
19
24
 
@@ -138,6 +143,64 @@ export type SimulatorAttachResult = {
138
143
  installError?: string;
139
144
  };
140
145
 
146
+ /**
147
+ * Status of the instance's embedded Bazel Remote Build Execution stack, as
148
+ * reported by limbuild's /rbe endpoints.
149
+ */
150
+ export type RbeStatus = {
151
+ state: 'stopped' | 'starting' | 'running' | 'failed';
152
+ /** Loopback port of the RBE gRPC frontend inside the instance, when running. */
153
+ frontendPort?: number;
154
+ /**
155
+ * The Xcode version key remote actions must declare via
156
+ * XCODE_VERSION_OVERRIDE (e.g. 26.4.0.17E192), when running. Clients
157
+ * generate their xcode_version_config from it.
158
+ */
159
+ xcodeVersion?: string;
160
+ error?: string;
161
+ };
162
+
163
+ /** Default local TCP port the RBE tunnel listens on. */
164
+ export const DEFAULT_RBE_TUNNEL_PORT = 8980;
165
+
166
+ export type RbeStartOptions = {
167
+ /** Max size of the content-addressable store (CAS), in bytes. */
168
+ casMaxBytes?: number;
169
+ /** Max size of the action cache (AC), in bytes. */
170
+ acMaxBytes?: number;
171
+ /** Number of concurrent build actions the embedded worker runs. */
172
+ workerConcurrency?: number;
173
+ };
174
+
175
+ export type RbeTunnelOptions = {
176
+ /** Local port to listen on. Defaults to DEFAULT_RBE_TUNNEL_PORT (8980). */
177
+ port?: number;
178
+ /** Local address to listen on. Defaults to 127.0.0.1. */
179
+ host?: string;
180
+ logLevel?: LogLevel;
181
+ };
182
+
183
+ /** Content-addressed digest of a build artifact in the instance's RBE cache. */
184
+ type RbeArtifactDigest = {
185
+ /** Lowercase hex SHA-256 of the blob (matches --digest_function=sha256). */
186
+ hash: string;
187
+ /** Size of the blob in bytes. */
188
+ sizeBytes: number;
189
+ };
190
+
191
+ export type RbeInstallResult = {
192
+ /** True when the app was synced and installed on the attached simulator. */
193
+ installed: boolean;
194
+ /** CFBundleIdentifier of the installed app, as reported by the simulator. */
195
+ bundleId?: string;
196
+ /** The .app bundle name discovered inside the .ipa (Payload/<appName>.app). */
197
+ appName?: string;
198
+ /** Differential-sync duration in milliseconds. */
199
+ syncDurationMs?: number;
200
+ /** Simulator install duration in milliseconds. */
201
+ installDurationMs?: number;
202
+ };
203
+
141
204
  export type XcodeClient = {
142
205
  /**
143
206
  * Sync source code to the xcode instance. In watch mode, keeps syncing on changes.
@@ -157,7 +220,9 @@ export type XcodeClient = {
157
220
 
158
221
  /**
159
222
  * Attach a simulator to this xcode instance.
160
- * After attaching, builds will auto-install on the simulator.
223
+ * After attaching, the latest xcodebuild build (if any) is installed on the
224
+ * simulator. RBE builds are not auto-installed on attach — install them
225
+ * explicitly with `installRbeBuildFromBep` while a simulator is attached.
161
226
  */
162
227
  attachSimulator: (
163
228
  simulator: IosInstance | { apiUrl: string; token: string },
@@ -167,6 +232,52 @@ export type XcodeClient = {
167
232
  * Return the currently attached simulator and latest installable build state.
168
233
  */
169
234
  getSimulator: () => Promise<SimulatorStatus>;
235
+
236
+ /**
237
+ * Start the instance's embedded Bazel Remote Build Execution stack.
238
+ * Idempotent: returns the running status when already up.
239
+ */
240
+ startRbe: (opts?: RbeStartOptions) => Promise<RbeStatus>;
241
+
242
+ /** Return the current RBE stack status. */
243
+ getRbe: () => Promise<RbeStatus>;
244
+
245
+ /** Stop the RBE stack. */
246
+ stopRbe: () => Promise<RbeStatus>;
247
+
248
+ /**
249
+ * Install a Bazel RBE build on the attached simulator, server-side, from its
250
+ * build event log (BEP). Parses the top-level `.ipa`'s CAS digest for `target`
251
+ * out of `bep` (the contents of `--build_event_json_file`); the instance fetches
252
+ * the blob from its embedded cache, unpacks the .app, and pushes it to the
253
+ * attached simulator via the differential-sync path — no client round-trip.
254
+ * Requires a running RBE stack and an attached simulator (installed=false when
255
+ * none is attached — attach one and call again). Throws a descriptive error if
256
+ * the target/.ipa is absent, was downloaded locally, or was built with a
257
+ * non-SHA256 digest (e.g. BLAKE3) the instance cache can't resolve. `ipaName` is
258
+ * the `.ipa` file name Bazel reported.
259
+ */
260
+ installRbeBuildFromBep: (opts: {
261
+ bep: string;
262
+ target: string;
263
+ }) => Promise<RbeInstallResult & { ipaName: string }>;
264
+
265
+ /**
266
+ * Open a local TCP listener bridged to the instance's RBE gRPC frontend
267
+ * over a multiplexed websocket. Point bazel at it with
268
+ * --remote_executor=grpc://127.0.0.1:<port>.
269
+ */
270
+ startRbeTunnel: (opts?: RbeTunnelOptions) => Promise<Tunnel>;
271
+
272
+ /**
273
+ * Create a new iOS simulator instance and attach it to this xcode instance.
274
+ * Deletes the simulator if attach fails so it is never leaked. Returns the new
275
+ * simulator's instance id (record it for teardown) and the instance itself.
276
+ */
277
+ attachNewSimulator: () => Promise<{ iosInstanceId: string; simulator: IosInstance }>;
278
+
279
+ /** Best-effort delete of a simulator instance. Never throws; returns success. */
280
+ deleteSimulator: (iosInstanceId: string) => Promise<boolean>;
170
281
  };
171
282
 
172
283
  export type XcodeCreateClientParams =
@@ -221,6 +332,57 @@ async function fetchSandboxInfo(apiUrl: string, token: string): Promise<{ homeDi
221
332
  };
222
333
  }
223
334
 
335
+ /**
336
+ * Derives the websocket URL of limbuild's /rbe/tunnel endpoint from the
337
+ * instance apiUrl, mirroring deriveReverseTunnelUrl in ios-client.ts.
338
+ */
339
+ export function deriveRbeTunnelUrl(apiUrl: string): string {
340
+ const url = new URL(apiUrl);
341
+ if (url.protocol === 'https:') {
342
+ url.protocol = 'wss:';
343
+ } else if (url.protocol === 'http:') {
344
+ url.protocol = 'ws:';
345
+ } else {
346
+ throw new Error(`Unsupported apiUrl protocol for rbe tunnel: ${url.protocol}`);
347
+ }
348
+ url.pathname = `${url.pathname.replace(/\/+$/, '')}/rbe/tunnel`;
349
+ url.search = '';
350
+ url.hash = '';
351
+ // The tunnel layer (startMultiplexedTcpTunnel) appends mode=multiplexed to
352
+ // the URL itself, so we don't set it here.
353
+ return url.toString();
354
+ }
355
+
356
+ /**
357
+ * Raised when an `/rbe` request returns 404. limbuild's `/rbe` routes only
358
+ * exist on builds with remote-execution support, so a 404 there means RBE is
359
+ * unavailable on this instance (an older limbuild, or the wrong environment) —
360
+ * NOT that the instance is missing. Kept distinct from NotFoundError so the CLI
361
+ * does not mistake it for a vanished instance and spin up replacements.
362
+ */
363
+ export class RbeUnsupportedError extends LimrunError {
364
+ constructor(operation: string) {
365
+ super(
366
+ `Remote build execution is not available on this Xcode instance (${operation} returned 404). ` +
367
+ 'Its limbuild may predate RBE support, or you may be targeting the wrong environment ' +
368
+ '(for example production instead of staging).',
369
+ );
370
+ this.name = 'RbeUnsupportedError';
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Reads an `/rbe` JSON response, mapping a 404 to RbeUnsupportedError (a route
376
+ * 404 means RBE is not supported here, never a missing instance).
377
+ */
378
+ async function readRbeResponse<T>(res: Response, operation: string): Promise<T> {
379
+ if (res.status === 404) {
380
+ await res.text().catch(() => undefined);
381
+ throw new RbeUnsupportedError(operation);
382
+ }
383
+ return readJsonResponse<T>(res, operation);
384
+ }
385
+
224
386
  async function readJsonResponse<T>(res: Response, operation: string): Promise<T> {
225
387
  const text = await res.text();
226
388
  if (!res.ok) {
@@ -255,6 +417,84 @@ export class XcodeInstances extends GeneratedXcodeInstances {
255
417
  return sandboxInfoPromise;
256
418
  };
257
419
 
420
+ // Shared local closures. The methods below live in an object literal over
421
+ // these closures (not `this`), so anything reused across methods is defined
422
+ // here once and called from each method.
423
+ const postRbeInstall = async (
424
+ ipaDigest: RbeArtifactDigest,
425
+ target?: string,
426
+ ): Promise<RbeInstallResult> => {
427
+ const res = await nodeProxyTransport.fetch(`${apiUrl}/rbe/install`, {
428
+ method: 'POST',
429
+ headers: {
430
+ 'Content-Type': 'application/json',
431
+ Authorization: `Bearer ${token}`,
432
+ },
433
+ body: JSON.stringify({ ipaDigest, ...(target ? { target } : {}) }),
434
+ });
435
+ return readRbeResponse<RbeInstallResult>(res, 'POST /rbe/install');
436
+ };
437
+
438
+ const installFromBep = async (
439
+ bep: string,
440
+ target: string,
441
+ ): Promise<RbeInstallResult & { ipaName: string }> => {
442
+ const digest = parseTopLevelIpaDigest(bep, target); // throws BLAKE3 / no-ipa errors
443
+ const result = await postRbeInstall({ hash: digest.hash, sizeBytes: digest.sizeBytes }, target);
444
+ return { ...result, ipaName: digest.ipaName };
445
+ };
446
+
447
+ const attachSimulatorImpl = async (
448
+ simulator: IosInstance | { apiUrl: string; token: string },
449
+ ): Promise<SimulatorAttachResult> => {
450
+ let simApiUrl: string;
451
+ let simToken: string;
452
+ if ('status' in simulator) {
453
+ if (!simulator.status.apiUrl) {
454
+ throw new Error('Simulator instance not ready: apiUrl is not available');
455
+ }
456
+ simApiUrl = simulator.status.apiUrl;
457
+ simToken = simulator.status.token;
458
+ } else {
459
+ simApiUrl = simulator.apiUrl;
460
+ simToken = simulator.token;
461
+ }
462
+ const res = await nodeProxyTransport.fetch(`${apiUrl}/simulator`, {
463
+ method: 'POST',
464
+ headers: {
465
+ 'Content-Type': 'application/json',
466
+ Authorization: `Bearer ${token}`,
467
+ },
468
+ body: JSON.stringify({ apiUrl: simApiUrl, token: simToken }),
469
+ });
470
+ return readJsonResponse<SimulatorAttachResult>(res, 'POST /simulator');
471
+ };
472
+
473
+ const deleteSimulatorImpl = async (iosInstanceId: string): Promise<boolean> => {
474
+ try {
475
+ await client.iosInstances.delete(iosInstanceId);
476
+ return true;
477
+ } catch {
478
+ return false;
479
+ }
480
+ };
481
+
482
+ const attachNewSimulatorImpl = async (): Promise<{
483
+ iosInstanceId: string;
484
+ simulator: IosInstance;
485
+ }> => {
486
+ const simulator = await client.iosInstances.create({ wait: true });
487
+ // The sim exists from here; if attach fails, delete it so we never leak an
488
+ // orphan the caller can't reap.
489
+ try {
490
+ await attachSimulatorImpl(simulator);
491
+ } catch (err) {
492
+ await deleteSimulatorImpl(simulator.metadata.id);
493
+ throw err;
494
+ }
495
+ return { iosInstanceId: simulator.metadata.id, simulator };
496
+ };
497
+
258
498
  return {
259
499
  async sync(localCodePath: string, opts?: SyncOptions): Promise<SyncResult> {
260
500
  const resolvedPath = path.resolve(localCodePath);
@@ -375,31 +615,56 @@ export class XcodeInstances extends GeneratedXcodeInstances {
375
615
  return readJsonResponse<SimulatorStatus>(res, 'GET /simulator');
376
616
  },
377
617
 
378
- async attachSimulator(
379
- simulator: IosInstance | { apiUrl: string; token: string },
380
- ): Promise<SimulatorAttachResult> {
381
- let simApiUrl: string;
382
- let simToken: string;
383
- if ('status' in simulator) {
384
- if (!simulator.status.apiUrl) {
385
- throw new Error('Simulator instance not ready: apiUrl is not available');
386
- }
387
- simApiUrl = simulator.status.apiUrl;
388
- simToken = simulator.status.token;
389
- } else {
390
- simApiUrl = simulator.apiUrl;
391
- simToken = simulator.token;
392
- }
618
+ attachSimulator: attachSimulatorImpl,
619
+ attachNewSimulator: attachNewSimulatorImpl,
620
+ deleteSimulator: deleteSimulatorImpl,
393
621
 
394
- const res = await nodeProxyTransport.fetch(`${apiUrl}/simulator`, {
622
+ async startRbe(opts?: RbeStartOptions): Promise<RbeStatus> {
623
+ const res = await nodeProxyTransport.fetch(`${apiUrl}/rbe`, {
395
624
  method: 'POST',
396
625
  headers: {
397
626
  'Content-Type': 'application/json',
398
627
  Authorization: `Bearer ${token}`,
399
628
  },
400
- body: JSON.stringify({ apiUrl: simApiUrl, token: simToken }),
629
+ body: JSON.stringify(opts ?? {}),
401
630
  });
402
- return readJsonResponse<SimulatorAttachResult>(res, 'POST /simulator');
631
+ return readRbeResponse<RbeStatus>(res, 'POST /rbe');
632
+ },
633
+
634
+ async getRbe(): Promise<RbeStatus> {
635
+ const res = await nodeProxyTransport.fetch(`${apiUrl}/rbe`, {
636
+ method: 'GET',
637
+ headers: {
638
+ Authorization: `Bearer ${token}`,
639
+ },
640
+ });
641
+ return readRbeResponse<RbeStatus>(res, 'GET /rbe');
642
+ },
643
+
644
+ async stopRbe(): Promise<RbeStatus> {
645
+ const res = await nodeProxyTransport.fetch(`${apiUrl}/rbe`, {
646
+ method: 'DELETE',
647
+ headers: {
648
+ Authorization: `Bearer ${token}`,
649
+ },
650
+ });
651
+ return readRbeResponse<RbeStatus>(res, 'DELETE /rbe');
652
+ },
653
+
654
+ installRbeBuildFromBep: (opts: { bep: string; target: string }) =>
655
+ installFromBep(opts.bep, opts.target),
656
+
657
+ async startRbeTunnel(opts?: RbeTunnelOptions): Promise<Tunnel> {
658
+ return startTcpTunnel(
659
+ deriveRbeTunnelUrl(apiUrl),
660
+ token,
661
+ opts?.host ?? '127.0.0.1',
662
+ opts?.port ?? DEFAULT_RBE_TUNNEL_PORT,
663
+ {
664
+ mode: 'multiplexed',
665
+ logLevel: opts?.logLevel ?? params.logLevel ?? 'info',
666
+ },
667
+ );
403
668
  },
404
669
  };
405
670
  }
package/src/tunnel.ts CHANGED
@@ -899,8 +899,19 @@ async function startMultiplexedTcpTunnel(
899
899
  let ws: WebSocket | undefined;
900
900
  let pingInterval: NodeJS.Timeout | undefined;
901
901
  let intentionalDisconnect = false;
902
+ // True once the initial connect has succeeded. Initial-connect failures
903
+ // reject the outer promise (no reconnect); only post-establishment drops
904
+ // reconnect, keeping the local listener open so long builds survive a blip.
905
+ let established = false;
902
906
  let connectionState: TunnelConnectionState = 'connecting';
903
907
 
908
+ const maxReconnectAttempts = options?.maxReconnectAttempts ?? 6;
909
+ const reconnectDelay = options?.reconnectDelay ?? 1000;
910
+ const maxReconnectDelay = options?.maxReconnectDelay ?? 30000;
911
+ let reconnectAttempts = 0;
912
+ let reconnectTimeout: NodeJS.Timeout | undefined;
913
+ let lastError: string | undefined;
914
+
904
915
  const stateChangeCallbacks: Set<TunnelConnectionStateCallback> = new Set();
905
916
 
906
917
  const updateConnectionState = (newState: TunnelConnectionState): void => {
@@ -943,6 +954,10 @@ async function startMultiplexedTcpTunnel(
943
954
 
944
955
  const close = () => {
945
956
  intentionalDisconnect = true;
957
+ if (reconnectTimeout) {
958
+ clearTimeout(reconnectTimeout);
959
+ reconnectTimeout = undefined;
960
+ }
946
961
  cleanupWebSocket();
947
962
  closeAllConnections();
948
963
  updateConnectionState('disconnected');
@@ -951,6 +966,37 @@ async function startMultiplexedTcpTunnel(
951
966
  }
952
967
  };
953
968
 
969
+ // Single reconnect scheduler. Only ws.on('close') drives it (after the
970
+ // tunnel is established), so there is no double-scheduling race; the
971
+ // `reconnectTimeout` guard is belt-and-suspenders. Mirrors the singleton
972
+ // tunnel's backoff, but tears down per-connection streams on each drop
973
+ // since a multiplexed connId cannot be resumed across a WS reconnect.
974
+ const scheduleReconnect = (): void => {
975
+ if (intentionalDisconnect || reconnectTimeout) {
976
+ return;
977
+ }
978
+ if (isNonRetryableError(lastError ?? '')) {
979
+ logger.error(`Closing multiplexed tunnel (non-retryable error): ${lastError}`);
980
+ close();
981
+ return;
982
+ }
983
+ if (reconnectAttempts >= maxReconnectAttempts) {
984
+ logger.error(`Max reconnection attempts (${maxReconnectAttempts}) reached. Closing tunnel.`);
985
+ close();
986
+ return;
987
+ }
988
+ const currentDelay = Math.min(reconnectDelay * Math.pow(2, reconnectAttempts), maxReconnectDelay);
989
+ reconnectAttempts++;
990
+ updateConnectionState('reconnecting');
991
+ logger.debug(`Scheduling reconnection attempt ${reconnectAttempts} in ${currentDelay}ms...`);
992
+ reconnectTimeout = setTimeout(() => {
993
+ reconnectTimeout = undefined;
994
+ // On failure the new ws's own 'close' handler schedules the next
995
+ // attempt, so here we only swallow the rejection.
996
+ setupWebSocket().catch(() => {});
997
+ }, currentDelay);
998
+ };
999
+
954
1000
  // Send close signal for a connection (header-only packet)
955
1001
  const sendCloseSignal = (connId: number) => {
956
1002
  if (ws && ws.readyState === WebSocket.OPEN) {
@@ -992,6 +1038,7 @@ async function startMultiplexedTcpTunnel(
992
1038
 
993
1039
  ws.on('error', (err: any) => {
994
1040
  const errMessage = err.message || String(err);
1041
+ lastError = errMessage;
995
1042
  logger.error('WebSocket error:', errMessage, err.code || '');
996
1043
  rejectWs(err);
997
1044
  });
@@ -1001,17 +1048,28 @@ async function startMultiplexedTcpTunnel(
1001
1048
  clearInterval(pingInterval);
1002
1049
  pingInterval = undefined;
1003
1050
  }
1004
-
1005
- updateConnectionState('disconnected');
1006
1051
  logger.debug(`WebSocket disconnected (code=${code}, reason=${reason.toString()})`);
1007
1052
 
1008
- // Close all TCP connections when WebSocket closes
1053
+ // Per-connection streams cannot survive a WS drop (their remote peers
1054
+ // are gone), so tear them down; bazel's own gRPC retries re-establish
1055
+ // channels once the WS is back.
1009
1056
  closeAllConnections();
1057
+
1058
+ // Before establishment, the listening handler's catch rejects + closes
1059
+ // (no reconnect). After establishment, a non-intentional drop reconnects
1060
+ // (keeping the listener open) instead of killing the tunnel.
1061
+ if (!established || intentionalDisconnect) {
1062
+ updateConnectionState('disconnected');
1063
+ return;
1064
+ }
1065
+ scheduleReconnect();
1010
1066
  });
1011
1067
 
1012
1068
  ws.on('open', () => {
1013
1069
  const socket = ws as WebSocket;
1014
1070
  logger.debug('WebSocket connected');
1071
+ reconnectAttempts = 0;
1072
+ lastError = undefined;
1015
1073
  updateConnectionState('connected');
1016
1074
 
1017
1075
  pingInterval = setInterval(() => {
@@ -1090,6 +1148,7 @@ async function startMultiplexedTcpTunnel(
1090
1148
  // Start WebSocket connection and wait for it to be ready
1091
1149
  try {
1092
1150
  await setupWebSocket();
1151
+ established = true;
1093
1152
  logger.debug('WebSocket ready, tunnel fully initialized');
1094
1153
  resolve({
1095
1154
  address,
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.30.0'; // x-release-please-version
1
+ export const VERSION = '0.31.1'; // x-release-please-version
package/tunnel.d.mts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"tunnel.d.mts","sourceRoot":"","sources":["src/tunnel.ts"],"names":[],"mappings":"AAKA;;GAEG;AACH,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC;AAEpE;;GAEG;AACH,MAAM,MAAM,qBAAqB,GAAG,YAAY,GAAG,WAAW,GAAG,cAAc,GAAG,cAAc,CAAC;AAEjG;;GAEG;AACH,MAAM,MAAM,6BAA6B,GAAG,CAAC,KAAK,EAAE,qBAAqB,KAAK,IAAI,CAAC;AAEnF;;;;GAIG;AACH,MAAM,MAAM,UAAU,GAAG,WAAW,GAAG,aAAa,CAAC;AAErD,MAAM,WAAW,MAAM;IACrB,OAAO,EAAE;QACP,OAAO,EAAE,MAAM,CAAC;QAChB,IAAI,EAAE,MAAM,CAAC;KACd,CAAC;IACF,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB;;OAEG;IACH,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;IAChD;;;OAGG;IACH,uBAAuB,EAAE,CAAC,QAAQ,EAAE,6BAA6B,KAAK,MAAM,IAAI,CAAC;CAClF;AAED,MAAM,WAAW,aAAa;IAC5B,aAAa,EAAE;QACb,OAAO,EAAE,MAAM,CAAC;QAChB,IAAI,EAAE,MAAM,CAAC;KACd,CAAC;IACF,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;IAChD,uBAAuB,EAAE,CAAC,QAAQ,EAAE,6BAA6B,KAAK,MAAM,IAAI,CAAC;CAClF;AAED,MAAM,WAAW,gBAAgB;IAC/B;;;OAGG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B;;;OAGG;IACH,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,UAAU,CAAC;CACnB;AAED,MAAM,WAAW,uBAAuB;IACtC;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;OAEG;IACH,SAAS,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,4BAA4B,CAAC,EAAE,MAAM,CAAC;IACtC;;;OAGG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,cAAc,CAClC,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE,gBAAgB,GACzB,OAAO,CAAC,MAAM,CAAC,CAQjB;AAED;;;;;;;GAOG;AACH,wBAAsB,qBAAqB,CACzC,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,uBAAuB,GAC/B,OAAO,CAAC,aAAa,CAAC,CA+XxB;AAymBD,eAAO,MAAM,mBAAmB,GAAI,YAAY,MAAM,KAAG,OAOxD,CAAC;AAEF,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAIrF;AAED;;GAEG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAI7D;AAED;;GAEG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAK7D"}
1
+ {"version":3,"file":"tunnel.d.mts","sourceRoot":"","sources":["src/tunnel.ts"],"names":[],"mappings":"AAKA;;GAEG;AACH,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC;AAEpE;;GAEG;AACH,MAAM,MAAM,qBAAqB,GAAG,YAAY,GAAG,WAAW,GAAG,cAAc,GAAG,cAAc,CAAC;AAEjG;;GAEG;AACH,MAAM,MAAM,6BAA6B,GAAG,CAAC,KAAK,EAAE,qBAAqB,KAAK,IAAI,CAAC;AAEnF;;;;GAIG;AACH,MAAM,MAAM,UAAU,GAAG,WAAW,GAAG,aAAa,CAAC;AAErD,MAAM,WAAW,MAAM;IACrB,OAAO,EAAE;QACP,OAAO,EAAE,MAAM,CAAC;QAChB,IAAI,EAAE,MAAM,CAAC;KACd,CAAC;IACF,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB;;OAEG;IACH,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;IAChD;;;OAGG;IACH,uBAAuB,EAAE,CAAC,QAAQ,EAAE,6BAA6B,KAAK,MAAM,IAAI,CAAC;CAClF;AAED,MAAM,WAAW,aAAa;IAC5B,aAAa,EAAE;QACb,OAAO,EAAE,MAAM,CAAC;QAChB,IAAI,EAAE,MAAM,CAAC;KACd,CAAC;IACF,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;IAChD,uBAAuB,EAAE,CAAC,QAAQ,EAAE,6BAA6B,KAAK,MAAM,IAAI,CAAC;CAClF;AAED,MAAM,WAAW,gBAAgB;IAC/B;;;OAGG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B;;;OAGG;IACH,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,UAAU,CAAC;CACnB;AAED,MAAM,WAAW,uBAAuB;IACtC;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;OAEG;IACH,SAAS,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,4BAA4B,CAAC,EAAE,MAAM,CAAC;IACtC;;;OAGG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,cAAc,CAClC,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE,gBAAgB,GACzB,OAAO,CAAC,MAAM,CAAC,CAQjB;AAED;;;;;;;GAOG;AACH,wBAAsB,qBAAqB,CACzC,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,uBAAuB,GAC/B,OAAO,CAAC,aAAa,CAAC,CA+XxB;AAoqBD,eAAO,MAAM,mBAAmB,GAAI,YAAY,MAAM,KAAG,OAOxD,CAAC;AAEF,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAIrF;AAED;;GAEG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAI7D;AAED;;GAEG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAK7D"}
package/tunnel.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"tunnel.d.ts","sourceRoot":"","sources":["src/tunnel.ts"],"names":[],"mappings":"AAKA;;GAEG;AACH,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC;AAEpE;;GAEG;AACH,MAAM,MAAM,qBAAqB,GAAG,YAAY,GAAG,WAAW,GAAG,cAAc,GAAG,cAAc,CAAC;AAEjG;;GAEG;AACH,MAAM,MAAM,6BAA6B,GAAG,CAAC,KAAK,EAAE,qBAAqB,KAAK,IAAI,CAAC;AAEnF;;;;GAIG;AACH,MAAM,MAAM,UAAU,GAAG,WAAW,GAAG,aAAa,CAAC;AAErD,MAAM,WAAW,MAAM;IACrB,OAAO,EAAE;QACP,OAAO,EAAE,MAAM,CAAC;QAChB,IAAI,EAAE,MAAM,CAAC;KACd,CAAC;IACF,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB;;OAEG;IACH,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;IAChD;;;OAGG;IACH,uBAAuB,EAAE,CAAC,QAAQ,EAAE,6BAA6B,KAAK,MAAM,IAAI,CAAC;CAClF;AAED,MAAM,WAAW,aAAa;IAC5B,aAAa,EAAE;QACb,OAAO,EAAE,MAAM,CAAC;QAChB,IAAI,EAAE,MAAM,CAAC;KACd,CAAC;IACF,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;IAChD,uBAAuB,EAAE,CAAC,QAAQ,EAAE,6BAA6B,KAAK,MAAM,IAAI,CAAC;CAClF;AAED,MAAM,WAAW,gBAAgB;IAC/B;;;OAGG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B;;;OAGG;IACH,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,UAAU,CAAC;CACnB;AAED,MAAM,WAAW,uBAAuB;IACtC;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;OAEG;IACH,SAAS,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,4BAA4B,CAAC,EAAE,MAAM,CAAC;IACtC;;;OAGG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,cAAc,CAClC,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE,gBAAgB,GACzB,OAAO,CAAC,MAAM,CAAC,CAQjB;AAED;;;;;;;GAOG;AACH,wBAAsB,qBAAqB,CACzC,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,uBAAuB,GAC/B,OAAO,CAAC,aAAa,CAAC,CA+XxB;AAymBD,eAAO,MAAM,mBAAmB,GAAI,YAAY,MAAM,KAAG,OAOxD,CAAC;AAEF,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAIrF;AAED;;GAEG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAI7D;AAED;;GAEG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAK7D"}
1
+ {"version":3,"file":"tunnel.d.ts","sourceRoot":"","sources":["src/tunnel.ts"],"names":[],"mappings":"AAKA;;GAEG;AACH,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC;AAEpE;;GAEG;AACH,MAAM,MAAM,qBAAqB,GAAG,YAAY,GAAG,WAAW,GAAG,cAAc,GAAG,cAAc,CAAC;AAEjG;;GAEG;AACH,MAAM,MAAM,6BAA6B,GAAG,CAAC,KAAK,EAAE,qBAAqB,KAAK,IAAI,CAAC;AAEnF;;;;GAIG;AACH,MAAM,MAAM,UAAU,GAAG,WAAW,GAAG,aAAa,CAAC;AAErD,MAAM,WAAW,MAAM;IACrB,OAAO,EAAE;QACP,OAAO,EAAE,MAAM,CAAC;QAChB,IAAI,EAAE,MAAM,CAAC;KACd,CAAC;IACF,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB;;OAEG;IACH,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;IAChD;;;OAGG;IACH,uBAAuB,EAAE,CAAC,QAAQ,EAAE,6BAA6B,KAAK,MAAM,IAAI,CAAC;CAClF;AAED,MAAM,WAAW,aAAa;IAC5B,aAAa,EAAE;QACb,OAAO,EAAE,MAAM,CAAC;QAChB,IAAI,EAAE,MAAM,CAAC;KACd,CAAC;IACF,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;IAChD,uBAAuB,EAAE,CAAC,QAAQ,EAAE,6BAA6B,KAAK,MAAM,IAAI,CAAC;CAClF;AAED,MAAM,WAAW,gBAAgB;IAC/B;;;OAGG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B;;;OAGG;IACH,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,UAAU,CAAC;CACnB;AAED,MAAM,WAAW,uBAAuB;IACtC;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;OAEG;IACH,SAAS,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,4BAA4B,CAAC,EAAE,MAAM,CAAC;IACtC;;;OAGG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,cAAc,CAClC,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE,gBAAgB,GACzB,OAAO,CAAC,MAAM,CAAC,CAQjB;AAED;;;;;;;GAOG;AACH,wBAAsB,qBAAqB,CACzC,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,uBAAuB,GAC/B,OAAO,CAAC,aAAa,CAAC,CA+XxB;AAoqBD,eAAO,MAAM,mBAAmB,GAAI,YAAY,MAAM,KAAG,OAOxD,CAAC;AAEF,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAIrF;AAED;;GAEG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAI7D;AAED;;GAEG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAK7D"}
package/tunnel.js CHANGED
@@ -690,7 +690,17 @@ async function startMultiplexedTcpTunnel(remoteURL, token, hostname, port, optio
690
690
  let ws;
691
691
  let pingInterval;
692
692
  let intentionalDisconnect = false;
693
+ // True once the initial connect has succeeded. Initial-connect failures
694
+ // reject the outer promise (no reconnect); only post-establishment drops
695
+ // reconnect, keeping the local listener open so long builds survive a blip.
696
+ let established = false;
693
697
  let connectionState = 'connecting';
698
+ const maxReconnectAttempts = options?.maxReconnectAttempts ?? 6;
699
+ const reconnectDelay = options?.reconnectDelay ?? 1000;
700
+ const maxReconnectDelay = options?.maxReconnectDelay ?? 30000;
701
+ let reconnectAttempts = 0;
702
+ let reconnectTimeout;
703
+ let lastError;
694
704
  const stateChangeCallbacks = new Set();
695
705
  const updateConnectionState = (newState) => {
696
706
  if (connectionState !== newState) {
@@ -730,6 +740,10 @@ async function startMultiplexedTcpTunnel(remoteURL, token, hostname, port, optio
730
740
  };
731
741
  const close = () => {
732
742
  intentionalDisconnect = true;
743
+ if (reconnectTimeout) {
744
+ clearTimeout(reconnectTimeout);
745
+ reconnectTimeout = undefined;
746
+ }
733
747
  cleanupWebSocket();
734
748
  closeAllConnections();
735
749
  updateConnectionState('disconnected');
@@ -737,6 +751,36 @@ async function startMultiplexedTcpTunnel(remoteURL, token, hostname, port, optio
737
751
  server.close();
738
752
  }
739
753
  };
754
+ // Single reconnect scheduler. Only ws.on('close') drives it (after the
755
+ // tunnel is established), so there is no double-scheduling race; the
756
+ // `reconnectTimeout` guard is belt-and-suspenders. Mirrors the singleton
757
+ // tunnel's backoff, but tears down per-connection streams on each drop
758
+ // since a multiplexed connId cannot be resumed across a WS reconnect.
759
+ const scheduleReconnect = () => {
760
+ if (intentionalDisconnect || reconnectTimeout) {
761
+ return;
762
+ }
763
+ if ((0, exports.isNonRetryableError)(lastError ?? '')) {
764
+ logger.error(`Closing multiplexed tunnel (non-retryable error): ${lastError}`);
765
+ close();
766
+ return;
767
+ }
768
+ if (reconnectAttempts >= maxReconnectAttempts) {
769
+ logger.error(`Max reconnection attempts (${maxReconnectAttempts}) reached. Closing tunnel.`);
770
+ close();
771
+ return;
772
+ }
773
+ const currentDelay = Math.min(reconnectDelay * Math.pow(2, reconnectAttempts), maxReconnectDelay);
774
+ reconnectAttempts++;
775
+ updateConnectionState('reconnecting');
776
+ logger.debug(`Scheduling reconnection attempt ${reconnectAttempts} in ${currentDelay}ms...`);
777
+ reconnectTimeout = setTimeout(() => {
778
+ reconnectTimeout = undefined;
779
+ // On failure the new ws's own 'close' handler schedules the next
780
+ // attempt, so here we only swallow the rejection.
781
+ setupWebSocket().catch(() => { });
782
+ }, currentDelay);
783
+ };
740
784
  // Send close signal for a connection (header-only packet)
741
785
  const sendCloseSignal = (connId) => {
742
786
  if (ws && ws.readyState === ws_1.WebSocket.OPEN) {
@@ -772,6 +816,7 @@ async function startMultiplexedTcpTunnel(remoteURL, token, hostname, port, optio
772
816
  });
773
817
  ws.on('error', (err) => {
774
818
  const errMessage = err.message || String(err);
819
+ lastError = errMessage;
775
820
  logger.error('WebSocket error:', errMessage, err.code || '');
776
821
  rejectWs(err);
777
822
  });
@@ -780,14 +825,25 @@ async function startMultiplexedTcpTunnel(remoteURL, token, hostname, port, optio
780
825
  clearInterval(pingInterval);
781
826
  pingInterval = undefined;
782
827
  }
783
- updateConnectionState('disconnected');
784
828
  logger.debug(`WebSocket disconnected (code=${code}, reason=${reason.toString()})`);
785
- // Close all TCP connections when WebSocket closes
829
+ // Per-connection streams cannot survive a WS drop (their remote peers
830
+ // are gone), so tear them down; bazel's own gRPC retries re-establish
831
+ // channels once the WS is back.
786
832
  closeAllConnections();
833
+ // Before establishment, the listening handler's catch rejects + closes
834
+ // (no reconnect). After establishment, a non-intentional drop reconnects
835
+ // (keeping the listener open) instead of killing the tunnel.
836
+ if (!established || intentionalDisconnect) {
837
+ updateConnectionState('disconnected');
838
+ return;
839
+ }
840
+ scheduleReconnect();
787
841
  });
788
842
  ws.on('open', () => {
789
843
  const socket = ws;
790
844
  logger.debug('WebSocket connected');
845
+ reconnectAttempts = 0;
846
+ lastError = undefined;
791
847
  updateConnectionState('connected');
792
848
  pingInterval = setInterval(() => {
793
849
  if (socket.readyState === ws_1.WebSocket.OPEN) {
@@ -852,6 +908,7 @@ async function startMultiplexedTcpTunnel(remoteURL, token, hostname, port, optio
852
908
  // Start WebSocket connection and wait for it to be ready
853
909
  try {
854
910
  await setupWebSocket();
911
+ established = true;
855
912
  logger.debug('WebSocket ready, tunnel fully initialized');
856
913
  resolve({
857
914
  address,