groove-dev 0.27.212 → 0.27.213
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 +96 -25
- package/node_modules/@groove-dev/daemon/test/tunnel-manager.test.js +79 -9
- 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 +96 -25
- 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,12 @@ 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;
|
|
23
29
|
|
|
24
30
|
const INJECTION_CHARS = /[;|&`$(){}[\]<>!#\n\r\\]/;
|
|
25
31
|
|
|
@@ -295,6 +301,8 @@ export class TunnelManager {
|
|
|
295
301
|
return { localPort: existing.localPort, pid: existing.pid, name: config.name };
|
|
296
302
|
}
|
|
297
303
|
console.log(`[Groove:Tunnel] ${config.name}: existing tunnel is not responding — rebuilding`);
|
|
304
|
+
// Reuse the dead tunnel's port so any GUI window pointed at it heals.
|
|
305
|
+
opts = { ...opts, preferredPort: opts.preferredPort || existing.localPort };
|
|
298
306
|
await this.disconnect(id);
|
|
299
307
|
}
|
|
300
308
|
|
|
@@ -328,7 +336,14 @@ export class TunnelManager {
|
|
|
328
336
|
// Establish SSH tunnel
|
|
329
337
|
this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'connecting' } });
|
|
330
338
|
|
|
331
|
-
|
|
339
|
+
// A rebuild wants its old port back: the remote GUI window is pointed at it
|
|
340
|
+
// and will self-heal over WebSocket retry only if the port stays the same.
|
|
341
|
+
let localPort;
|
|
342
|
+
if (opts.preferredPort && !(await this._isPortInUse(opts.preferredPort))) {
|
|
343
|
+
localPort = opts.preferredPort;
|
|
344
|
+
} else {
|
|
345
|
+
localPort = await this._findAvailablePort();
|
|
346
|
+
}
|
|
332
347
|
const target = `${config.user}@${config.host}`;
|
|
333
348
|
const keyArgs = config.sshKeyPath ? ['-i', config.sshKeyPath] : [];
|
|
334
349
|
|
|
@@ -445,7 +460,7 @@ export class TunnelManager {
|
|
|
445
460
|
// Does the tunnel actually serve a request, as opposed to merely holding an
|
|
446
461
|
// open listening socket? A wedged forward passes a TCP connect test but never
|
|
447
462
|
// answers, so only an HTTP round-trip proves it.
|
|
448
|
-
async _tunnelResponds(localPort, timeoutMs = HEALTH_TIMEOUT) {
|
|
463
|
+
async _tunnelResponds(localPort, timeoutMs = this.healthTimeout ?? HEALTH_TIMEOUT) {
|
|
449
464
|
try {
|
|
450
465
|
const res = await fetch(`http://localhost:${localPort}/api/health`, {
|
|
451
466
|
signal: AbortSignal.timeout(timeoutMs),
|
|
@@ -915,22 +930,26 @@ export class TunnelManager {
|
|
|
915
930
|
|
|
916
931
|
async _healthCheckPass() {
|
|
917
932
|
// 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
|
-
//
|
|
933
|
+
// have run every HEALTH_INTERVAL arriving far later means we PROBABLY just
|
|
934
|
+
// woke up — but not certainly: this daemon also blocks its event loop for
|
|
935
|
+
// long stretches (execFileSync ssh calls in test/upgrade paths), which
|
|
936
|
+
// produces the same gap on a machine that never slept. So a gap only makes
|
|
937
|
+
// tunnels *suspect* — it fast-tracks them to the confirmation ladder below.
|
|
938
|
+
// It must never lower the bar for killing one (that misdiagnosis dropped a
|
|
939
|
+
// healthy DGX tunnel twice in ten minutes).
|
|
921
940
|
const now = Date.now();
|
|
922
941
|
const gap = now - (this._lastHealthCheck || now);
|
|
923
942
|
this._lastHealthCheck = now;
|
|
924
|
-
const
|
|
925
|
-
if (
|
|
926
|
-
console.log(`[Groove:Tunnel]
|
|
943
|
+
const suspectAll = gap > HEALTH_INTERVAL * 3;
|
|
944
|
+
if (suspectAll && this.active.size > 0) {
|
|
945
|
+
console.log(`[Groove:Tunnel] ${Math.round(gap / 1000)}s timer gap (sleep or blocked loop) — verifying tunnels`);
|
|
927
946
|
}
|
|
928
947
|
|
|
929
948
|
for (const [id, conn] of this.active) {
|
|
930
949
|
try {
|
|
931
950
|
const start = Date.now();
|
|
932
951
|
const res = await fetch(`http://localhost:${conn.localPort}/api/health`, {
|
|
933
|
-
signal: AbortSignal.timeout(HEALTH_TIMEOUT),
|
|
952
|
+
signal: AbortSignal.timeout(this.healthTimeout ?? HEALTH_TIMEOUT),
|
|
934
953
|
});
|
|
935
954
|
if (res.ok) {
|
|
936
955
|
conn.latencyMs = Date.now() - start;
|
|
@@ -942,22 +961,29 @@ export class TunnelManager {
|
|
|
942
961
|
}
|
|
943
962
|
} catch {
|
|
944
963
|
conn.failCount = (conn.failCount || 0) + 1;
|
|
945
|
-
//
|
|
946
|
-
//
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
this.daemon.broadcast({ type: 'tunnel.
|
|
959
|
-
|
|
960
|
-
|
|
964
|
+
// A failed 5s probe is WEAK evidence: it can't distinguish a dead
|
|
965
|
+
// tunnel from a remote daemon that's briefly busy or our own blocked
|
|
966
|
+
// event loop. Never kill on it. Once failures accumulate (or a timer
|
|
967
|
+
// gap makes everything suspect), run the confirmation ladder, which
|
|
968
|
+
// can — a healthy verdict there resets the count.
|
|
969
|
+
if (conn.failCount >= MAX_FAIL_COUNT || suspectAll) {
|
|
970
|
+
const verdict = await this._confirmDead(conn);
|
|
971
|
+
if (verdict === 'alive') {
|
|
972
|
+
conn.failCount = 0;
|
|
973
|
+
conn.healthy = true;
|
|
974
|
+
conn._wedgedStreak = 0;
|
|
975
|
+
} else {
|
|
976
|
+
conn.healthy = false;
|
|
977
|
+
this.daemon.broadcast({ type: 'tunnel.unhealthy', data: { id } });
|
|
978
|
+
// 'wedged' (port accepts, HTTP silent even at long timeout) is the
|
|
979
|
+
// one verdict with a false-positive path — a remote event loop
|
|
980
|
+
// blocked 15s+ — so demand it twice in a row. proc-dead/port-dead
|
|
981
|
+
// are unambiguous: the ssh client is gone or nothing is listening.
|
|
982
|
+
conn._wedgedStreak = verdict === 'wedged' ? (conn._wedgedStreak || 0) + 1 : 0;
|
|
983
|
+
if (verdict !== 'wedged' || conn._wedgedStreak >= 2) {
|
|
984
|
+
await this._reapAndRebuild(id, conn, verdict);
|
|
985
|
+
continue;
|
|
986
|
+
}
|
|
961
987
|
}
|
|
962
988
|
}
|
|
963
989
|
}
|
|
@@ -968,6 +994,51 @@ export class TunnelManager {
|
|
|
968
994
|
}
|
|
969
995
|
}
|
|
970
996
|
|
|
997
|
+
// Escalating evidence that a tunnel is actually dead, not merely slow:
|
|
998
|
+
// 'alive' — answered a long-timeout HTTP probe; leave it alone
|
|
999
|
+
// 'proc-dead' — the ssh client process is gone
|
|
1000
|
+
// 'port-dead' — nothing is listening on the local port
|
|
1001
|
+
// 'wedged' — port accepts TCP but HTTP never answers (dead forward)
|
|
1002
|
+
async _confirmDead(conn) {
|
|
1003
|
+
if (await this._tunnelResponds(conn.localPort, this.confirmTimeout ?? CONFIRM_TIMEOUT)) return 'alive';
|
|
1004
|
+
if (conn.pid) {
|
|
1005
|
+
try { process.kill(conn.pid, 0); } catch { return 'proc-dead'; }
|
|
1006
|
+
}
|
|
1007
|
+
if (!(await this._isPortInUse(conn.localPort))) return 'port-dead';
|
|
1008
|
+
return 'wedged';
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// Tear down a confirmed-dead tunnel and immediately rebuild it on the SAME
|
|
1012
|
+
// local port. The remote GUI window points at that port and its WebSocket
|
|
1013
|
+
// retries every 2s, so a same-port rebuild heals an open window without the
|
|
1014
|
+
// user noticing. Only if the rebuild fails does this surface as a disconnect.
|
|
1015
|
+
// Rate-limited so a genuinely dead host degrades to disconnected instead of
|
|
1016
|
+
// thrashing reconnect attempts forever.
|
|
1017
|
+
async _reapAndRebuild(id, conn, reason) {
|
|
1018
|
+
const { localPort } = conn;
|
|
1019
|
+
console.log(`[Groove:Tunnel] Tunnel ${id} confirmed dead (${reason}) — rebuilding`);
|
|
1020
|
+
this.daemon.audit.log('tunnel.reap', { id, reason, failCount: conn.failCount });
|
|
1021
|
+
await this.disconnect(id);
|
|
1022
|
+
|
|
1023
|
+
const lastRebuild = this._rebuildAt?.get(id) || 0;
|
|
1024
|
+
if (Date.now() - lastRebuild < REBUILD_COOLDOWN_MS) {
|
|
1025
|
+
console.log(`[Groove:Tunnel] ${id} already auto-rebuilt recently — leaving disconnected`);
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
this._rebuildAt = this._rebuildAt || new Map();
|
|
1029
|
+
this._rebuildAt.set(id, Date.now());
|
|
1030
|
+
|
|
1031
|
+
try {
|
|
1032
|
+
this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'reconnecting' } });
|
|
1033
|
+
await this.connect(id, { preferredPort: localPort });
|
|
1034
|
+
console.log(`[Groove:Tunnel] ${id} rebuilt on port ${localPort}`);
|
|
1035
|
+
} catch (err) {
|
|
1036
|
+
console.warn(`[Groove:Tunnel] Auto-rebuild of ${id} failed: ${err.message}`);
|
|
1037
|
+
// disconnect() above already broadcast tunnel.disconnected — the GUI is
|
|
1038
|
+
// consistent; the user can reconnect manually when the host is back.
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
971
1042
|
// Signal 0 only tests for existence — no signal is delivered.
|
|
972
1043
|
async _waitForExit(pid, timeoutMs) {
|
|
973
1044
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -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,84 @@ 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
|
+
|
|
173
243
|
it('_waitForPortFree reports a released port', async () => {
|
|
174
244
|
const live = await startHealthyListener();
|
|
175
245
|
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.213",
|
|
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,12 @@ 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;
|
|
23
29
|
|
|
24
30
|
const INJECTION_CHARS = /[;|&`$(){}[\]<>!#\n\r\\]/;
|
|
25
31
|
|
|
@@ -295,6 +301,8 @@ export class TunnelManager {
|
|
|
295
301
|
return { localPort: existing.localPort, pid: existing.pid, name: config.name };
|
|
296
302
|
}
|
|
297
303
|
console.log(`[Groove:Tunnel] ${config.name}: existing tunnel is not responding — rebuilding`);
|
|
304
|
+
// Reuse the dead tunnel's port so any GUI window pointed at it heals.
|
|
305
|
+
opts = { ...opts, preferredPort: opts.preferredPort || existing.localPort };
|
|
298
306
|
await this.disconnect(id);
|
|
299
307
|
}
|
|
300
308
|
|
|
@@ -328,7 +336,14 @@ export class TunnelManager {
|
|
|
328
336
|
// Establish SSH tunnel
|
|
329
337
|
this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'connecting' } });
|
|
330
338
|
|
|
331
|
-
|
|
339
|
+
// A rebuild wants its old port back: the remote GUI window is pointed at it
|
|
340
|
+
// and will self-heal over WebSocket retry only if the port stays the same.
|
|
341
|
+
let localPort;
|
|
342
|
+
if (opts.preferredPort && !(await this._isPortInUse(opts.preferredPort))) {
|
|
343
|
+
localPort = opts.preferredPort;
|
|
344
|
+
} else {
|
|
345
|
+
localPort = await this._findAvailablePort();
|
|
346
|
+
}
|
|
332
347
|
const target = `${config.user}@${config.host}`;
|
|
333
348
|
const keyArgs = config.sshKeyPath ? ['-i', config.sshKeyPath] : [];
|
|
334
349
|
|
|
@@ -445,7 +460,7 @@ export class TunnelManager {
|
|
|
445
460
|
// Does the tunnel actually serve a request, as opposed to merely holding an
|
|
446
461
|
// open listening socket? A wedged forward passes a TCP connect test but never
|
|
447
462
|
// answers, so only an HTTP round-trip proves it.
|
|
448
|
-
async _tunnelResponds(localPort, timeoutMs = HEALTH_TIMEOUT) {
|
|
463
|
+
async _tunnelResponds(localPort, timeoutMs = this.healthTimeout ?? HEALTH_TIMEOUT) {
|
|
449
464
|
try {
|
|
450
465
|
const res = await fetch(`http://localhost:${localPort}/api/health`, {
|
|
451
466
|
signal: AbortSignal.timeout(timeoutMs),
|
|
@@ -915,22 +930,26 @@ export class TunnelManager {
|
|
|
915
930
|
|
|
916
931
|
async _healthCheckPass() {
|
|
917
932
|
// 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
|
-
//
|
|
933
|
+
// have run every HEALTH_INTERVAL arriving far later means we PROBABLY just
|
|
934
|
+
// woke up — but not certainly: this daemon also blocks its event loop for
|
|
935
|
+
// long stretches (execFileSync ssh calls in test/upgrade paths), which
|
|
936
|
+
// produces the same gap on a machine that never slept. So a gap only makes
|
|
937
|
+
// tunnels *suspect* — it fast-tracks them to the confirmation ladder below.
|
|
938
|
+
// It must never lower the bar for killing one (that misdiagnosis dropped a
|
|
939
|
+
// healthy DGX tunnel twice in ten minutes).
|
|
921
940
|
const now = Date.now();
|
|
922
941
|
const gap = now - (this._lastHealthCheck || now);
|
|
923
942
|
this._lastHealthCheck = now;
|
|
924
|
-
const
|
|
925
|
-
if (
|
|
926
|
-
console.log(`[Groove:Tunnel]
|
|
943
|
+
const suspectAll = gap > HEALTH_INTERVAL * 3;
|
|
944
|
+
if (suspectAll && this.active.size > 0) {
|
|
945
|
+
console.log(`[Groove:Tunnel] ${Math.round(gap / 1000)}s timer gap (sleep or blocked loop) — verifying tunnels`);
|
|
927
946
|
}
|
|
928
947
|
|
|
929
948
|
for (const [id, conn] of this.active) {
|
|
930
949
|
try {
|
|
931
950
|
const start = Date.now();
|
|
932
951
|
const res = await fetch(`http://localhost:${conn.localPort}/api/health`, {
|
|
933
|
-
signal: AbortSignal.timeout(HEALTH_TIMEOUT),
|
|
952
|
+
signal: AbortSignal.timeout(this.healthTimeout ?? HEALTH_TIMEOUT),
|
|
934
953
|
});
|
|
935
954
|
if (res.ok) {
|
|
936
955
|
conn.latencyMs = Date.now() - start;
|
|
@@ -942,22 +961,29 @@ export class TunnelManager {
|
|
|
942
961
|
}
|
|
943
962
|
} catch {
|
|
944
963
|
conn.failCount = (conn.failCount || 0) + 1;
|
|
945
|
-
//
|
|
946
|
-
//
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
this.daemon.broadcast({ type: 'tunnel.
|
|
959
|
-
|
|
960
|
-
|
|
964
|
+
// A failed 5s probe is WEAK evidence: it can't distinguish a dead
|
|
965
|
+
// tunnel from a remote daemon that's briefly busy or our own blocked
|
|
966
|
+
// event loop. Never kill on it. Once failures accumulate (or a timer
|
|
967
|
+
// gap makes everything suspect), run the confirmation ladder, which
|
|
968
|
+
// can — a healthy verdict there resets the count.
|
|
969
|
+
if (conn.failCount >= MAX_FAIL_COUNT || suspectAll) {
|
|
970
|
+
const verdict = await this._confirmDead(conn);
|
|
971
|
+
if (verdict === 'alive') {
|
|
972
|
+
conn.failCount = 0;
|
|
973
|
+
conn.healthy = true;
|
|
974
|
+
conn._wedgedStreak = 0;
|
|
975
|
+
} else {
|
|
976
|
+
conn.healthy = false;
|
|
977
|
+
this.daemon.broadcast({ type: 'tunnel.unhealthy', data: { id } });
|
|
978
|
+
// 'wedged' (port accepts, HTTP silent even at long timeout) is the
|
|
979
|
+
// one verdict with a false-positive path — a remote event loop
|
|
980
|
+
// blocked 15s+ — so demand it twice in a row. proc-dead/port-dead
|
|
981
|
+
// are unambiguous: the ssh client is gone or nothing is listening.
|
|
982
|
+
conn._wedgedStreak = verdict === 'wedged' ? (conn._wedgedStreak || 0) + 1 : 0;
|
|
983
|
+
if (verdict !== 'wedged' || conn._wedgedStreak >= 2) {
|
|
984
|
+
await this._reapAndRebuild(id, conn, verdict);
|
|
985
|
+
continue;
|
|
986
|
+
}
|
|
961
987
|
}
|
|
962
988
|
}
|
|
963
989
|
}
|
|
@@ -968,6 +994,51 @@ export class TunnelManager {
|
|
|
968
994
|
}
|
|
969
995
|
}
|
|
970
996
|
|
|
997
|
+
// Escalating evidence that a tunnel is actually dead, not merely slow:
|
|
998
|
+
// 'alive' — answered a long-timeout HTTP probe; leave it alone
|
|
999
|
+
// 'proc-dead' — the ssh client process is gone
|
|
1000
|
+
// 'port-dead' — nothing is listening on the local port
|
|
1001
|
+
// 'wedged' — port accepts TCP but HTTP never answers (dead forward)
|
|
1002
|
+
async _confirmDead(conn) {
|
|
1003
|
+
if (await this._tunnelResponds(conn.localPort, this.confirmTimeout ?? CONFIRM_TIMEOUT)) return 'alive';
|
|
1004
|
+
if (conn.pid) {
|
|
1005
|
+
try { process.kill(conn.pid, 0); } catch { return 'proc-dead'; }
|
|
1006
|
+
}
|
|
1007
|
+
if (!(await this._isPortInUse(conn.localPort))) return 'port-dead';
|
|
1008
|
+
return 'wedged';
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// Tear down a confirmed-dead tunnel and immediately rebuild it on the SAME
|
|
1012
|
+
// local port. The remote GUI window points at that port and its WebSocket
|
|
1013
|
+
// retries every 2s, so a same-port rebuild heals an open window without the
|
|
1014
|
+
// user noticing. Only if the rebuild fails does this surface as a disconnect.
|
|
1015
|
+
// Rate-limited so a genuinely dead host degrades to disconnected instead of
|
|
1016
|
+
// thrashing reconnect attempts forever.
|
|
1017
|
+
async _reapAndRebuild(id, conn, reason) {
|
|
1018
|
+
const { localPort } = conn;
|
|
1019
|
+
console.log(`[Groove:Tunnel] Tunnel ${id} confirmed dead (${reason}) — rebuilding`);
|
|
1020
|
+
this.daemon.audit.log('tunnel.reap', { id, reason, failCount: conn.failCount });
|
|
1021
|
+
await this.disconnect(id);
|
|
1022
|
+
|
|
1023
|
+
const lastRebuild = this._rebuildAt?.get(id) || 0;
|
|
1024
|
+
if (Date.now() - lastRebuild < REBUILD_COOLDOWN_MS) {
|
|
1025
|
+
console.log(`[Groove:Tunnel] ${id} already auto-rebuilt recently — leaving disconnected`);
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
this._rebuildAt = this._rebuildAt || new Map();
|
|
1029
|
+
this._rebuildAt.set(id, Date.now());
|
|
1030
|
+
|
|
1031
|
+
try {
|
|
1032
|
+
this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'reconnecting' } });
|
|
1033
|
+
await this.connect(id, { preferredPort: localPort });
|
|
1034
|
+
console.log(`[Groove:Tunnel] ${id} rebuilt on port ${localPort}`);
|
|
1035
|
+
} catch (err) {
|
|
1036
|
+
console.warn(`[Groove:Tunnel] Auto-rebuild of ${id} failed: ${err.message}`);
|
|
1037
|
+
// disconnect() above already broadcast tunnel.disconnected — the GUI is
|
|
1038
|
+
// consistent; the user can reconnect manually when the host is back.
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
971
1042
|
// Signal 0 only tests for existence — no signal is delivered.
|
|
972
1043
|
async _waitForExit(pid, timeoutMs) {
|
|
973
1044
|
const deadline = Date.now() + timeoutMs;
|