Skip to content

Commit eb2c636

Browse files
dorlugasigalCopilot
andcommitted
fix(tunnel): add network-wait mode for transient DNS outages
The watchdog used to burn all 10 restart attempts during any network outage longer than ~6 minutes and then permanently give up, leaving the tunnel dead even after connectivity returned (common overnight: Wi-Fi sleep, DHCP renewal, ISP DNS blip, EAI_NONAME on macOS). Now DNS / connectivity errors route to a new network-wait state that mirrors the existing auth-wait pattern: probes the DevTunnel host via DNS every 60s and resumes automatically when reachable. Network errors no longer consume restart attempts, and the 10-attempt ceiling transitions into network-wait instead of a terminal giveup. - src/tunnel/index.js: NETWORK_ERROR_PATTERNS, isNetworkError, isNetworkReachable, startNetworkWait/stopNetworkWait; new 'network-lost' / 'network-restored' events - test/tunnel/watchdog.test.js: unit tests for classifiers + events - docs/architecture.md, docs/troubleshooting.md: describe new behavior Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent b4fe5bf commit eb2c636

5 files changed

Lines changed: 206 additions & 7 deletions

File tree

docs/architecture.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,10 +117,11 @@ Manages Azure DevTunnel lifecycle: login, create, host, cleanup. Includes a **wa
117117
- **Health check** — every 30 seconds, runs `devtunnel show` and parses the host connection count.
118118
- **Zombie detection** — if host connections drop to 0 for two consecutive checks (60s grace), the stale process is killed and a restart is initiated.
119119
- **Crash detection** — an `exit` handler on the child process triggers immediate restart if the process dies.
120-
- **Auto-restart** — exponential backoff (1s → 2s → 5s → 10s → 15s → 30s), up to 10 attempts before giving up.
120+
- **Auto-restart** — exponential backoff (1s → 2s → 5s → 10s → 15s → 30s), up to 10 attempts before transitioning to network-wait.
121121
- **Auth-wait system** — detects auth token expiry (Microsoft limitation), enters an auth-wait mode, polls for re-authentication via device code flow, and auto-reconnects once a fresh token is obtained.
122+
- **Network-wait system** — detects DNS / connectivity errors (e.g. `ENOTFOUND`, `EAI_AGAIN`, `nodename nor servname`), enters a network-wait mode, probes the DevTunnel host via DNS every 60 seconds, and auto-reconnects once the network is reachable again. Network errors do not consume restart attempts, so transient outages (Wi-Fi sleep, DHCP renewal, ISP DNS blips) no longer cause a permanent giveup.
122123
- **Token lifetime monitoring** — tracks the remaining lifetime of the DevTunnel auth token and emits warnings when less than 1 hour remains, giving the frontend time to prompt the user.
123-
- **Event emitter** — exports `tunnelEvents` (EventEmitter) with events: `connected`, `disconnected`, `reconnecting`, `failed`. The server subscribes for logging.
124+
- **Event emitter** — exports `tunnelEvents` (EventEmitter) with events: `connected`, `disconnected`, `reconnecting`, `network-lost`, `network-restored`, `failed`. The server subscribes for logging.
124125

125126
Also exports `getLoginInfo()` (returns current auth provider and token expiry) and `parseLoginInfo()` (parses raw `devtunnel` CLI output into structured login metadata).
126127

docs/troubleshooting.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,12 @@ devtunnel user login
7979
!!! warning
8080
If a persisted tunnel stops working after ~30 days, delete `~/.termbeam/tunnel.json` and restart TermBeam to create a fresh tunnel.
8181

82+
### Tunnel died overnight / after sleep
83+
84+
If TermBeam is running as a long-lived service (`termbeam service`) and the tunnel stops working after a network interruption (laptop sleep, Wi-Fi drop, DHCP renewal, ISP DNS blip, etc.), the watchdog automatically enters a **network-wait** state once transient DNS / connectivity errors are detected. It probes the DevTunnel host every 60 seconds and reconnects as soon as the network is reachable again — no manual restart required.
85+
86+
You'll see `[WARN] Tunnel paused — waiting for network connectivity` in the logs followed by `[INFO] Network connectivity restored — resuming tunnel` when it recovers. If the logs instead show repeated `Tunnel restart returned no URL` with no final giveup, the watchdog is still cycling through its 10 restart attempts; wait a few minutes for it to settle into network-wait.
87+
8288
---
8389

8490
## Authentication Issues

package-lock.json

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/tunnel/index.js

Lines changed: 130 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ const { execSync, execFileSync, execFile, spawn } = require('child_process');
22
const path = require('path');
33
const fs = require('fs');
44
const os = require('os');
5+
const dns = require('dns');
56
const EventEmitter = require('events');
67
const log = require('../utils/logger');
78
const { promptInstall } = require('./install');
@@ -26,15 +27,38 @@ let waitingForAuth = false;
2627
let authCheckInterval = null;
2728
let expiryWarned = false;
2829

30+
// --- Network-wait state ---
31+
let waitingForNetwork = false;
32+
let networkWaitInterval = null;
33+
2934
const HEALTH_CHECK_INTERVAL = 30_000; // 30s between checks
3035
const HEALTH_CHECK_GRACE = 2; // 2 consecutive failures before restart
3136
const MAX_RESTART_ATTEMPTS = 10;
3237
const BACKOFF_DELAYS = [1000, 2000, 5000, 10_000, 15_000, 30_000]; // then stays at 30s
3338
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;
3442
const TOKEN_EXPIRY_WARN_SECONDS = 3600; // warn at 1 hour remaining
3543

3644
const AUTH_ERROR_PATTERNS = ['login required', 'not logged in', 'sign in required'];
3745

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+
3862
const SAFE_ID_RE = /^[a-zA-Z0-9._-]+$/;
3963

4064
const DEVICE_CODE_INITIAL_TIMEOUT = 15000;
@@ -45,6 +69,37 @@ function isAuthError(message) {
4569
return AUTH_ERROR_PATTERNS.some((p) => lower.includes(p));
4670
}
4771

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+
48103
function isLoggedIn() {
49104
try {
50105
const out = execFileSync(devtunnelCmd, ['user', 'show'], {
@@ -210,7 +265,7 @@ let isPersisted = false;
210265
// --- Watchdog: health check & auto-restart ---
211266

212267
function checkTunnelHealth() {
213-
if (!tunnelId || !tunnelProc || isRestarting || waitingForAuth) return;
268+
if (!tunnelId || !tunnelProc || isRestarting || waitingForAuth || waitingForNetwork) return;
214269

215270
const abortCtrl = new AbortController();
216271
const timer = setTimeout(() => abortCtrl.abort(), 10_000);
@@ -229,6 +284,17 @@ function checkTunnelHealth() {
229284
return;
230285
}
231286

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+
232298
// "Tunnel not found" can mean the user's auth expired (CLI can't
233299
// query the tunnel without valid credentials). Check login status
234300
// to distinguish from a genuinely deleted tunnel.
@@ -389,13 +455,58 @@ function stopAuthWait() {
389455
}
390456
}
391457

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+
392500
function scheduleRestart() {
501+
if (waitingForNetwork || waitingForAuth) return;
502+
393503
if (restartAttempts >= MAX_RESTART_ATTEMPTS) {
394-
log.error(
395-
`Tunnel restart failed after ${MAX_RESTART_ATTEMPTS} attempts — giving up. Tunnel URL is unreachable.`,
504+
log.warn(
505+
`Tunnel restart failed after ${MAX_RESTART_ATTEMPTS} attempts — entering network-wait mode.`,
396506
);
397507
tunnelEvents.emit('failed', { attempts: restartAttempts });
398508
isRestarting = false;
509+
startNetworkWait();
399510
return;
400511
}
401512

@@ -428,11 +539,20 @@ function scheduleRestart() {
428539
} else {
429540
log.warn('Tunnel restart returned no URL');
430541
isRestarting = false;
542+
// If the host appears to be offline, stop burning attempts.
543+
if (!(await isNetworkReachable())) {
544+
startNetworkWait();
545+
return;
546+
}
431547
scheduleRestart();
432548
}
433549
} catch (err) {
434550
log.error(`Tunnel restart error: ${err.message}`);
435551
isRestarting = false;
552+
if (isNetworkError(err.message) || !(await isNetworkReachable())) {
553+
startNetworkWait();
554+
return;
555+
}
436556
scheduleRestart();
437557
}
438558
}, delay);
@@ -635,9 +755,10 @@ async function startTunnel(port, options = {}) {
635755
}
636756

637757
function cleanupTunnel() {
638-
// Stop watchdog and auth-wait to prevent restart during cleanup
758+
// Stop watchdog, auth-wait, and network-wait to prevent restart during cleanup
639759
stopHealthCheck();
640760
stopAuthWait();
761+
stopNetworkWait();
641762
isRestarting = true; // prevent exit handler from restarting
642763
if (restartTimer) {
643764
clearTimeout(restartTimer);
@@ -674,4 +795,9 @@ module.exports = {
674795
tunnelEvents,
675796
getLoginInfo,
676797
parseLoginInfo,
798+
// Exported for tests
799+
_internal: {
800+
isNetworkError,
801+
isAuthError,
802+
},
677803
};

test/tunnel/watchdog.test.js

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
const { describe, it } = require('node:test');
2+
const assert = require('node:assert/strict');
3+
4+
const tunnel = require('../../src/tunnel');
5+
const { isNetworkError, isAuthError } = tunnel._internal;
6+
7+
describe('tunnel watchdog classification', () => {
8+
it('isNetworkError detects common DNS/connectivity failures', () => {
9+
const cases = [
10+
'nodename nor servname provided, or not known',
11+
'getaddrinfo ENOTFOUND uks1.rel.tunnels.api.visualstudio.com',
12+
'connect ECONNREFUSED 20.0.0.1:443',
13+
'connect ETIMEDOUT',
14+
'Network is unreachable',
15+
'Temporary failure in name resolution',
16+
'EAI_AGAIN some-host',
17+
];
18+
for (const msg of cases) {
19+
assert.equal(isNetworkError(msg), true, `expected network error: ${msg}`);
20+
}
21+
});
22+
23+
it('isNetworkError returns false for non-network messages', () => {
24+
assert.equal(isNetworkError('login required'), false);
25+
assert.equal(isNetworkError('tunnel not found'), false);
26+
assert.equal(isNetworkError(''), false);
27+
assert.equal(isNetworkError(null), false);
28+
assert.equal(isNetworkError(undefined), false);
29+
});
30+
31+
it('isAuthError detects auth-related messages', () => {
32+
assert.equal(isAuthError('Login required'), true);
33+
assert.equal(isAuthError('not logged in'), true);
34+
assert.equal(isAuthError('Sign in required'), true);
35+
});
36+
37+
it('isAuthError ignores network errors', () => {
38+
assert.equal(isAuthError('getaddrinfo ENOTFOUND'), false);
39+
assert.equal(isAuthError('ECONNREFUSED'), false);
40+
});
41+
});
42+
43+
describe('tunnel watchdog public surface', () => {
44+
it('exposes tunnelEvents emitter', () => {
45+
assert.ok(tunnel.tunnelEvents);
46+
assert.equal(typeof tunnel.tunnelEvents.on, 'function');
47+
assert.equal(typeof tunnel.tunnelEvents.emit, 'function');
48+
});
49+
50+
it('network-lost/network-restored events are emitable', () => {
51+
let lost = 0;
52+
let restored = 0;
53+
const onLost = () => lost++;
54+
const onRestored = () => restored++;
55+
tunnel.tunnelEvents.on('network-lost', onLost);
56+
tunnel.tunnelEvents.on('network-restored', onRestored);
57+
try {
58+
tunnel.tunnelEvents.emit('network-lost');
59+
tunnel.tunnelEvents.emit('network-restored');
60+
assert.equal(lost, 1);
61+
assert.equal(restored, 1);
62+
} finally {
63+
tunnel.tunnelEvents.off('network-lost', onLost);
64+
tunnel.tunnelEvents.off('network-restored', onRestored);
65+
}
66+
});
67+
});

0 commit comments

Comments
 (0)