groove-dev 0.27.212 → 0.27.214
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/daemon-bridge.js +87 -0
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/tunnel-manager.js +237 -40
- package/node_modules/@groove-dev/daemon/test/tunnel-manager.test.js +178 -10
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/tunnel-manager.js +237 -40
- package/packages/gui/package.json +1 -1
package/daemon-bridge.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
2
|
+
import { existsSync, readdirSync } from 'fs';
|
|
3
|
+
import { createRequire } from 'module';
|
|
4
|
+
import { dirname, join } from 'path';
|
|
5
|
+
import { pathToFileURL } from 'url';
|
|
6
|
+
|
|
7
|
+
const port = 31415;
|
|
8
|
+
const projectDir = process.argv[2] || process.cwd();
|
|
9
|
+
|
|
10
|
+
function preflightCheck(daemonPath) {
|
|
11
|
+
if (!existsSync(daemonPath)) {
|
|
12
|
+
throw new Error(
|
|
13
|
+
`Daemon entry point not found at ${daemonPath}. ` +
|
|
14
|
+
'The app may not have been packaged correctly — try reinstalling Groove.'
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const daemonDir = dirname(daemonPath);
|
|
19
|
+
const require = createRequire(daemonPath);
|
|
20
|
+
const critical = ['express', 'ws'];
|
|
21
|
+
const missing = critical.filter(dep => {
|
|
22
|
+
try { require.resolve(dep); return false; } catch { return true; }
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
if (missing.length) {
|
|
26
|
+
const diag = [`Missing deps: ${missing.join(', ')}`, `GROOVE_DAEMON_PATH: ${daemonPath}`];
|
|
27
|
+
let walkDir = daemonDir;
|
|
28
|
+
for (let i = 0; i < 5 && walkDir !== dirname(walkDir); i++) {
|
|
29
|
+
try {
|
|
30
|
+
const entries = readdirSync(walkDir);
|
|
31
|
+
diag.push(`${walkDir}/: [${entries.join(', ')}]`);
|
|
32
|
+
} catch { diag.push(`${walkDir}/: (unreadable)`); }
|
|
33
|
+
walkDir = dirname(walkDir);
|
|
34
|
+
}
|
|
35
|
+
throw new Error(
|
|
36
|
+
`Daemon is missing dependencies. ` +
|
|
37
|
+
`Diagnostics:\n${diag.join('\n')}\n` +
|
|
38
|
+
'The app bundle may be incomplete — try reinstalling Groove.'
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function main() {
|
|
44
|
+
let Daemon;
|
|
45
|
+
const daemonPath = process.env.GROOVE_DAEMON_PATH;
|
|
46
|
+
|
|
47
|
+
if (daemonPath) {
|
|
48
|
+
preflightCheck(daemonPath);
|
|
49
|
+
const mod = await import(pathToFileURL(daemonPath).href);
|
|
50
|
+
Daemon = mod.Daemon;
|
|
51
|
+
} else {
|
|
52
|
+
const mod = await import('@groove-dev/daemon');
|
|
53
|
+
Daemon = mod.Daemon;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const daemon = new Daemon({ port, projectDir });
|
|
57
|
+
await daemon.start();
|
|
58
|
+
|
|
59
|
+
process.send({ type: 'ready', port: daemon.port });
|
|
60
|
+
|
|
61
|
+
process.on('message', (msg) => {
|
|
62
|
+
if (msg.type === 'auth-token') {
|
|
63
|
+
(async () => {
|
|
64
|
+
try { await daemon.setAuthToken(msg.token); } catch (err) {
|
|
65
|
+
process.stderr.write(`[daemon-bridge] setAuthToken failed: ${err.message}\n`);
|
|
66
|
+
}
|
|
67
|
+
})();
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
process.on('SIGTERM', async () => {
|
|
72
|
+
await daemon.stop();
|
|
73
|
+
process.exit(0);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
process.on('SIGINT', async () => {
|
|
77
|
+
await daemon.stop();
|
|
78
|
+
process.exit(0);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
main().catch((err) => {
|
|
83
|
+
if (process.send) {
|
|
84
|
+
process.send({ type: 'error', message: err.message });
|
|
85
|
+
}
|
|
86
|
+
process.exit(1);
|
|
87
|
+
});
|
|
@@ -20,6 +20,16 @@ const MAX_PORT_ATTEMPTS = 10;
|
|
|
20
20
|
const HEALTH_INTERVAL = 30000;
|
|
21
21
|
const HEALTH_TIMEOUT = 5000;
|
|
22
22
|
const MAX_FAIL_COUNT = 3;
|
|
23
|
+
// Long-timeout probe used to CONFIRM death before killing a tunnel — a busy
|
|
24
|
+
// remote daemon can sit on /api/health well past the 5s routine probe.
|
|
25
|
+
const CONFIRM_TIMEOUT = 15000;
|
|
26
|
+
// At most one automatic rebuild per tunnel per window; beyond that it stays
|
|
27
|
+
// disconnected rather than thrashing against a host that keeps dying.
|
|
28
|
+
const REBUILD_COOLDOWN_MS = 10 * 60 * 1000;
|
|
29
|
+
// Restarting a crashed REMOTE daemon is cheaper and safer than a rebuild, so
|
|
30
|
+
// its cooldown is shorter — but still bounded: a daemon that dies right after
|
|
31
|
+
// every start has a real problem more starts won't fix.
|
|
32
|
+
const REMOTE_START_COOLDOWN_MS = 2 * 60 * 1000;
|
|
23
33
|
|
|
24
34
|
const INJECTION_CHARS = /[;|&`$(){}[\]<>!#\n\r\\]/;
|
|
25
35
|
|
|
@@ -54,6 +64,10 @@ export class TunnelManager {
|
|
|
54
64
|
constructor(daemon) {
|
|
55
65
|
this.daemon = daemon;
|
|
56
66
|
this.remotesPath = resolve(daemon.grooveDir, 'remotes.json');
|
|
67
|
+
// Live tunnel state, persisted separately from the configs so a daemon
|
|
68
|
+
// restart can re-adopt still-running ssh processes instead of forgetting
|
|
69
|
+
// them (the configs file is user data; this is runtime state).
|
|
70
|
+
this.activePath = resolve(daemon.grooveDir, 'tunnels-active.json');
|
|
57
71
|
this.saved = new Map();
|
|
58
72
|
this.active = new Map();
|
|
59
73
|
this._healthInterval = null;
|
|
@@ -82,8 +96,9 @@ export class TunnelManager {
|
|
|
82
96
|
}
|
|
83
97
|
|
|
84
98
|
async init() {
|
|
99
|
+
await this._readopt();
|
|
85
100
|
for (const [id, config] of this.saved) {
|
|
86
|
-
if (config.autoConnect) {
|
|
101
|
+
if (config.autoConnect && !this.active.has(id)) {
|
|
87
102
|
try {
|
|
88
103
|
await this.connect(id);
|
|
89
104
|
} catch (err) {
|
|
@@ -93,6 +108,71 @@ export class TunnelManager {
|
|
|
93
108
|
}
|
|
94
109
|
}
|
|
95
110
|
|
|
111
|
+
// Re-adopt tunnels whose detached ssh processes survived a daemon restart.
|
|
112
|
+
// Without this, every daemon/app restart orphaned the ssh (or shutdown killed
|
|
113
|
+
// it) and the new daemon started amnesiac — remote windows died mid-session
|
|
114
|
+
// and the user had to reconnect everything by hand.
|
|
115
|
+
async _readopt() {
|
|
116
|
+
let entries = [];
|
|
117
|
+
try {
|
|
118
|
+
if (existsSync(this.activePath)) entries = JSON.parse(readFileSync(this.activePath, 'utf8'));
|
|
119
|
+
} catch { /* corrupt — treat as none */ }
|
|
120
|
+
if (!Array.isArray(entries) || entries.length === 0) return;
|
|
121
|
+
|
|
122
|
+
let adopted = 0;
|
|
123
|
+
for (const e of entries) {
|
|
124
|
+
if (!e?.id || !e.pid || !e.localPort || !this.saved.has(e.id)) continue;
|
|
125
|
+
// Only re-adopt what is provably OUR ssh still doing THIS job: the pid
|
|
126
|
+
// must be alive, be an ssh process forwarding this port, and the port
|
|
127
|
+
// must serve HTTP.
|
|
128
|
+
let alive = false;
|
|
129
|
+
try { process.kill(e.pid, 0); alive = true; } catch { /* gone */ }
|
|
130
|
+
if (alive) alive = this._looksLikeOurSsh(e.pid, e.localPort);
|
|
131
|
+
if (alive && await this._tunnelResponds(e.localPort)) {
|
|
132
|
+
this.active.set(e.id, {
|
|
133
|
+
pid: e.pid,
|
|
134
|
+
localPort: e.localPort,
|
|
135
|
+
startedAt: e.startedAt || new Date().toISOString(),
|
|
136
|
+
lastPing: Date.now(),
|
|
137
|
+
latencyMs: null,
|
|
138
|
+
healthy: true,
|
|
139
|
+
failCount: 0,
|
|
140
|
+
});
|
|
141
|
+
adopted++;
|
|
142
|
+
const name = this.saved.get(e.id)?.name || e.id;
|
|
143
|
+
console.log(`[Groove:Tunnel] Re-adopted live tunnel to ${name} on port ${e.localPort}`);
|
|
144
|
+
this.daemon.broadcast({ type: 'tunnel.connected', data: { id: e.id, name, localPort: e.localPort, host: this.saved.get(e.id)?.host, url: `http://localhost:${e.localPort}?instance=${encodeURIComponent(name)}` } });
|
|
145
|
+
} else if (alive) {
|
|
146
|
+
// ssh survives but doesn't serve — a corpse from before the restart.
|
|
147
|
+
try { process.kill(e.pid, 'SIGTERM'); } catch { /* gone */ }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (adopted > 0 && !this._healthInterval) {
|
|
151
|
+
this._healthInterval = setInterval(() => this._healthCheckAll(), HEALTH_INTERVAL);
|
|
152
|
+
}
|
|
153
|
+
this._saveActive();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Identity check for re-adoption: is this pid an ssh forwarding this port?
|
|
157
|
+
// Guards against pid recycling handing us an unrelated process.
|
|
158
|
+
_looksLikeOurSsh(pid, localPort) {
|
|
159
|
+
try {
|
|
160
|
+
const cmd = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
|
|
161
|
+
encoding: 'utf8', timeout: 3000,
|
|
162
|
+
}).trim();
|
|
163
|
+
return cmd.includes('ssh') && cmd.includes(String(localPort));
|
|
164
|
+
} catch { return false; }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
_saveActive() {
|
|
168
|
+
try {
|
|
169
|
+
const entries = [...this.active.entries()].map(([id, c]) => ({
|
|
170
|
+
id, pid: c.pid, localPort: c.localPort, startedAt: c.startedAt,
|
|
171
|
+
}));
|
|
172
|
+
writeFileSync(this.activePath, JSON.stringify(entries, null, 2), { mode: 0o600 });
|
|
173
|
+
} catch { /* best effort */ }
|
|
174
|
+
}
|
|
175
|
+
|
|
96
176
|
getSaved() {
|
|
97
177
|
return Array.from(this.saved.values()).map(s => ({
|
|
98
178
|
...this._sanitize(s),
|
|
@@ -295,6 +375,8 @@ export class TunnelManager {
|
|
|
295
375
|
return { localPort: existing.localPort, pid: existing.pid, name: config.name };
|
|
296
376
|
}
|
|
297
377
|
console.log(`[Groove:Tunnel] ${config.name}: existing tunnel is not responding — rebuilding`);
|
|
378
|
+
// Reuse the dead tunnel's port so any GUI window pointed at it heals.
|
|
379
|
+
opts = { ...opts, preferredPort: opts.preferredPort || existing.localPort };
|
|
298
380
|
await this.disconnect(id);
|
|
299
381
|
}
|
|
300
382
|
|
|
@@ -328,7 +410,14 @@ export class TunnelManager {
|
|
|
328
410
|
// Establish SSH tunnel
|
|
329
411
|
this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'connecting' } });
|
|
330
412
|
|
|
331
|
-
|
|
413
|
+
// A rebuild wants its old port back: the remote GUI window is pointed at it
|
|
414
|
+
// and will self-heal over WebSocket retry only if the port stays the same.
|
|
415
|
+
let localPort;
|
|
416
|
+
if (opts.preferredPort && !(await this._isPortInUse(opts.preferredPort))) {
|
|
417
|
+
localPort = opts.preferredPort;
|
|
418
|
+
} else {
|
|
419
|
+
localPort = await this._findAvailablePort();
|
|
420
|
+
}
|
|
332
421
|
const target = `${config.user}@${config.host}`;
|
|
333
422
|
const keyArgs = config.sshKeyPath ? ['-i', config.sshKeyPath] : [];
|
|
334
423
|
|
|
@@ -391,6 +480,7 @@ export class TunnelManager {
|
|
|
391
480
|
healthy: true,
|
|
392
481
|
failCount: 0,
|
|
393
482
|
});
|
|
483
|
+
this._saveActive();
|
|
394
484
|
|
|
395
485
|
// Verify daemon is reachable through tunnel, start if needed
|
|
396
486
|
let remoteAlive = false;
|
|
@@ -445,7 +535,7 @@ export class TunnelManager {
|
|
|
445
535
|
// Does the tunnel actually serve a request, as opposed to merely holding an
|
|
446
536
|
// open listening socket? A wedged forward passes a TCP connect test but never
|
|
447
537
|
// answers, so only an HTTP round-trip proves it.
|
|
448
|
-
async _tunnelResponds(localPort, timeoutMs = HEALTH_TIMEOUT) {
|
|
538
|
+
async _tunnelResponds(localPort, timeoutMs = this.healthTimeout ?? HEALTH_TIMEOUT) {
|
|
449
539
|
try {
|
|
450
540
|
const res = await fetch(`http://localhost:${localPort}/api/health`, {
|
|
451
541
|
signal: AbortSignal.timeout(timeoutMs),
|
|
@@ -481,6 +571,7 @@ export class TunnelManager {
|
|
|
481
571
|
if (localPort) await this._waitForPortFree(localPort, 3000);
|
|
482
572
|
|
|
483
573
|
this.active.delete(id);
|
|
574
|
+
this._saveActive();
|
|
484
575
|
|
|
485
576
|
const config = this.saved.get(id);
|
|
486
577
|
this.daemon.audit.log('tunnel.disconnect', { id, name: config?.name });
|
|
@@ -915,22 +1006,26 @@ export class TunnelManager {
|
|
|
915
1006
|
|
|
916
1007
|
async _healthCheckPass() {
|
|
917
1008
|
// Timers don't fire while the machine is asleep, so an interval that should
|
|
918
|
-
// have run every HEALTH_INTERVAL arriving far later means we just
|
|
919
|
-
//
|
|
920
|
-
//
|
|
1009
|
+
// have run every HEALTH_INTERVAL arriving far later means we PROBABLY just
|
|
1010
|
+
// woke up — but not certainly: this daemon also blocks its event loop for
|
|
1011
|
+
// long stretches (execFileSync ssh calls in test/upgrade paths), which
|
|
1012
|
+
// produces the same gap on a machine that never slept. So a gap only makes
|
|
1013
|
+
// tunnels *suspect* — it fast-tracks them to the confirmation ladder below.
|
|
1014
|
+
// It must never lower the bar for killing one (that misdiagnosis dropped a
|
|
1015
|
+
// healthy DGX tunnel twice in ten minutes).
|
|
921
1016
|
const now = Date.now();
|
|
922
1017
|
const gap = now - (this._lastHealthCheck || now);
|
|
923
1018
|
this._lastHealthCheck = now;
|
|
924
|
-
const
|
|
925
|
-
if (
|
|
926
|
-
console.log(`[Groove:Tunnel]
|
|
1019
|
+
const suspectAll = gap > HEALTH_INTERVAL * 3;
|
|
1020
|
+
if (suspectAll && this.active.size > 0) {
|
|
1021
|
+
console.log(`[Groove:Tunnel] ${Math.round(gap / 1000)}s timer gap (sleep or blocked loop) — verifying tunnels`);
|
|
927
1022
|
}
|
|
928
1023
|
|
|
929
1024
|
for (const [id, conn] of this.active) {
|
|
930
1025
|
try {
|
|
931
1026
|
const start = Date.now();
|
|
932
1027
|
const res = await fetch(`http://localhost:${conn.localPort}/api/health`, {
|
|
933
|
-
signal: AbortSignal.timeout(HEALTH_TIMEOUT),
|
|
1028
|
+
signal: AbortSignal.timeout(this.healthTimeout ?? HEALTH_TIMEOUT),
|
|
934
1029
|
});
|
|
935
1030
|
if (res.ok) {
|
|
936
1031
|
conn.latencyMs = Date.now() - start;
|
|
@@ -942,22 +1037,35 @@ export class TunnelManager {
|
|
|
942
1037
|
}
|
|
943
1038
|
} catch {
|
|
944
1039
|
conn.failCount = (conn.failCount || 0) + 1;
|
|
945
|
-
//
|
|
946
|
-
//
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
if (
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
1040
|
+
// A failed 5s probe is WEAK evidence: it can't distinguish a dead
|
|
1041
|
+
// tunnel from a remote daemon that's briefly busy or our own blocked
|
|
1042
|
+
// event loop. Never kill on it. Once failures accumulate (or a timer
|
|
1043
|
+
// gap makes everything suspect), run the confirmation ladder, which
|
|
1044
|
+
// can — a healthy verdict there resets the count.
|
|
1045
|
+
if (conn.failCount >= MAX_FAIL_COUNT || suspectAll) {
|
|
1046
|
+
const verdict = await this._confirmDead(conn);
|
|
1047
|
+
if (verdict === 'alive') {
|
|
1048
|
+
conn.failCount = 0;
|
|
1049
|
+
conn.healthy = true;
|
|
1050
|
+
conn._wedgedStreak = 0;
|
|
1051
|
+
} else if (verdict === 'remote-down') {
|
|
1052
|
+
// The tunnel is carrying traffic correctly — the far daemon is what
|
|
1053
|
+
// died (typically mid-upgrade). Rebuild would be useless; start it.
|
|
1054
|
+
conn.healthy = false;
|
|
1055
|
+
this.daemon.broadcast({ type: 'tunnel.unhealthy', data: { id } });
|
|
1056
|
+
await this._startRemoteDaemon(id, conn);
|
|
1057
|
+
} else {
|
|
1058
|
+
conn.healthy = false;
|
|
1059
|
+
this.daemon.broadcast({ type: 'tunnel.unhealthy', data: { id } });
|
|
1060
|
+
// 'wedged' (port accepts, HTTP silent even at long timeout) is the
|
|
1061
|
+
// one verdict with a false-positive path — a remote event loop
|
|
1062
|
+
// blocked 15s+ — so demand it twice in a row. proc-dead/port-dead
|
|
1063
|
+
// are unambiguous: the ssh client is gone or nothing is listening.
|
|
1064
|
+
conn._wedgedStreak = verdict === 'wedged' ? (conn._wedgedStreak || 0) + 1 : 0;
|
|
1065
|
+
if (verdict !== 'wedged' || conn._wedgedStreak >= 2) {
|
|
1066
|
+
await this._reapAndRebuild(id, conn, verdict);
|
|
1067
|
+
continue;
|
|
1068
|
+
}
|
|
961
1069
|
}
|
|
962
1070
|
}
|
|
963
1071
|
}
|
|
@@ -968,6 +1076,103 @@ export class TunnelManager {
|
|
|
968
1076
|
}
|
|
969
1077
|
}
|
|
970
1078
|
|
|
1079
|
+
// Escalating evidence that a tunnel is actually dead, not merely slow:
|
|
1080
|
+
// 'alive' — answered a long-timeout HTTP probe; leave it alone
|
|
1081
|
+
// 'proc-dead' — the ssh client process is gone
|
|
1082
|
+
// 'port-dead' — nothing is listening on the local port
|
|
1083
|
+
// 'remote-down' — the tunnel forwards fine but the REMOTE end refuses:
|
|
1084
|
+
// the probe fails fast with a connection error, not a
|
|
1085
|
+
// timeout. Killing the tunnel won't fix that — the remote
|
|
1086
|
+
// daemon needs starting (e.g. it died during an upgrade).
|
|
1087
|
+
// 'wedged' — port accepts TCP but HTTP hangs to timeout (dead forward)
|
|
1088
|
+
async _confirmDead(conn) {
|
|
1089
|
+
const started = Date.now();
|
|
1090
|
+
let probeErr = null;
|
|
1091
|
+
try {
|
|
1092
|
+
const res = await fetch(`http://localhost:${conn.localPort}/api/health`, {
|
|
1093
|
+
signal: AbortSignal.timeout(this.confirmTimeout ?? CONFIRM_TIMEOUT),
|
|
1094
|
+
});
|
|
1095
|
+
if (res.ok) return 'alive';
|
|
1096
|
+
} catch (err) { probeErr = err; }
|
|
1097
|
+
|
|
1098
|
+
if (conn.pid) {
|
|
1099
|
+
try { process.kill(conn.pid, 0); } catch { return 'proc-dead'; }
|
|
1100
|
+
}
|
|
1101
|
+
if (!(await this._isPortInUse(conn.localPort))) return 'port-dead';
|
|
1102
|
+
|
|
1103
|
+
// ssh is alive and its port listens. A hang (timeout) means the forward is
|
|
1104
|
+
// dead; a FAST connection-level error means ssh relayed the remote side's
|
|
1105
|
+
// refusal — the tunnel works, the far daemon doesn't.
|
|
1106
|
+
const failedFast = Date.now() - started < 2000;
|
|
1107
|
+
const timedOut = probeErr && (probeErr.name === 'TimeoutError' || probeErr.name === 'AbortError');
|
|
1108
|
+
if (failedFast && !timedOut) return 'remote-down';
|
|
1109
|
+
return 'wedged';
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// The tunnel is healthy; the daemon on the far side is what's down. Start it
|
|
1113
|
+
// over ssh rather than pointlessly rebuilding the tunnel. Rate-limited: if
|
|
1114
|
+
// the remote daemon won't stay up, repeated starts won't save it.
|
|
1115
|
+
async _startRemoteDaemon(id, conn) {
|
|
1116
|
+
this._remoteStartAt = this._remoteStartAt || new Map();
|
|
1117
|
+
const last = this._remoteStartAt.get(id) || 0;
|
|
1118
|
+
if (Date.now() - last < REMOTE_START_COOLDOWN_MS) return;
|
|
1119
|
+
this._remoteStartAt.set(id, Date.now());
|
|
1120
|
+
|
|
1121
|
+
console.log(`[Groove:Tunnel] ${id}: tunnel is fine but the remote daemon is down — starting it`);
|
|
1122
|
+
this.daemon.audit.log('tunnel.remote-daemon-start', { id });
|
|
1123
|
+
this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'starting' } });
|
|
1124
|
+
try {
|
|
1125
|
+
await this.autoStart(id);
|
|
1126
|
+
// Confirm it came up; a success resets the failure counters immediately
|
|
1127
|
+
// instead of waiting out another health cycle.
|
|
1128
|
+
for (let i = 0; i < 10; i++) {
|
|
1129
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
1130
|
+
if (await this._tunnelResponds(conn.localPort)) {
|
|
1131
|
+
conn.failCount = 0;
|
|
1132
|
+
conn.healthy = true;
|
|
1133
|
+
conn._wedgedStreak = 0;
|
|
1134
|
+
console.log(`[Groove:Tunnel] ${id}: remote daemon is back`);
|
|
1135
|
+
this.daemon.broadcast({ type: 'tunnel.health', data: { id, latencyMs: conn.latencyMs, healthy: true } });
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
console.warn(`[Groove:Tunnel] ${id}: remote daemon did not come back after start`);
|
|
1140
|
+
} catch (err) {
|
|
1141
|
+
console.warn(`[Groove:Tunnel] ${id}: could not start remote daemon: ${err.message}`);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
// Tear down a confirmed-dead tunnel and immediately rebuild it on the SAME
|
|
1146
|
+
// local port. The remote GUI window points at that port and its WebSocket
|
|
1147
|
+
// retries every 2s, so a same-port rebuild heals an open window without the
|
|
1148
|
+
// user noticing. Only if the rebuild fails does this surface as a disconnect.
|
|
1149
|
+
// Rate-limited so a genuinely dead host degrades to disconnected instead of
|
|
1150
|
+
// thrashing reconnect attempts forever.
|
|
1151
|
+
async _reapAndRebuild(id, conn, reason) {
|
|
1152
|
+
const { localPort } = conn;
|
|
1153
|
+
console.log(`[Groove:Tunnel] Tunnel ${id} confirmed dead (${reason}) — rebuilding`);
|
|
1154
|
+
this.daemon.audit.log('tunnel.reap', { id, reason, failCount: conn.failCount });
|
|
1155
|
+
await this.disconnect(id);
|
|
1156
|
+
|
|
1157
|
+
const lastRebuild = this._rebuildAt?.get(id) || 0;
|
|
1158
|
+
if (Date.now() - lastRebuild < REBUILD_COOLDOWN_MS) {
|
|
1159
|
+
console.log(`[Groove:Tunnel] ${id} already auto-rebuilt recently — leaving disconnected`);
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
this._rebuildAt = this._rebuildAt || new Map();
|
|
1163
|
+
this._rebuildAt.set(id, Date.now());
|
|
1164
|
+
|
|
1165
|
+
try {
|
|
1166
|
+
this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'reconnecting' } });
|
|
1167
|
+
await this.connect(id, { preferredPort: localPort });
|
|
1168
|
+
console.log(`[Groove:Tunnel] ${id} rebuilt on port ${localPort}`);
|
|
1169
|
+
} catch (err) {
|
|
1170
|
+
console.warn(`[Groove:Tunnel] Auto-rebuild of ${id} failed: ${err.message}`);
|
|
1171
|
+
// disconnect() above already broadcast tunnel.disconnected — the GUI is
|
|
1172
|
+
// consistent; the user can reconnect manually when the host is back.
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
971
1176
|
// Signal 0 only tests for existence — no signal is delivered.
|
|
972
1177
|
async _waitForExit(pid, timeoutMs) {
|
|
973
1178
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -1004,25 +1209,17 @@ export class TunnelManager {
|
|
|
1004
1209
|
throw new Error(`No available local port found (tried ${DEFAULT_LOCAL_PORT}-${DEFAULT_LOCAL_PORT + MAX_PORT_ATTEMPTS - 1})`);
|
|
1005
1210
|
}
|
|
1006
1211
|
|
|
1212
|
+
// Deliberately does NOT kill the ssh processes. They are spawned detached and
|
|
1213
|
+
// are the user's live sessions: killing them on every daemon restart (app
|
|
1214
|
+
// upgrade, promote, crash) is what nuked remote windows mid-session. State is
|
|
1215
|
+
// persisted; the next daemon re-adopts whatever is still alive and serving.
|
|
1216
|
+
// Explicit disconnect()/delete() remain the paths that actually kill a tunnel.
|
|
1007
1217
|
shutdown() {
|
|
1008
1218
|
if (this._healthInterval) {
|
|
1009
1219
|
clearInterval(this._healthInterval);
|
|
1010
1220
|
this._healthInterval = null;
|
|
1011
1221
|
}
|
|
1012
|
-
|
|
1013
|
-
try {
|
|
1014
|
-
const conn = this.active.get(id);
|
|
1015
|
-
if (conn?.pid) {
|
|
1016
|
-
const cmd = execFileSync('ps', ['-p', String(conn.pid), '-o', 'command='], {
|
|
1017
|
-
encoding: 'utf8',
|
|
1018
|
-
timeout: 3000,
|
|
1019
|
-
}).trim();
|
|
1020
|
-
if (cmd.includes('ssh')) {
|
|
1021
|
-
process.kill(conn.pid, 'SIGTERM');
|
|
1022
|
-
}
|
|
1023
|
-
}
|
|
1024
|
-
} catch { /* ignore */ }
|
|
1025
|
-
}
|
|
1222
|
+
this._saveActive();
|
|
1026
1223
|
this.active.clear();
|
|
1027
1224
|
}
|
|
1028
1225
|
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
12
12
|
import assert from 'node:assert/strict';
|
|
13
|
-
import { mkdtempSync, rmSync } from 'fs';
|
|
13
|
+
import { mkdtempSync, rmSync, readFileSync, existsSync } from 'fs';
|
|
14
14
|
import { tmpdir } from 'os';
|
|
15
15
|
import { resolve } from 'path';
|
|
16
16
|
import { createServer } from 'net';
|
|
@@ -66,6 +66,10 @@ describe('TunnelManager — wake-from-sleep recovery', () => {
|
|
|
66
66
|
grooveDir = mkdtempSync(resolve(tmpdir(), 'groove-tunnel-'));
|
|
67
67
|
daemon = makeDaemon(grooveDir);
|
|
68
68
|
mgr = new TunnelManager(daemon);
|
|
69
|
+
// Shrink probe timeouts (production: 5s routine / 15s confirm) so the
|
|
70
|
+
// wedged-listener tests don't burn real minutes waiting them out.
|
|
71
|
+
mgr.healthTimeout = 400;
|
|
72
|
+
mgr.confirmTimeout = 1200;
|
|
69
73
|
});
|
|
70
74
|
|
|
71
75
|
afterEach(() => {
|
|
@@ -84,7 +88,7 @@ describe('TunnelManager — wake-from-sleep recovery', () => {
|
|
|
84
88
|
it('_tunnelResponds rejects a tunnel that accepts but never answers', async () => {
|
|
85
89
|
const wedged = await startWedgedListener();
|
|
86
90
|
try {
|
|
87
|
-
assert.equal(await mgr._tunnelResponds(wedged.port,
|
|
91
|
+
assert.equal(await mgr._tunnelResponds(wedged.port, 800), false);
|
|
88
92
|
} finally { wedged.close(); }
|
|
89
93
|
});
|
|
90
94
|
|
|
@@ -132,19 +136,25 @@ describe('TunnelManager — wake-from-sleep recovery', () => {
|
|
|
132
136
|
} finally { live.close(); }
|
|
133
137
|
});
|
|
134
138
|
|
|
135
|
-
it('the health pass reaps a dead tunnel
|
|
139
|
+
it('the health pass reaps a proc-dead tunnel and tries to rebuild it', async () => {
|
|
136
140
|
const wedged = await startWedgedListener();
|
|
137
141
|
try {
|
|
138
142
|
mgr.saved.set('s19', { id: 's19', name: 'S19 Agency', host: 'example.invalid', user: 'ops', port: 22 });
|
|
139
143
|
mgr.active.set('s19', {
|
|
140
|
-
pid: 999999,
|
|
144
|
+
pid: 999999, // no such process → verdict 'proc-dead', reap on first confirm
|
|
145
|
+
localPort: wedged.port, healthy: true,
|
|
141
146
|
failCount: 99, // already past the failure limit
|
|
142
147
|
startedAt: new Date().toISOString(),
|
|
143
148
|
});
|
|
144
149
|
|
|
150
|
+
const rebuilds = [];
|
|
151
|
+
mgr.connect = async (id, opts) => { rebuilds.push({ id, opts }); throw new Error('host unreachable'); };
|
|
152
|
+
|
|
145
153
|
await mgr._healthCheckAll();
|
|
146
154
|
|
|
147
155
|
assert.equal(mgr.active.has('s19'), false, 'dead tunnel removed from active');
|
|
156
|
+
assert.equal(rebuilds.length, 1, 'an automatic rebuild was attempted');
|
|
157
|
+
assert.equal(rebuilds[0].opts.preferredPort, wedged.port, 'rebuild asks for the same port');
|
|
148
158
|
assert.ok(
|
|
149
159
|
daemon.broadcasts.some((b) => b.type === 'tunnel.unhealthy'),
|
|
150
160
|
'the GUI is told the tunnel went unhealthy',
|
|
@@ -152,24 +162,182 @@ describe('TunnelManager — wake-from-sleep recovery', () => {
|
|
|
152
162
|
} finally { wedged.close(); }
|
|
153
163
|
});
|
|
154
164
|
|
|
155
|
-
it('
|
|
165
|
+
it('a slow-but-alive tunnel is never reaped — the long confirm probe clears it', async () => {
|
|
166
|
+
// Answers /api/health slower than the routine probe allows but inside the
|
|
167
|
+
// confirmation window. This is a busy DGX, not a dead tunnel.
|
|
168
|
+
const sockets = [];
|
|
169
|
+
const slow = createServer((sock) => {
|
|
170
|
+
sockets.push(sock);
|
|
171
|
+
sock.on('data', () => setTimeout(() => {
|
|
172
|
+
const body = '{"ok":true}';
|
|
173
|
+
try {
|
|
174
|
+
sock.end('HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n'
|
|
175
|
+
+ `Content-Length: ${body.length}\r\nConnection: close\r\n\r\n${body}`);
|
|
176
|
+
} catch { /* closed */ }
|
|
177
|
+
}, 700));
|
|
178
|
+
});
|
|
179
|
+
await new Promise((r) => slow.listen(0, '127.0.0.1', r));
|
|
180
|
+
const port = slow.address().port;
|
|
181
|
+
try {
|
|
182
|
+
mgr.saved.set('dgx', { id: 'dgx', name: 'Axom Spark', host: 'edgexpert.local', user: 'rok', port: 22 });
|
|
183
|
+
mgr.active.set('dgx', {
|
|
184
|
+
pid: process.pid, // definitely alive
|
|
185
|
+
localPort: port, healthy: true, failCount: 99,
|
|
186
|
+
startedAt: new Date().toISOString(),
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
mgr.connect = async () => { throw new Error('rebuild must not be attempted'); };
|
|
190
|
+
await mgr._healthCheckAll();
|
|
191
|
+
|
|
192
|
+
const conn = mgr.active.get('dgx');
|
|
193
|
+
assert.ok(conn, 'the tunnel survived');
|
|
194
|
+
assert.equal(conn.healthy, true, 'confirmed alive by the long probe');
|
|
195
|
+
assert.equal(conn.failCount, 0, 'failure count reset');
|
|
196
|
+
} finally {
|
|
197
|
+
for (const s of sockets) s.destroy();
|
|
198
|
+
slow.close();
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('a wedged tunnel needs two consecutive confirmations before it is reaped', { timeout: 30000 }, async () => {
|
|
156
203
|
const wedged = await startWedgedListener();
|
|
157
204
|
try {
|
|
158
205
|
mgr.saved.set('s19', { id: 's19', name: 'S19 Agency', host: 'example.invalid', user: 'ops', port: 22 });
|
|
159
206
|
mgr.active.set('s19', {
|
|
160
|
-
pid:
|
|
207
|
+
pid: process.pid, // ssh "alive" → verdict is 'wedged', not 'proc-dead'
|
|
208
|
+
localPort: wedged.port, healthy: true, failCount: 99,
|
|
161
209
|
startedAt: new Date().toISOString(),
|
|
162
210
|
});
|
|
211
|
+
mgr.connect = async () => { throw new Error('unreachable'); };
|
|
163
212
|
|
|
164
|
-
// Last check was 10 minutes ago — the machine was asleep.
|
|
165
|
-
mgr._lastHealthCheck = Date.now() - 10 * 60 * 1000;
|
|
166
213
|
await mgr._healthCheckAll();
|
|
214
|
+
assert.equal(mgr.active.has('s19'), true, 'first wedged verdict only flags it');
|
|
215
|
+
assert.equal(mgr.active.get('s19').healthy, false);
|
|
167
216
|
|
|
168
|
-
|
|
169
|
-
|
|
217
|
+
mgr.active.get('s19').failCount = 99; // still failing next tick
|
|
218
|
+
await mgr._healthCheckAll();
|
|
219
|
+
assert.equal(mgr.active.has('s19'), false, 'second wedged verdict reaps it');
|
|
170
220
|
} finally { wedged.close(); }
|
|
171
221
|
});
|
|
172
222
|
|
|
223
|
+
it('a timer gap fast-tracks confirmation but cannot kill a live tunnel', async () => {
|
|
224
|
+
const live = await startHealthyListener();
|
|
225
|
+
try {
|
|
226
|
+
mgr.saved.set('dgx', { id: 'dgx', name: 'Axom Spark', host: 'edgexpert.local', user: 'rok', port: 22 });
|
|
227
|
+
mgr.active.set('dgx', {
|
|
228
|
+
pid: process.pid, localPort: live.port, healthy: true, failCount: 0,
|
|
229
|
+
startedAt: new Date().toISOString(),
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// 10-minute gap: sleep — or just a blocked event loop on a busy daemon.
|
|
233
|
+
mgr._lastHealthCheck = Date.now() - 10 * 60 * 1000;
|
|
234
|
+
mgr.connect = async () => { throw new Error('rebuild must not be attempted'); };
|
|
235
|
+
await mgr._healthCheckAll();
|
|
236
|
+
|
|
237
|
+
const conn = mgr.active.get('dgx');
|
|
238
|
+
assert.ok(conn, 'live tunnel survived the suspicious gap');
|
|
239
|
+
assert.equal(conn.healthy, true);
|
|
240
|
+
} finally { live.close(); }
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('remote-down (fast refusal through a live tunnel) starts the daemon, not a rebuild', async () => {
|
|
244
|
+
// ssh alive + port listens + connections REFUSED at the far end: ssh relays
|
|
245
|
+
// the refusal as an immediate close, so the probe fails fast rather than
|
|
246
|
+
// hanging. The tunnel is fine; only the remote daemon needs starting.
|
|
247
|
+
const refusing = createServer((sock) => sock.destroy());
|
|
248
|
+
await new Promise((r) => refusing.listen(0, '127.0.0.1', r));
|
|
249
|
+
const port = refusing.address().port;
|
|
250
|
+
try {
|
|
251
|
+
mgr.saved.set('dgx', { id: 'dgx', name: 'Axom Spark', host: 'edgexpert.local', user: 'axom', port: 22 });
|
|
252
|
+
mgr.active.set('dgx', {
|
|
253
|
+
pid: process.pid, localPort: port, healthy: true, failCount: 99,
|
|
254
|
+
startedAt: new Date().toISOString(),
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const started = [];
|
|
258
|
+
mgr.autoStart = async (id) => { started.push(id); };
|
|
259
|
+
mgr.connect = async () => { throw new Error('rebuild must not be attempted'); };
|
|
260
|
+
|
|
261
|
+
await mgr._healthCheckAll();
|
|
262
|
+
|
|
263
|
+
assert.deepEqual(started, ['dgx'], 'the remote daemon was started over ssh');
|
|
264
|
+
assert.equal(mgr.active.has('dgx'), true, 'the healthy tunnel was NOT torn down');
|
|
265
|
+
} finally { refusing.close(); }
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('re-adopts a surviving tunnel after a daemon restart instead of forgetting it', async () => {
|
|
269
|
+
const live = await startHealthyListener();
|
|
270
|
+
try {
|
|
271
|
+
mgr.saved.set('s19', { id: 's19', name: 'S19 Agency', host: '3.22.211.238', user: 'ubuntu', port: 22 });
|
|
272
|
+
mgr._save(); // the new daemon loads configs from disk
|
|
273
|
+
mgr.active.set('s19', {
|
|
274
|
+
pid: process.pid, localPort: live.port,
|
|
275
|
+
startedAt: new Date().toISOString(), healthy: true, failCount: 0,
|
|
276
|
+
});
|
|
277
|
+
mgr._saveActive();
|
|
278
|
+
mgr.shutdown(); // daemon going down — must NOT kill the tunnel
|
|
279
|
+
|
|
280
|
+
// "New daemon" after restart. The stand-in pid is node, not ssh, so the
|
|
281
|
+
// identity gate is stubbed — everything else runs for real.
|
|
282
|
+
const daemon2 = makeDaemon(grooveDir);
|
|
283
|
+
const mgr2 = new TunnelManager(daemon2);
|
|
284
|
+
mgr2.healthTimeout = 400;
|
|
285
|
+
mgr2._looksLikeOurSsh = (pid, port) => pid === process.pid && port === live.port;
|
|
286
|
+
|
|
287
|
+
await mgr2._readopt();
|
|
288
|
+
|
|
289
|
+
assert.equal(mgr2.active.has('s19'), true, 'the surviving tunnel was re-adopted');
|
|
290
|
+
assert.equal(mgr2.active.get('s19').localPort, live.port, 'on its original port');
|
|
291
|
+
assert.ok(
|
|
292
|
+
daemon2.broadcasts.some((b) => b.type === 'tunnel.connected'),
|
|
293
|
+
'the GUI is told the tunnel is (still) connected',
|
|
294
|
+
);
|
|
295
|
+
mgr2.shutdown();
|
|
296
|
+
} finally { live.close(); }
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('does not re-adopt a dead or hijacked pid', async () => {
|
|
300
|
+
const live = await startHealthyListener();
|
|
301
|
+
try {
|
|
302
|
+
mgr.saved.set('s19', { id: 's19', name: 'S19 Agency', host: '3.22.211.238', user: 'ubuntu', port: 22 });
|
|
303
|
+
mgr._save();
|
|
304
|
+
mgr.active.set('s19', {
|
|
305
|
+
pid: 999999, localPort: live.port, // no such process
|
|
306
|
+
startedAt: new Date().toISOString(), healthy: true, failCount: 0,
|
|
307
|
+
});
|
|
308
|
+
mgr._saveActive();
|
|
309
|
+
mgr.shutdown();
|
|
310
|
+
|
|
311
|
+
const daemon2 = makeDaemon(grooveDir);
|
|
312
|
+
const mgr2 = new TunnelManager(daemon2);
|
|
313
|
+
mgr2.healthTimeout = 400;
|
|
314
|
+
await mgr2._readopt();
|
|
315
|
+
|
|
316
|
+
assert.equal(mgr2.active.has('s19'), false, 'a dead pid is not adopted');
|
|
317
|
+
mgr2.shutdown();
|
|
318
|
+
} finally { live.close(); }
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('shutdown persists state and does not kill the tunnel process', async () => {
|
|
322
|
+
const live = await startHealthyListener();
|
|
323
|
+
try {
|
|
324
|
+
mgr.saved.set('s19', { id: 's19', name: 'S19', host: 'x', user: 'u', port: 22 });
|
|
325
|
+
mgr.active.set('s19', {
|
|
326
|
+
pid: process.pid, localPort: live.port,
|
|
327
|
+
startedAt: new Date().toISOString(), healthy: true, failCount: 0,
|
|
328
|
+
});
|
|
329
|
+
mgr.shutdown();
|
|
330
|
+
|
|
331
|
+
// Our stand-in "tunnel process" (this test runner) must still be alive —
|
|
332
|
+
// shutdown killing it would have killed the test.
|
|
333
|
+
assert.doesNotThrow(() => process.kill(process.pid, 0));
|
|
334
|
+
const persisted = JSON.parse(readFileSync(resolve(grooveDir, 'tunnels-active.json'), 'utf8'));
|
|
335
|
+
assert.equal(persisted.length, 1);
|
|
336
|
+
assert.equal(persisted[0].id, 's19');
|
|
337
|
+
assert.equal(persisted[0].localPort, live.port);
|
|
338
|
+
} finally { live.close(); }
|
|
339
|
+
});
|
|
340
|
+
|
|
173
341
|
it('_waitForPortFree reports a released port', async () => {
|
|
174
342
|
const live = await startHealthyListener();
|
|
175
343
|
assert.equal(await mgr._waitForPortFree(live.port, 600), false, 'still held while listening');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "groove-dev",
|
|
3
|
-
"version": "0.27.
|
|
3
|
+
"version": "0.27.214",
|
|
4
4
|
"description": "Open-source agent orchestration layer — the AI company OS. Local model agent engine (GGUF/Ollama/llama-server), HuggingFace model browser, MCP integrations (Slack, Gmail, Stripe, 15+), agent scheduling (cron), business roles (CMO, CFO, EA). GUI dashboard, multi-agent coordination, zero cold-start, infinite sessions. Works with Claude Code, Codex, Gemini CLI, Ollama, any local model.",
|
|
5
5
|
"license": "FSL-1.1-Apache-2.0",
|
|
6
6
|
"author": "Groove Dev <hello@groovedev.ai> (https://groovedev.ai)",
|
|
@@ -20,6 +20,16 @@ const MAX_PORT_ATTEMPTS = 10;
|
|
|
20
20
|
const HEALTH_INTERVAL = 30000;
|
|
21
21
|
const HEALTH_TIMEOUT = 5000;
|
|
22
22
|
const MAX_FAIL_COUNT = 3;
|
|
23
|
+
// Long-timeout probe used to CONFIRM death before killing a tunnel — a busy
|
|
24
|
+
// remote daemon can sit on /api/health well past the 5s routine probe.
|
|
25
|
+
const CONFIRM_TIMEOUT = 15000;
|
|
26
|
+
// At most one automatic rebuild per tunnel per window; beyond that it stays
|
|
27
|
+
// disconnected rather than thrashing against a host that keeps dying.
|
|
28
|
+
const REBUILD_COOLDOWN_MS = 10 * 60 * 1000;
|
|
29
|
+
// Restarting a crashed REMOTE daemon is cheaper and safer than a rebuild, so
|
|
30
|
+
// its cooldown is shorter — but still bounded: a daemon that dies right after
|
|
31
|
+
// every start has a real problem more starts won't fix.
|
|
32
|
+
const REMOTE_START_COOLDOWN_MS = 2 * 60 * 1000;
|
|
23
33
|
|
|
24
34
|
const INJECTION_CHARS = /[;|&`$(){}[\]<>!#\n\r\\]/;
|
|
25
35
|
|
|
@@ -54,6 +64,10 @@ export class TunnelManager {
|
|
|
54
64
|
constructor(daemon) {
|
|
55
65
|
this.daemon = daemon;
|
|
56
66
|
this.remotesPath = resolve(daemon.grooveDir, 'remotes.json');
|
|
67
|
+
// Live tunnel state, persisted separately from the configs so a daemon
|
|
68
|
+
// restart can re-adopt still-running ssh processes instead of forgetting
|
|
69
|
+
// them (the configs file is user data; this is runtime state).
|
|
70
|
+
this.activePath = resolve(daemon.grooveDir, 'tunnels-active.json');
|
|
57
71
|
this.saved = new Map();
|
|
58
72
|
this.active = new Map();
|
|
59
73
|
this._healthInterval = null;
|
|
@@ -82,8 +96,9 @@ export class TunnelManager {
|
|
|
82
96
|
}
|
|
83
97
|
|
|
84
98
|
async init() {
|
|
99
|
+
await this._readopt();
|
|
85
100
|
for (const [id, config] of this.saved) {
|
|
86
|
-
if (config.autoConnect) {
|
|
101
|
+
if (config.autoConnect && !this.active.has(id)) {
|
|
87
102
|
try {
|
|
88
103
|
await this.connect(id);
|
|
89
104
|
} catch (err) {
|
|
@@ -93,6 +108,71 @@ export class TunnelManager {
|
|
|
93
108
|
}
|
|
94
109
|
}
|
|
95
110
|
|
|
111
|
+
// Re-adopt tunnels whose detached ssh processes survived a daemon restart.
|
|
112
|
+
// Without this, every daemon/app restart orphaned the ssh (or shutdown killed
|
|
113
|
+
// it) and the new daemon started amnesiac — remote windows died mid-session
|
|
114
|
+
// and the user had to reconnect everything by hand.
|
|
115
|
+
async _readopt() {
|
|
116
|
+
let entries = [];
|
|
117
|
+
try {
|
|
118
|
+
if (existsSync(this.activePath)) entries = JSON.parse(readFileSync(this.activePath, 'utf8'));
|
|
119
|
+
} catch { /* corrupt — treat as none */ }
|
|
120
|
+
if (!Array.isArray(entries) || entries.length === 0) return;
|
|
121
|
+
|
|
122
|
+
let adopted = 0;
|
|
123
|
+
for (const e of entries) {
|
|
124
|
+
if (!e?.id || !e.pid || !e.localPort || !this.saved.has(e.id)) continue;
|
|
125
|
+
// Only re-adopt what is provably OUR ssh still doing THIS job: the pid
|
|
126
|
+
// must be alive, be an ssh process forwarding this port, and the port
|
|
127
|
+
// must serve HTTP.
|
|
128
|
+
let alive = false;
|
|
129
|
+
try { process.kill(e.pid, 0); alive = true; } catch { /* gone */ }
|
|
130
|
+
if (alive) alive = this._looksLikeOurSsh(e.pid, e.localPort);
|
|
131
|
+
if (alive && await this._tunnelResponds(e.localPort)) {
|
|
132
|
+
this.active.set(e.id, {
|
|
133
|
+
pid: e.pid,
|
|
134
|
+
localPort: e.localPort,
|
|
135
|
+
startedAt: e.startedAt || new Date().toISOString(),
|
|
136
|
+
lastPing: Date.now(),
|
|
137
|
+
latencyMs: null,
|
|
138
|
+
healthy: true,
|
|
139
|
+
failCount: 0,
|
|
140
|
+
});
|
|
141
|
+
adopted++;
|
|
142
|
+
const name = this.saved.get(e.id)?.name || e.id;
|
|
143
|
+
console.log(`[Groove:Tunnel] Re-adopted live tunnel to ${name} on port ${e.localPort}`);
|
|
144
|
+
this.daemon.broadcast({ type: 'tunnel.connected', data: { id: e.id, name, localPort: e.localPort, host: this.saved.get(e.id)?.host, url: `http://localhost:${e.localPort}?instance=${encodeURIComponent(name)}` } });
|
|
145
|
+
} else if (alive) {
|
|
146
|
+
// ssh survives but doesn't serve — a corpse from before the restart.
|
|
147
|
+
try { process.kill(e.pid, 'SIGTERM'); } catch { /* gone */ }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (adopted > 0 && !this._healthInterval) {
|
|
151
|
+
this._healthInterval = setInterval(() => this._healthCheckAll(), HEALTH_INTERVAL);
|
|
152
|
+
}
|
|
153
|
+
this._saveActive();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Identity check for re-adoption: is this pid an ssh forwarding this port?
|
|
157
|
+
// Guards against pid recycling handing us an unrelated process.
|
|
158
|
+
_looksLikeOurSsh(pid, localPort) {
|
|
159
|
+
try {
|
|
160
|
+
const cmd = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
|
|
161
|
+
encoding: 'utf8', timeout: 3000,
|
|
162
|
+
}).trim();
|
|
163
|
+
return cmd.includes('ssh') && cmd.includes(String(localPort));
|
|
164
|
+
} catch { return false; }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
_saveActive() {
|
|
168
|
+
try {
|
|
169
|
+
const entries = [...this.active.entries()].map(([id, c]) => ({
|
|
170
|
+
id, pid: c.pid, localPort: c.localPort, startedAt: c.startedAt,
|
|
171
|
+
}));
|
|
172
|
+
writeFileSync(this.activePath, JSON.stringify(entries, null, 2), { mode: 0o600 });
|
|
173
|
+
} catch { /* best effort */ }
|
|
174
|
+
}
|
|
175
|
+
|
|
96
176
|
getSaved() {
|
|
97
177
|
return Array.from(this.saved.values()).map(s => ({
|
|
98
178
|
...this._sanitize(s),
|
|
@@ -295,6 +375,8 @@ export class TunnelManager {
|
|
|
295
375
|
return { localPort: existing.localPort, pid: existing.pid, name: config.name };
|
|
296
376
|
}
|
|
297
377
|
console.log(`[Groove:Tunnel] ${config.name}: existing tunnel is not responding — rebuilding`);
|
|
378
|
+
// Reuse the dead tunnel's port so any GUI window pointed at it heals.
|
|
379
|
+
opts = { ...opts, preferredPort: opts.preferredPort || existing.localPort };
|
|
298
380
|
await this.disconnect(id);
|
|
299
381
|
}
|
|
300
382
|
|
|
@@ -328,7 +410,14 @@ export class TunnelManager {
|
|
|
328
410
|
// Establish SSH tunnel
|
|
329
411
|
this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'connecting' } });
|
|
330
412
|
|
|
331
|
-
|
|
413
|
+
// A rebuild wants its old port back: the remote GUI window is pointed at it
|
|
414
|
+
// and will self-heal over WebSocket retry only if the port stays the same.
|
|
415
|
+
let localPort;
|
|
416
|
+
if (opts.preferredPort && !(await this._isPortInUse(opts.preferredPort))) {
|
|
417
|
+
localPort = opts.preferredPort;
|
|
418
|
+
} else {
|
|
419
|
+
localPort = await this._findAvailablePort();
|
|
420
|
+
}
|
|
332
421
|
const target = `${config.user}@${config.host}`;
|
|
333
422
|
const keyArgs = config.sshKeyPath ? ['-i', config.sshKeyPath] : [];
|
|
334
423
|
|
|
@@ -391,6 +480,7 @@ export class TunnelManager {
|
|
|
391
480
|
healthy: true,
|
|
392
481
|
failCount: 0,
|
|
393
482
|
});
|
|
483
|
+
this._saveActive();
|
|
394
484
|
|
|
395
485
|
// Verify daemon is reachable through tunnel, start if needed
|
|
396
486
|
let remoteAlive = false;
|
|
@@ -445,7 +535,7 @@ export class TunnelManager {
|
|
|
445
535
|
// Does the tunnel actually serve a request, as opposed to merely holding an
|
|
446
536
|
// open listening socket? A wedged forward passes a TCP connect test but never
|
|
447
537
|
// answers, so only an HTTP round-trip proves it.
|
|
448
|
-
async _tunnelResponds(localPort, timeoutMs = HEALTH_TIMEOUT) {
|
|
538
|
+
async _tunnelResponds(localPort, timeoutMs = this.healthTimeout ?? HEALTH_TIMEOUT) {
|
|
449
539
|
try {
|
|
450
540
|
const res = await fetch(`http://localhost:${localPort}/api/health`, {
|
|
451
541
|
signal: AbortSignal.timeout(timeoutMs),
|
|
@@ -481,6 +571,7 @@ export class TunnelManager {
|
|
|
481
571
|
if (localPort) await this._waitForPortFree(localPort, 3000);
|
|
482
572
|
|
|
483
573
|
this.active.delete(id);
|
|
574
|
+
this._saveActive();
|
|
484
575
|
|
|
485
576
|
const config = this.saved.get(id);
|
|
486
577
|
this.daemon.audit.log('tunnel.disconnect', { id, name: config?.name });
|
|
@@ -915,22 +1006,26 @@ export class TunnelManager {
|
|
|
915
1006
|
|
|
916
1007
|
async _healthCheckPass() {
|
|
917
1008
|
// Timers don't fire while the machine is asleep, so an interval that should
|
|
918
|
-
// have run every HEALTH_INTERVAL arriving far later means we just
|
|
919
|
-
//
|
|
920
|
-
//
|
|
1009
|
+
// have run every HEALTH_INTERVAL arriving far later means we PROBABLY just
|
|
1010
|
+
// woke up — but not certainly: this daemon also blocks its event loop for
|
|
1011
|
+
// long stretches (execFileSync ssh calls in test/upgrade paths), which
|
|
1012
|
+
// produces the same gap on a machine that never slept. So a gap only makes
|
|
1013
|
+
// tunnels *suspect* — it fast-tracks them to the confirmation ladder below.
|
|
1014
|
+
// It must never lower the bar for killing one (that misdiagnosis dropped a
|
|
1015
|
+
// healthy DGX tunnel twice in ten minutes).
|
|
921
1016
|
const now = Date.now();
|
|
922
1017
|
const gap = now - (this._lastHealthCheck || now);
|
|
923
1018
|
this._lastHealthCheck = now;
|
|
924
|
-
const
|
|
925
|
-
if (
|
|
926
|
-
console.log(`[Groove:Tunnel]
|
|
1019
|
+
const suspectAll = gap > HEALTH_INTERVAL * 3;
|
|
1020
|
+
if (suspectAll && this.active.size > 0) {
|
|
1021
|
+
console.log(`[Groove:Tunnel] ${Math.round(gap / 1000)}s timer gap (sleep or blocked loop) — verifying tunnels`);
|
|
927
1022
|
}
|
|
928
1023
|
|
|
929
1024
|
for (const [id, conn] of this.active) {
|
|
930
1025
|
try {
|
|
931
1026
|
const start = Date.now();
|
|
932
1027
|
const res = await fetch(`http://localhost:${conn.localPort}/api/health`, {
|
|
933
|
-
signal: AbortSignal.timeout(HEALTH_TIMEOUT),
|
|
1028
|
+
signal: AbortSignal.timeout(this.healthTimeout ?? HEALTH_TIMEOUT),
|
|
934
1029
|
});
|
|
935
1030
|
if (res.ok) {
|
|
936
1031
|
conn.latencyMs = Date.now() - start;
|
|
@@ -942,22 +1037,35 @@ export class TunnelManager {
|
|
|
942
1037
|
}
|
|
943
1038
|
} catch {
|
|
944
1039
|
conn.failCount = (conn.failCount || 0) + 1;
|
|
945
|
-
//
|
|
946
|
-
//
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
if (
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
1040
|
+
// A failed 5s probe is WEAK evidence: it can't distinguish a dead
|
|
1041
|
+
// tunnel from a remote daemon that's briefly busy or our own blocked
|
|
1042
|
+
// event loop. Never kill on it. Once failures accumulate (or a timer
|
|
1043
|
+
// gap makes everything suspect), run the confirmation ladder, which
|
|
1044
|
+
// can — a healthy verdict there resets the count.
|
|
1045
|
+
if (conn.failCount >= MAX_FAIL_COUNT || suspectAll) {
|
|
1046
|
+
const verdict = await this._confirmDead(conn);
|
|
1047
|
+
if (verdict === 'alive') {
|
|
1048
|
+
conn.failCount = 0;
|
|
1049
|
+
conn.healthy = true;
|
|
1050
|
+
conn._wedgedStreak = 0;
|
|
1051
|
+
} else if (verdict === 'remote-down') {
|
|
1052
|
+
// The tunnel is carrying traffic correctly — the far daemon is what
|
|
1053
|
+
// died (typically mid-upgrade). Rebuild would be useless; start it.
|
|
1054
|
+
conn.healthy = false;
|
|
1055
|
+
this.daemon.broadcast({ type: 'tunnel.unhealthy', data: { id } });
|
|
1056
|
+
await this._startRemoteDaemon(id, conn);
|
|
1057
|
+
} else {
|
|
1058
|
+
conn.healthy = false;
|
|
1059
|
+
this.daemon.broadcast({ type: 'tunnel.unhealthy', data: { id } });
|
|
1060
|
+
// 'wedged' (port accepts, HTTP silent even at long timeout) is the
|
|
1061
|
+
// one verdict with a false-positive path — a remote event loop
|
|
1062
|
+
// blocked 15s+ — so demand it twice in a row. proc-dead/port-dead
|
|
1063
|
+
// are unambiguous: the ssh client is gone or nothing is listening.
|
|
1064
|
+
conn._wedgedStreak = verdict === 'wedged' ? (conn._wedgedStreak || 0) + 1 : 0;
|
|
1065
|
+
if (verdict !== 'wedged' || conn._wedgedStreak >= 2) {
|
|
1066
|
+
await this._reapAndRebuild(id, conn, verdict);
|
|
1067
|
+
continue;
|
|
1068
|
+
}
|
|
961
1069
|
}
|
|
962
1070
|
}
|
|
963
1071
|
}
|
|
@@ -968,6 +1076,103 @@ export class TunnelManager {
|
|
|
968
1076
|
}
|
|
969
1077
|
}
|
|
970
1078
|
|
|
1079
|
+
// Escalating evidence that a tunnel is actually dead, not merely slow:
|
|
1080
|
+
// 'alive' — answered a long-timeout HTTP probe; leave it alone
|
|
1081
|
+
// 'proc-dead' — the ssh client process is gone
|
|
1082
|
+
// 'port-dead' — nothing is listening on the local port
|
|
1083
|
+
// 'remote-down' — the tunnel forwards fine but the REMOTE end refuses:
|
|
1084
|
+
// the probe fails fast with a connection error, not a
|
|
1085
|
+
// timeout. Killing the tunnel won't fix that — the remote
|
|
1086
|
+
// daemon needs starting (e.g. it died during an upgrade).
|
|
1087
|
+
// 'wedged' — port accepts TCP but HTTP hangs to timeout (dead forward)
|
|
1088
|
+
async _confirmDead(conn) {
|
|
1089
|
+
const started = Date.now();
|
|
1090
|
+
let probeErr = null;
|
|
1091
|
+
try {
|
|
1092
|
+
const res = await fetch(`http://localhost:${conn.localPort}/api/health`, {
|
|
1093
|
+
signal: AbortSignal.timeout(this.confirmTimeout ?? CONFIRM_TIMEOUT),
|
|
1094
|
+
});
|
|
1095
|
+
if (res.ok) return 'alive';
|
|
1096
|
+
} catch (err) { probeErr = err; }
|
|
1097
|
+
|
|
1098
|
+
if (conn.pid) {
|
|
1099
|
+
try { process.kill(conn.pid, 0); } catch { return 'proc-dead'; }
|
|
1100
|
+
}
|
|
1101
|
+
if (!(await this._isPortInUse(conn.localPort))) return 'port-dead';
|
|
1102
|
+
|
|
1103
|
+
// ssh is alive and its port listens. A hang (timeout) means the forward is
|
|
1104
|
+
// dead; a FAST connection-level error means ssh relayed the remote side's
|
|
1105
|
+
// refusal — the tunnel works, the far daemon doesn't.
|
|
1106
|
+
const failedFast = Date.now() - started < 2000;
|
|
1107
|
+
const timedOut = probeErr && (probeErr.name === 'TimeoutError' || probeErr.name === 'AbortError');
|
|
1108
|
+
if (failedFast && !timedOut) return 'remote-down';
|
|
1109
|
+
return 'wedged';
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// The tunnel is healthy; the daemon on the far side is what's down. Start it
|
|
1113
|
+
// over ssh rather than pointlessly rebuilding the tunnel. Rate-limited: if
|
|
1114
|
+
// the remote daemon won't stay up, repeated starts won't save it.
|
|
1115
|
+
async _startRemoteDaemon(id, conn) {
|
|
1116
|
+
this._remoteStartAt = this._remoteStartAt || new Map();
|
|
1117
|
+
const last = this._remoteStartAt.get(id) || 0;
|
|
1118
|
+
if (Date.now() - last < REMOTE_START_COOLDOWN_MS) return;
|
|
1119
|
+
this._remoteStartAt.set(id, Date.now());
|
|
1120
|
+
|
|
1121
|
+
console.log(`[Groove:Tunnel] ${id}: tunnel is fine but the remote daemon is down — starting it`);
|
|
1122
|
+
this.daemon.audit.log('tunnel.remote-daemon-start', { id });
|
|
1123
|
+
this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'starting' } });
|
|
1124
|
+
try {
|
|
1125
|
+
await this.autoStart(id);
|
|
1126
|
+
// Confirm it came up; a success resets the failure counters immediately
|
|
1127
|
+
// instead of waiting out another health cycle.
|
|
1128
|
+
for (let i = 0; i < 10; i++) {
|
|
1129
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
1130
|
+
if (await this._tunnelResponds(conn.localPort)) {
|
|
1131
|
+
conn.failCount = 0;
|
|
1132
|
+
conn.healthy = true;
|
|
1133
|
+
conn._wedgedStreak = 0;
|
|
1134
|
+
console.log(`[Groove:Tunnel] ${id}: remote daemon is back`);
|
|
1135
|
+
this.daemon.broadcast({ type: 'tunnel.health', data: { id, latencyMs: conn.latencyMs, healthy: true } });
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
console.warn(`[Groove:Tunnel] ${id}: remote daemon did not come back after start`);
|
|
1140
|
+
} catch (err) {
|
|
1141
|
+
console.warn(`[Groove:Tunnel] ${id}: could not start remote daemon: ${err.message}`);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
// Tear down a confirmed-dead tunnel and immediately rebuild it on the SAME
|
|
1146
|
+
// local port. The remote GUI window points at that port and its WebSocket
|
|
1147
|
+
// retries every 2s, so a same-port rebuild heals an open window without the
|
|
1148
|
+
// user noticing. Only if the rebuild fails does this surface as a disconnect.
|
|
1149
|
+
// Rate-limited so a genuinely dead host degrades to disconnected instead of
|
|
1150
|
+
// thrashing reconnect attempts forever.
|
|
1151
|
+
async _reapAndRebuild(id, conn, reason) {
|
|
1152
|
+
const { localPort } = conn;
|
|
1153
|
+
console.log(`[Groove:Tunnel] Tunnel ${id} confirmed dead (${reason}) — rebuilding`);
|
|
1154
|
+
this.daemon.audit.log('tunnel.reap', { id, reason, failCount: conn.failCount });
|
|
1155
|
+
await this.disconnect(id);
|
|
1156
|
+
|
|
1157
|
+
const lastRebuild = this._rebuildAt?.get(id) || 0;
|
|
1158
|
+
if (Date.now() - lastRebuild < REBUILD_COOLDOWN_MS) {
|
|
1159
|
+
console.log(`[Groove:Tunnel] ${id} already auto-rebuilt recently — leaving disconnected`);
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
this._rebuildAt = this._rebuildAt || new Map();
|
|
1163
|
+
this._rebuildAt.set(id, Date.now());
|
|
1164
|
+
|
|
1165
|
+
try {
|
|
1166
|
+
this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'reconnecting' } });
|
|
1167
|
+
await this.connect(id, { preferredPort: localPort });
|
|
1168
|
+
console.log(`[Groove:Tunnel] ${id} rebuilt on port ${localPort}`);
|
|
1169
|
+
} catch (err) {
|
|
1170
|
+
console.warn(`[Groove:Tunnel] Auto-rebuild of ${id} failed: ${err.message}`);
|
|
1171
|
+
// disconnect() above already broadcast tunnel.disconnected — the GUI is
|
|
1172
|
+
// consistent; the user can reconnect manually when the host is back.
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
971
1176
|
// Signal 0 only tests for existence — no signal is delivered.
|
|
972
1177
|
async _waitForExit(pid, timeoutMs) {
|
|
973
1178
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -1004,25 +1209,17 @@ export class TunnelManager {
|
|
|
1004
1209
|
throw new Error(`No available local port found (tried ${DEFAULT_LOCAL_PORT}-${DEFAULT_LOCAL_PORT + MAX_PORT_ATTEMPTS - 1})`);
|
|
1005
1210
|
}
|
|
1006
1211
|
|
|
1212
|
+
// Deliberately does NOT kill the ssh processes. They are spawned detached and
|
|
1213
|
+
// are the user's live sessions: killing them on every daemon restart (app
|
|
1214
|
+
// upgrade, promote, crash) is what nuked remote windows mid-session. State is
|
|
1215
|
+
// persisted; the next daemon re-adopts whatever is still alive and serving.
|
|
1216
|
+
// Explicit disconnect()/delete() remain the paths that actually kill a tunnel.
|
|
1007
1217
|
shutdown() {
|
|
1008
1218
|
if (this._healthInterval) {
|
|
1009
1219
|
clearInterval(this._healthInterval);
|
|
1010
1220
|
this._healthInterval = null;
|
|
1011
1221
|
}
|
|
1012
|
-
|
|
1013
|
-
try {
|
|
1014
|
-
const conn = this.active.get(id);
|
|
1015
|
-
if (conn?.pid) {
|
|
1016
|
-
const cmd = execFileSync('ps', ['-p', String(conn.pid), '-o', 'command='], {
|
|
1017
|
-
encoding: 'utf8',
|
|
1018
|
-
timeout: 3000,
|
|
1019
|
-
}).trim();
|
|
1020
|
-
if (cmd.includes('ssh')) {
|
|
1021
|
-
process.kill(conn.pid, 'SIGTERM');
|
|
1022
|
-
}
|
|
1023
|
-
}
|
|
1024
|
-
} catch { /* ignore */ }
|
|
1025
|
-
}
|
|
1222
|
+
this._saveActive();
|
|
1026
1223
|
this.active.clear();
|
|
1027
1224
|
}
|
|
1028
1225
|
}
|