@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.
- package/CHANGELOG.md +26 -1
- package/index.d.mts +2 -1
- package/index.d.mts.map +1 -1
- package/index.d.ts +2 -1
- package/index.d.ts.map +1 -1
- package/index.js +7 -1
- package/index.js.map +1 -1
- package/index.mjs +2 -0
- package/index.mjs.map +1 -1
- package/package.json +11 -1
- package/rbe-bep.d.mts +27 -0
- package/rbe-bep.d.mts.map +1 -0
- package/rbe-bep.d.ts +27 -0
- package/rbe-bep.d.ts.map +1 -0
- package/rbe-bep.js +130 -0
- package/rbe-bep.js.map +1 -0
- package/rbe-bep.mjs +127 -0
- package/rbe-bep.mjs.map +1 -0
- package/resources/index.d.mts +1 -1
- package/resources/index.d.mts.map +1 -1
- package/resources/index.d.ts +1 -1
- package/resources/index.d.ts.map +1 -1
- package/resources/index.js +3 -1
- package/resources/index.js.map +1 -1
- package/resources/index.mjs +1 -1
- package/resources/index.mjs.map +1 -1
- package/resources/xcode-instances-helpers.d.mts +110 -1
- package/resources/xcode-instances-helpers.d.mts.map +1 -1
- package/resources/xcode-instances-helpers.d.ts +110 -1
- package/resources/xcode-instances-helpers.d.ts.map +1 -1
- package/resources/xcode-instances-helpers.js +153 -18
- package/resources/xcode-instances-helpers.js.map +1 -1
- package/resources/xcode-instances-helpers.mjs +150 -17
- package/resources/xcode-instances-helpers.mjs.map +1 -1
- package/src/index.ts +9 -0
- package/src/rbe-bep.ts +155 -0
- package/src/resources/index.ts +3 -0
- package/src/resources/xcode-instances-helpers.ts +284 -19
- package/src/tunnel.ts +62 -3
- package/src/version.ts +1 -1
- package/tunnel.d.mts.map +1 -1
- package/tunnel.d.ts.map +1 -1
- package/tunnel.js +59 -2
- package/tunnel.js.map +1 -1
- package/tunnel.mjs +59 -2
- package/tunnel.mjs.map +1 -1
- package/version.d.mts +1 -1
- package/version.d.ts +1 -1
- package/version.js +1 -1
- 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,
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
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(
|
|
629
|
+
body: JSON.stringify(opts ?? {}),
|
|
401
630
|
});
|
|
402
|
-
return
|
|
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
|
-
//
|
|
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.
|
|
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;
|
|
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;
|
|
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
|
-
//
|
|
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,
|