termbeam 1.20.3 → 1.20.4
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/package.json +1 -1
- package/src/tunnel/index.js +130 -4
package/package.json
CHANGED
package/src/tunnel/index.js
CHANGED
|
@@ -2,6 +2,7 @@ const { execSync, execFileSync, execFile, spawn } = require('child_process');
|
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const os = require('os');
|
|
5
|
+
const dns = require('dns');
|
|
5
6
|
const EventEmitter = require('events');
|
|
6
7
|
const log = require('../utils/logger');
|
|
7
8
|
const { promptInstall } = require('./install');
|
|
@@ -26,15 +27,38 @@ let waitingForAuth = false;
|
|
|
26
27
|
let authCheckInterval = null;
|
|
27
28
|
let expiryWarned = false;
|
|
28
29
|
|
|
30
|
+
// --- Network-wait state ---
|
|
31
|
+
let waitingForNetwork = false;
|
|
32
|
+
let networkWaitInterval = null;
|
|
33
|
+
|
|
29
34
|
const HEALTH_CHECK_INTERVAL = 30_000; // 30s between checks
|
|
30
35
|
const HEALTH_CHECK_GRACE = 2; // 2 consecutive failures before restart
|
|
31
36
|
const MAX_RESTART_ATTEMPTS = 10;
|
|
32
37
|
const BACKOFF_DELAYS = [1000, 2000, 5000, 10_000, 15_000, 30_000]; // then stays at 30s
|
|
33
38
|
const AUTH_CHECK_INTERVAL = 30_000; // 30s between auth re-checks
|
|
39
|
+
const NETWORK_CHECK_INTERVAL = 60_000; // 60s between network reachability probes
|
|
40
|
+
const NETWORK_PROBE_HOST = 'global.rel.tunnels.api.visualstudio.com';
|
|
41
|
+
const NETWORK_PROBE_TIMEOUT = 5_000;
|
|
34
42
|
const TOKEN_EXPIRY_WARN_SECONDS = 3600; // warn at 1 hour remaining
|
|
35
43
|
|
|
36
44
|
const AUTH_ERROR_PATTERNS = ['login required', 'not logged in', 'sign in required'];
|
|
37
45
|
|
|
46
|
+
// DNS / transient network failures that should NOT burn restart attempts.
|
|
47
|
+
// These typically resolve on their own once the host regains connectivity
|
|
48
|
+
// (e.g. Wi-Fi sleep/wake, router reboot, upstream DNS hiccup).
|
|
49
|
+
const NETWORK_ERROR_PATTERNS = [
|
|
50
|
+
'nodename nor servname',
|
|
51
|
+
'getaddrinfo',
|
|
52
|
+
'enotfound',
|
|
53
|
+
'eai_again',
|
|
54
|
+
'econnrefused',
|
|
55
|
+
'econnreset',
|
|
56
|
+
'etimedout',
|
|
57
|
+
'network is unreachable',
|
|
58
|
+
'no such host',
|
|
59
|
+
'temporary failure in name resolution',
|
|
60
|
+
];
|
|
61
|
+
|
|
38
62
|
const SAFE_ID_RE = /^[a-zA-Z0-9._-]+$/;
|
|
39
63
|
|
|
40
64
|
const DEVICE_CODE_INITIAL_TIMEOUT = 15000;
|
|
@@ -45,6 +69,37 @@ function isAuthError(message) {
|
|
|
45
69
|
return AUTH_ERROR_PATTERNS.some((p) => lower.includes(p));
|
|
46
70
|
}
|
|
47
71
|
|
|
72
|
+
function isNetworkError(message) {
|
|
73
|
+
const lower = (message || '').toLowerCase();
|
|
74
|
+
return NETWORK_ERROR_PATTERNS.some((p) => lower.includes(p));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isNetworkReachable() {
|
|
78
|
+
return new Promise((resolve) => {
|
|
79
|
+
let settled = false;
|
|
80
|
+
const timer = setTimeout(() => {
|
|
81
|
+
if (!settled) {
|
|
82
|
+
settled = true;
|
|
83
|
+
resolve(false);
|
|
84
|
+
}
|
|
85
|
+
}, NETWORK_PROBE_TIMEOUT);
|
|
86
|
+
try {
|
|
87
|
+
dns.lookup(NETWORK_PROBE_HOST, (err) => {
|
|
88
|
+
if (settled) return;
|
|
89
|
+
settled = true;
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
resolve(!err);
|
|
92
|
+
});
|
|
93
|
+
} catch {
|
|
94
|
+
if (!settled) {
|
|
95
|
+
settled = true;
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
resolve(false);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
48
103
|
function isLoggedIn() {
|
|
49
104
|
try {
|
|
50
105
|
const out = execFileSync(devtunnelCmd, ['user', 'show'], {
|
|
@@ -210,7 +265,7 @@ let isPersisted = false;
|
|
|
210
265
|
// --- Watchdog: health check & auto-restart ---
|
|
211
266
|
|
|
212
267
|
function checkTunnelHealth() {
|
|
213
|
-
if (!tunnelId || !tunnelProc || isRestarting || waitingForAuth) return;
|
|
268
|
+
if (!tunnelId || !tunnelProc || isRestarting || waitingForAuth || waitingForNetwork) return;
|
|
214
269
|
|
|
215
270
|
const abortCtrl = new AbortController();
|
|
216
271
|
const timer = setTimeout(() => abortCtrl.abort(), 10_000);
|
|
@@ -229,6 +284,17 @@ function checkTunnelHealth() {
|
|
|
229
284
|
return;
|
|
230
285
|
}
|
|
231
286
|
|
|
287
|
+
// Transient network errors (DNS, connection refused): the host has
|
|
288
|
+
// lost connectivity. Don't burn restart attempts — wait for network.
|
|
289
|
+
if (isNetworkError(err.message) || isNetworkError(err.stderr)) {
|
|
290
|
+
log.warn(`Tunnel health check: network unreachable — pausing until connectivity returns`);
|
|
291
|
+
stopHealthCheck();
|
|
292
|
+
killTunnelProc();
|
|
293
|
+
tunnelEvents.emit('disconnected');
|
|
294
|
+
startNetworkWait();
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
232
298
|
// "Tunnel not found" can mean the user's auth expired (CLI can't
|
|
233
299
|
// query the tunnel without valid credentials). Check login status
|
|
234
300
|
// to distinguish from a genuinely deleted tunnel.
|
|
@@ -389,13 +455,58 @@ function stopAuthWait() {
|
|
|
389
455
|
}
|
|
390
456
|
}
|
|
391
457
|
|
|
458
|
+
function startNetworkWait() {
|
|
459
|
+
if (waitingForNetwork) return;
|
|
460
|
+
waitingForNetwork = true;
|
|
461
|
+
isRestarting = false;
|
|
462
|
+
restartAttempts = 0;
|
|
463
|
+
consecutiveFailures = 0;
|
|
464
|
+
|
|
465
|
+
log.warn('Tunnel paused — waiting for network connectivity.');
|
|
466
|
+
log.warn('Will auto-resume when the tunnel service is reachable.');
|
|
467
|
+
tunnelEvents.emit('network-lost');
|
|
468
|
+
|
|
469
|
+
const probe = async () => {
|
|
470
|
+
if (!waitingForNetwork) return;
|
|
471
|
+
// If auth expired while we were offline, switch to auth-wait instead.
|
|
472
|
+
if (!isLoggedIn()) {
|
|
473
|
+
log.warn('DevTunnel auth expired during network outage — waiting for re-authentication');
|
|
474
|
+
stopNetworkWait();
|
|
475
|
+
handleAuthExpiration();
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
if (await isNetworkReachable()) {
|
|
479
|
+
log.info('Network connectivity restored — resuming tunnel');
|
|
480
|
+
stopNetworkWait();
|
|
481
|
+
tunnelEvents.emit('network-restored');
|
|
482
|
+
scheduleRestart();
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
networkWaitInterval = setInterval(probe, NETWORK_CHECK_INTERVAL);
|
|
487
|
+
networkWaitInterval.unref();
|
|
488
|
+
// Also probe once immediately in case the outage already cleared.
|
|
489
|
+
probe();
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function stopNetworkWait() {
|
|
493
|
+
waitingForNetwork = false;
|
|
494
|
+
if (networkWaitInterval) {
|
|
495
|
+
clearInterval(networkWaitInterval);
|
|
496
|
+
networkWaitInterval = null;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
392
500
|
function scheduleRestart() {
|
|
501
|
+
if (waitingForNetwork || waitingForAuth) return;
|
|
502
|
+
|
|
393
503
|
if (restartAttempts >= MAX_RESTART_ATTEMPTS) {
|
|
394
|
-
log.
|
|
395
|
-
`Tunnel restart failed after ${MAX_RESTART_ATTEMPTS} attempts —
|
|
504
|
+
log.warn(
|
|
505
|
+
`Tunnel restart failed after ${MAX_RESTART_ATTEMPTS} attempts — entering network-wait mode.`,
|
|
396
506
|
);
|
|
397
507
|
tunnelEvents.emit('failed', { attempts: restartAttempts });
|
|
398
508
|
isRestarting = false;
|
|
509
|
+
startNetworkWait();
|
|
399
510
|
return;
|
|
400
511
|
}
|
|
401
512
|
|
|
@@ -428,11 +539,20 @@ function scheduleRestart() {
|
|
|
428
539
|
} else {
|
|
429
540
|
log.warn('Tunnel restart returned no URL');
|
|
430
541
|
isRestarting = false;
|
|
542
|
+
// If the host appears to be offline, stop burning attempts.
|
|
543
|
+
if (!(await isNetworkReachable())) {
|
|
544
|
+
startNetworkWait();
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
431
547
|
scheduleRestart();
|
|
432
548
|
}
|
|
433
549
|
} catch (err) {
|
|
434
550
|
log.error(`Tunnel restart error: ${err.message}`);
|
|
435
551
|
isRestarting = false;
|
|
552
|
+
if (isNetworkError(err.message) || !(await isNetworkReachable())) {
|
|
553
|
+
startNetworkWait();
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
436
556
|
scheduleRestart();
|
|
437
557
|
}
|
|
438
558
|
}, delay);
|
|
@@ -635,9 +755,10 @@ async function startTunnel(port, options = {}) {
|
|
|
635
755
|
}
|
|
636
756
|
|
|
637
757
|
function cleanupTunnel() {
|
|
638
|
-
// Stop watchdog and
|
|
758
|
+
// Stop watchdog, auth-wait, and network-wait to prevent restart during cleanup
|
|
639
759
|
stopHealthCheck();
|
|
640
760
|
stopAuthWait();
|
|
761
|
+
stopNetworkWait();
|
|
641
762
|
isRestarting = true; // prevent exit handler from restarting
|
|
642
763
|
if (restartTimer) {
|
|
643
764
|
clearTimeout(restartTimer);
|
|
@@ -674,4 +795,9 @@ module.exports = {
|
|
|
674
795
|
tunnelEvents,
|
|
675
796
|
getLoginInfo,
|
|
676
797
|
parseLoginInfo,
|
|
798
|
+
// Exported for tests
|
|
799
|
+
_internal: {
|
|
800
|
+
isNetworkError,
|
|
801
|
+
isAuthError,
|
|
802
|
+
},
|
|
677
803
|
};
|