groove-dev 0.27.213 → 0.27.215
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/CLAUDE.md +2 -0
- package/node_modules/@groove-dev/cli/bin/groove.js +8 -0
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/cli/src/commands/handoff.js +22 -0
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/index.js +18 -1
- package/node_modules/@groove-dev/daemon/src/journalist.js +28 -0
- package/node_modules/@groove-dev/daemon/src/rotator.js +179 -0
- package/node_modules/@groove-dev/daemon/src/routes/agents.js +25 -0
- package/node_modules/@groove-dev/daemon/src/tunnel-manager.js +217 -30
- package/node_modules/@groove-dev/daemon/test/rotator.test.js +86 -0
- package/node_modules/@groove-dev/daemon/test/tunnel-manager.test.js +143 -2
- package/node_modules/@groove-dev/gui/dist/assets/{index-DPsim83z.css → index-B82kMi13.css} +1 -1
- package/node_modules/@groove-dev/gui/dist/assets/{index-BP4oE2UL.js → index-CLfmxBx1.js} +1 -1
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/package.json +1 -1
- package/packages/cli/bin/groove.js +8 -0
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/commands/handoff.js +22 -0
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/index.js +18 -1
- package/packages/daemon/src/journalist.js +28 -0
- package/packages/daemon/src/rotator.js +179 -0
- package/packages/daemon/src/routes/agents.js +25 -0
- package/packages/daemon/src/tunnel-manager.js +217 -30
- package/packages/gui/dist/assets/{index-DPsim83z.css → index-B82kMi13.css} +1 -1
- package/packages/gui/dist/assets/{index-BP4oE2UL.js → index-CLfmxBx1.js} +1 -1
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
import { execFileSync, spawn } from 'child_process';
|
|
5
5
|
import { existsSync, writeFileSync, readFileSync, statSync } from 'fs';
|
|
6
6
|
import { resolve } from 'path';
|
|
7
|
-
import { createConnection } from 'net';
|
|
7
|
+
import { createConnection, isIP } from 'net';
|
|
8
|
+
import { lookup } from 'dns/promises';
|
|
8
9
|
import crypto from 'crypto';
|
|
9
10
|
|
|
10
11
|
function getLocalVersion() {
|
|
@@ -15,6 +16,13 @@ function getLocalVersion() {
|
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
const REMOTE_PORT = 31415;
|
|
19
|
+
|
|
20
|
+
// Every remote `groove start` must run from the directory of the daemon's
|
|
21
|
+
// EXISTING world (grooveDir is derived from cwd). The remote daemon records
|
|
22
|
+
// that directory in ~/.groove/last-run-dir on each boot; starting anywhere
|
|
23
|
+
// else boots a fresh empty .groove — which reads as "all my teams are gone".
|
|
24
|
+
// Falls back to $HOME (matching old behavior) when no anchor exists yet.
|
|
25
|
+
const ANCHOR_CD = `cd "$(cat "$HOME/.groove/last-run-dir" 2>/dev/null || echo "$HOME")" 2>/dev/null || cd "$HOME"; `;
|
|
18
26
|
const DEFAULT_LOCAL_PORT = 31416;
|
|
19
27
|
const MAX_PORT_ATTEMPTS = 10;
|
|
20
28
|
const HEALTH_INTERVAL = 30000;
|
|
@@ -26,6 +34,10 @@ const CONFIRM_TIMEOUT = 15000;
|
|
|
26
34
|
// At most one automatic rebuild per tunnel per window; beyond that it stays
|
|
27
35
|
// disconnected rather than thrashing against a host that keeps dying.
|
|
28
36
|
const REBUILD_COOLDOWN_MS = 10 * 60 * 1000;
|
|
37
|
+
// Restarting a crashed REMOTE daemon is cheaper and safer than a rebuild, so
|
|
38
|
+
// its cooldown is shorter — but still bounded: a daemon that dies right after
|
|
39
|
+
// every start has a real problem more starts won't fix.
|
|
40
|
+
const REMOTE_START_COOLDOWN_MS = 2 * 60 * 1000;
|
|
29
41
|
|
|
30
42
|
const INJECTION_CHARS = /[;|&`$(){}[\]<>!#\n\r\\]/;
|
|
31
43
|
|
|
@@ -56,10 +68,57 @@ function isPermissionError(output) {
|
|
|
56
68
|
|
|
57
69
|
const PERMISSION_HINT = 'npm global install requires write access. Either install Node via nvm (recommended) or configure passwordless sudo for npm on the remote server.';
|
|
58
70
|
|
|
71
|
+
// A hostname can resolve to several addresses on different interfaces — a
|
|
72
|
+
// dual-homed LAN box (wired + Wi-Fi) advertises all of them over mDNS, and ssh
|
|
73
|
+
// just takes the resolver's first pick. Landing on a weak Wi-Fi address gives
|
|
74
|
+
// a tunnel that dies of keepalive timeout minutes later, and every reconnect
|
|
75
|
+
// re-rolls the dice. Probe all candidates with a TCP handshake to the ssh port
|
|
76
|
+
// and take the fastest responder — on a LAN that reliably picks wired over
|
|
77
|
+
// Wi-Fi. Falls back to the original hostname if resolution or every probe
|
|
78
|
+
// fails, so behavior is unchanged for the cases that already worked.
|
|
79
|
+
export async function resolveBestAddress(host, port = 22, probeTimeoutMs = 2500) {
|
|
80
|
+
if (isIP(host)) return host; // literal IP — nothing to choose
|
|
81
|
+
let addrs;
|
|
82
|
+
try {
|
|
83
|
+
addrs = await lookup(host, { all: true, verbatim: true });
|
|
84
|
+
} catch { return host; }
|
|
85
|
+
if (!Array.isArray(addrs) || addrs.length <= 1) return host;
|
|
86
|
+
|
|
87
|
+
const handshake = (address) => new Promise((res) => {
|
|
88
|
+
const started = Date.now();
|
|
89
|
+
let sock;
|
|
90
|
+
try { sock = createConnection({ host: address, port }); } catch { return res(null); }
|
|
91
|
+
sock.setTimeout(probeTimeoutMs);
|
|
92
|
+
sock.on('connect', () => { sock.destroy(); res(Date.now() - started); });
|
|
93
|
+
sock.on('error', () => res(null));
|
|
94
|
+
sock.on('timeout', () => { sock.destroy(); res(null); });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Median of three handshakes per address: one lucky round-trip can make a
|
|
98
|
+
// weak link look fine, but a flaky link rarely wins three in a row — a
|
|
99
|
+
// single retransmit (or drop, scored as the timeout) sinks its median.
|
|
100
|
+
const probe = async (address) => {
|
|
101
|
+
const times = [];
|
|
102
|
+
for (let i = 0; i < 3; i++) times.push(await handshake(address));
|
|
103
|
+
const scored = times.map((t) => (t === null ? probeTimeoutMs : t)).sort((a, b) => a - b);
|
|
104
|
+
if (times.every((t) => t === null)) return null; // never connected at all
|
|
105
|
+
return { address, ms: scored[1] };
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const results = (await Promise.all(addrs.map((a) => probe(a.address)))).filter(Boolean);
|
|
109
|
+
if (results.length === 0) return host;
|
|
110
|
+
results.sort((a, b) => a.ms - b.ms);
|
|
111
|
+
return results[0].address;
|
|
112
|
+
}
|
|
113
|
+
|
|
59
114
|
export class TunnelManager {
|
|
60
115
|
constructor(daemon) {
|
|
61
116
|
this.daemon = daemon;
|
|
62
117
|
this.remotesPath = resolve(daemon.grooveDir, 'remotes.json');
|
|
118
|
+
// Live tunnel state, persisted separately from the configs so a daemon
|
|
119
|
+
// restart can re-adopt still-running ssh processes instead of forgetting
|
|
120
|
+
// them (the configs file is user data; this is runtime state).
|
|
121
|
+
this.activePath = resolve(daemon.grooveDir, 'tunnels-active.json');
|
|
63
122
|
this.saved = new Map();
|
|
64
123
|
this.active = new Map();
|
|
65
124
|
this._healthInterval = null;
|
|
@@ -88,8 +147,9 @@ export class TunnelManager {
|
|
|
88
147
|
}
|
|
89
148
|
|
|
90
149
|
async init() {
|
|
150
|
+
await this._readopt();
|
|
91
151
|
for (const [id, config] of this.saved) {
|
|
92
|
-
if (config.autoConnect) {
|
|
152
|
+
if (config.autoConnect && !this.active.has(id)) {
|
|
93
153
|
try {
|
|
94
154
|
await this.connect(id);
|
|
95
155
|
} catch (err) {
|
|
@@ -99,6 +159,71 @@ export class TunnelManager {
|
|
|
99
159
|
}
|
|
100
160
|
}
|
|
101
161
|
|
|
162
|
+
// Re-adopt tunnels whose detached ssh processes survived a daemon restart.
|
|
163
|
+
// Without this, every daemon/app restart orphaned the ssh (or shutdown killed
|
|
164
|
+
// it) and the new daemon started amnesiac — remote windows died mid-session
|
|
165
|
+
// and the user had to reconnect everything by hand.
|
|
166
|
+
async _readopt() {
|
|
167
|
+
let entries = [];
|
|
168
|
+
try {
|
|
169
|
+
if (existsSync(this.activePath)) entries = JSON.parse(readFileSync(this.activePath, 'utf8'));
|
|
170
|
+
} catch { /* corrupt — treat as none */ }
|
|
171
|
+
if (!Array.isArray(entries) || entries.length === 0) return;
|
|
172
|
+
|
|
173
|
+
let adopted = 0;
|
|
174
|
+
for (const e of entries) {
|
|
175
|
+
if (!e?.id || !e.pid || !e.localPort || !this.saved.has(e.id)) continue;
|
|
176
|
+
// Only re-adopt what is provably OUR ssh still doing THIS job: the pid
|
|
177
|
+
// must be alive, be an ssh process forwarding this port, and the port
|
|
178
|
+
// must serve HTTP.
|
|
179
|
+
let alive = false;
|
|
180
|
+
try { process.kill(e.pid, 0); alive = true; } catch { /* gone */ }
|
|
181
|
+
if (alive) alive = this._looksLikeOurSsh(e.pid, e.localPort);
|
|
182
|
+
if (alive && await this._tunnelResponds(e.localPort)) {
|
|
183
|
+
this.active.set(e.id, {
|
|
184
|
+
pid: e.pid,
|
|
185
|
+
localPort: e.localPort,
|
|
186
|
+
startedAt: e.startedAt || new Date().toISOString(),
|
|
187
|
+
lastPing: Date.now(),
|
|
188
|
+
latencyMs: null,
|
|
189
|
+
healthy: true,
|
|
190
|
+
failCount: 0,
|
|
191
|
+
});
|
|
192
|
+
adopted++;
|
|
193
|
+
const name = this.saved.get(e.id)?.name || e.id;
|
|
194
|
+
console.log(`[Groove:Tunnel] Re-adopted live tunnel to ${name} on port ${e.localPort}`);
|
|
195
|
+
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)}` } });
|
|
196
|
+
} else if (alive) {
|
|
197
|
+
// ssh survives but doesn't serve — a corpse from before the restart.
|
|
198
|
+
try { process.kill(e.pid, 'SIGTERM'); } catch { /* gone */ }
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (adopted > 0 && !this._healthInterval) {
|
|
202
|
+
this._healthInterval = setInterval(() => this._healthCheckAll(), HEALTH_INTERVAL);
|
|
203
|
+
}
|
|
204
|
+
this._saveActive();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Identity check for re-adoption: is this pid an ssh forwarding this port?
|
|
208
|
+
// Guards against pid recycling handing us an unrelated process.
|
|
209
|
+
_looksLikeOurSsh(pid, localPort) {
|
|
210
|
+
try {
|
|
211
|
+
const cmd = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
|
|
212
|
+
encoding: 'utf8', timeout: 3000,
|
|
213
|
+
}).trim();
|
|
214
|
+
return cmd.includes('ssh') && cmd.includes(String(localPort));
|
|
215
|
+
} catch { return false; }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
_saveActive() {
|
|
219
|
+
try {
|
|
220
|
+
const entries = [...this.active.entries()].map(([id, c]) => ({
|
|
221
|
+
id, pid: c.pid, localPort: c.localPort, startedAt: c.startedAt,
|
|
222
|
+
}));
|
|
223
|
+
writeFileSync(this.activePath, JSON.stringify(entries, null, 2), { mode: 0o600 });
|
|
224
|
+
} catch { /* best effort */ }
|
|
225
|
+
}
|
|
226
|
+
|
|
102
227
|
getSaved() {
|
|
103
228
|
return Array.from(this.saved.values()).map(s => ({
|
|
104
229
|
...this._sanitize(s),
|
|
@@ -228,7 +353,7 @@ export class TunnelManager {
|
|
|
228
353
|
const config = this.saved.get(id);
|
|
229
354
|
if (!config) throw new Error(`Remote ${id} not found`);
|
|
230
355
|
|
|
231
|
-
const target = `${config.user}@${config.host}`;
|
|
356
|
+
const target = `${config.user}@${await resolveBestAddress(config.host, config.port || 22)}`;
|
|
232
357
|
const keyArgs = config.sshKeyPath ? ['-i', config.sshKeyPath] : [];
|
|
233
358
|
|
|
234
359
|
try {
|
|
@@ -344,18 +469,28 @@ export class TunnelManager {
|
|
|
344
469
|
} else {
|
|
345
470
|
localPort = await this._findAvailablePort();
|
|
346
471
|
}
|
|
347
|
-
|
|
472
|
+
// Multi-homed hosts (mDNS names especially): pick the address that actually
|
|
473
|
+
// answers fastest instead of letting the resolver gamble on an interface.
|
|
474
|
+
const connectHost = await resolveBestAddress(config.host, config.port || 22);
|
|
475
|
+
if (connectHost !== config.host) {
|
|
476
|
+
console.log(`[Groove:Tunnel] ${config.name}: ${config.host} → ${connectHost} (fastest responding address)`);
|
|
477
|
+
}
|
|
478
|
+
const target = `${config.user}@${connectHost}`;
|
|
348
479
|
const keyArgs = config.sshKeyPath ? ['-i', config.sshKeyPath] : [];
|
|
480
|
+
// Keep the host key pinned to the NAME when we connect by address, so every
|
|
481
|
+
// address of the same box shares one known_hosts entry.
|
|
482
|
+
const aliasArgs = connectHost !== config.host ? ['-o', `HostKeyAlias=${config.host}`] : [];
|
|
349
483
|
|
|
350
484
|
const sshArgs = [
|
|
351
485
|
'-N',
|
|
352
486
|
'-L', `127.0.0.1:${localPort}:localhost:${REMOTE_PORT}`,
|
|
353
487
|
'-p', String(config.port || 22),
|
|
354
|
-
'-o', 'ServerAliveInterval=
|
|
355
|
-
'-o', 'ServerAliveCountMax=
|
|
488
|
+
'-o', 'ServerAliveInterval=15',
|
|
489
|
+
'-o', 'ServerAliveCountMax=4',
|
|
356
490
|
'-o', 'ExitOnForwardFailure=yes',
|
|
357
491
|
'-o', 'StrictHostKeyChecking=accept-new',
|
|
358
492
|
'-o', 'GSSAPIAuthentication=no',
|
|
493
|
+
...aliasArgs,
|
|
359
494
|
...keyArgs,
|
|
360
495
|
target,
|
|
361
496
|
];
|
|
@@ -406,6 +541,7 @@ export class TunnelManager {
|
|
|
406
541
|
healthy: true,
|
|
407
542
|
failCount: 0,
|
|
408
543
|
});
|
|
544
|
+
this._saveActive();
|
|
409
545
|
|
|
410
546
|
// Verify daemon is reachable through tunnel, start if needed
|
|
411
547
|
let remoteAlive = false;
|
|
@@ -496,6 +632,7 @@ export class TunnelManager {
|
|
|
496
632
|
if (localPort) await this._waitForPortFree(localPort, 3000);
|
|
497
633
|
|
|
498
634
|
this.active.delete(id);
|
|
635
|
+
this._saveActive();
|
|
499
636
|
|
|
500
637
|
const config = this.saved.get(id);
|
|
501
638
|
this.daemon.audit.log('tunnel.disconnect', { id, name: config?.name });
|
|
@@ -559,7 +696,7 @@ export class TunnelManager {
|
|
|
559
696
|
}
|
|
560
697
|
|
|
561
698
|
// Restart remote daemon — fire and forget the SSH, verify through the tunnel
|
|
562
|
-
const cdPrefix = config.projectDir ? `cd "${config.projectDir}" && ` :
|
|
699
|
+
const cdPrefix = config.projectDir ? `cd "${config.projectDir}" && ` : ANCHOR_CD;
|
|
563
700
|
try {
|
|
564
701
|
execFileSync('ssh', [...sshBase, sshCmd(`kill $(lsof -t -i:${REMOTE_PORT}) 2>/dev/null || true; sleep 1; ${cdPrefix}GROOVE_BIN=$(which groove) && nohup "$GROOVE_BIN" start > /tmp/groove-daemon.log 2>&1 < /dev/null & disown`)], {
|
|
565
702
|
encoding: 'utf8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
@@ -666,7 +803,7 @@ export class TunnelManager {
|
|
|
666
803
|
const config = this.saved.get(id);
|
|
667
804
|
if (!config) throw new Error(`Remote ${id} not found`);
|
|
668
805
|
|
|
669
|
-
const target = `${config.user}@${config.host}`;
|
|
806
|
+
const target = `${config.user}@${await resolveBestAddress(config.host, config.port || 22)}`;
|
|
670
807
|
const keyArgs = config.sshKeyPath ? ['-i', config.sshKeyPath] : [];
|
|
671
808
|
|
|
672
809
|
// Build the remote bash command:
|
|
@@ -676,7 +813,7 @@ export class TunnelManager {
|
|
|
676
813
|
// 4. explicitly POST /api/project-dir so the daemon's projectDir matches
|
|
677
814
|
// config.projectDir even if the backgrounded cwd didn't stick (this
|
|
678
815
|
// also updates the editor root used for /api/browse, /api/files/*)
|
|
679
|
-
const cdPrefix = config.projectDir ? `cd "${config.projectDir}" && ` :
|
|
816
|
+
const cdPrefix = config.projectDir ? `cd "${config.projectDir}" && ` : ANCHOR_CD;
|
|
680
817
|
const setProjectDir = config.projectDir
|
|
681
818
|
? `curl -sf -X POST -H 'Content-Type: application/json' --data '{"path":"${config.projectDir}"}' http://localhost:${REMOTE_PORT}/api/project-dir > /dev/null 2>&1 || true; `
|
|
682
819
|
: '';
|
|
@@ -798,7 +935,7 @@ export class TunnelManager {
|
|
|
798
935
|
try {
|
|
799
936
|
const result = execFileSync('ssh', [
|
|
800
937
|
...sshBase,
|
|
801
|
-
remoteCmd(
|
|
938
|
+
remoteCmd(`${ANCHOR_CD}GROOVE_BIN=$(which groove) && nohup "$GROOVE_BIN" start > /tmp/groove-daemon.log 2>&1 < /dev/null & disown; sleep 5; curl -sf http://localhost:${REMOTE_PORT}/api/health > /dev/null && echo __DAEMON_OK__ || (echo __DAEMON_FAIL__; tail -20 /tmp/groove-daemon.log 2>/dev/null)`),
|
|
802
939
|
], {
|
|
803
940
|
encoding: 'utf8',
|
|
804
941
|
timeout: 45000,
|
|
@@ -868,7 +1005,7 @@ export class TunnelManager {
|
|
|
868
1005
|
}).trim();
|
|
869
1006
|
const installedVer = verOutput.replace(/[^0-9.]/g, '') || verOutput.trim();
|
|
870
1007
|
|
|
871
|
-
const restartCmd = `kill $(lsof -t -i:${REMOTE_PORT}) 2>/dev/null || true; sleep 2; GROOVE_BIN=$(which groove) && nohup "$GROOVE_BIN" start > /tmp/groove-daemon.log 2>&1 < /dev/null & disown; sleep 4; curl -sf http://localhost:${REMOTE_PORT}/api/status`;
|
|
1008
|
+
const restartCmd = `kill $(lsof -t -i:${REMOTE_PORT}) 2>/dev/null || true; sleep 2; ${ANCHOR_CD}GROOVE_BIN=$(which groove) && nohup "$GROOVE_BIN" start > /tmp/groove-daemon.log 2>&1 < /dev/null & disown; sleep 4; curl -sf http://localhost:${REMOTE_PORT}/api/status`;
|
|
872
1009
|
const restartResult = execFileSync('ssh', [...sshBase, sshCmd(restartCmd)], {
|
|
873
1010
|
encoding: 'utf8',
|
|
874
1011
|
timeout: 60000,
|
|
@@ -972,6 +1109,12 @@ export class TunnelManager {
|
|
|
972
1109
|
conn.failCount = 0;
|
|
973
1110
|
conn.healthy = true;
|
|
974
1111
|
conn._wedgedStreak = 0;
|
|
1112
|
+
} else if (verdict === 'remote-down') {
|
|
1113
|
+
// The tunnel is carrying traffic correctly — the far daemon is what
|
|
1114
|
+
// died (typically mid-upgrade). Rebuild would be useless; start it.
|
|
1115
|
+
conn.healthy = false;
|
|
1116
|
+
this.daemon.broadcast({ type: 'tunnel.unhealthy', data: { id } });
|
|
1117
|
+
await this._startRemoteDaemon(id, conn);
|
|
975
1118
|
} else {
|
|
976
1119
|
conn.healthy = false;
|
|
977
1120
|
this.daemon.broadcast({ type: 'tunnel.unhealthy', data: { id } });
|
|
@@ -995,19 +1138,71 @@ export class TunnelManager {
|
|
|
995
1138
|
}
|
|
996
1139
|
|
|
997
1140
|
// Escalating evidence that a tunnel is actually dead, not merely slow:
|
|
998
|
-
// 'alive'
|
|
999
|
-
// 'proc-dead'
|
|
1000
|
-
// 'port-dead'
|
|
1001
|
-
// '
|
|
1141
|
+
// 'alive' — answered a long-timeout HTTP probe; leave it alone
|
|
1142
|
+
// 'proc-dead' — the ssh client process is gone
|
|
1143
|
+
// 'port-dead' — nothing is listening on the local port
|
|
1144
|
+
// 'remote-down' — the tunnel forwards fine but the REMOTE end refuses:
|
|
1145
|
+
// the probe fails fast with a connection error, not a
|
|
1146
|
+
// timeout. Killing the tunnel won't fix that — the remote
|
|
1147
|
+
// daemon needs starting (e.g. it died during an upgrade).
|
|
1148
|
+
// 'wedged' — port accepts TCP but HTTP hangs to timeout (dead forward)
|
|
1002
1149
|
async _confirmDead(conn) {
|
|
1003
|
-
|
|
1150
|
+
const started = Date.now();
|
|
1151
|
+
let probeErr = null;
|
|
1152
|
+
try {
|
|
1153
|
+
const res = await fetch(`http://localhost:${conn.localPort}/api/health`, {
|
|
1154
|
+
signal: AbortSignal.timeout(this.confirmTimeout ?? CONFIRM_TIMEOUT),
|
|
1155
|
+
});
|
|
1156
|
+
if (res.ok) return 'alive';
|
|
1157
|
+
} catch (err) { probeErr = err; }
|
|
1158
|
+
|
|
1004
1159
|
if (conn.pid) {
|
|
1005
1160
|
try { process.kill(conn.pid, 0); } catch { return 'proc-dead'; }
|
|
1006
1161
|
}
|
|
1007
1162
|
if (!(await this._isPortInUse(conn.localPort))) return 'port-dead';
|
|
1163
|
+
|
|
1164
|
+
// ssh is alive and its port listens. A hang (timeout) means the forward is
|
|
1165
|
+
// dead; a FAST connection-level error means ssh relayed the remote side's
|
|
1166
|
+
// refusal — the tunnel works, the far daemon doesn't.
|
|
1167
|
+
const failedFast = Date.now() - started < 2000;
|
|
1168
|
+
const timedOut = probeErr && (probeErr.name === 'TimeoutError' || probeErr.name === 'AbortError');
|
|
1169
|
+
if (failedFast && !timedOut) return 'remote-down';
|
|
1008
1170
|
return 'wedged';
|
|
1009
1171
|
}
|
|
1010
1172
|
|
|
1173
|
+
// The tunnel is healthy; the daemon on the far side is what's down. Start it
|
|
1174
|
+
// over ssh rather than pointlessly rebuilding the tunnel. Rate-limited: if
|
|
1175
|
+
// the remote daemon won't stay up, repeated starts won't save it.
|
|
1176
|
+
async _startRemoteDaemon(id, conn) {
|
|
1177
|
+
this._remoteStartAt = this._remoteStartAt || new Map();
|
|
1178
|
+
const last = this._remoteStartAt.get(id) || 0;
|
|
1179
|
+
if (Date.now() - last < REMOTE_START_COOLDOWN_MS) return;
|
|
1180
|
+
this._remoteStartAt.set(id, Date.now());
|
|
1181
|
+
|
|
1182
|
+
console.log(`[Groove:Tunnel] ${id}: tunnel is fine but the remote daemon is down — starting it`);
|
|
1183
|
+
this.daemon.audit.log('tunnel.remote-daemon-start', { id });
|
|
1184
|
+
this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'starting' } });
|
|
1185
|
+
try {
|
|
1186
|
+
await this.autoStart(id);
|
|
1187
|
+
// Confirm it came up; a success resets the failure counters immediately
|
|
1188
|
+
// instead of waiting out another health cycle.
|
|
1189
|
+
for (let i = 0; i < 10; i++) {
|
|
1190
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
1191
|
+
if (await this._tunnelResponds(conn.localPort)) {
|
|
1192
|
+
conn.failCount = 0;
|
|
1193
|
+
conn.healthy = true;
|
|
1194
|
+
conn._wedgedStreak = 0;
|
|
1195
|
+
console.log(`[Groove:Tunnel] ${id}: remote daemon is back`);
|
|
1196
|
+
this.daemon.broadcast({ type: 'tunnel.health', data: { id, latencyMs: conn.latencyMs, healthy: true } });
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
console.warn(`[Groove:Tunnel] ${id}: remote daemon did not come back after start`);
|
|
1201
|
+
} catch (err) {
|
|
1202
|
+
console.warn(`[Groove:Tunnel] ${id}: could not start remote daemon: ${err.message}`);
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1011
1206
|
// Tear down a confirmed-dead tunnel and immediately rebuild it on the SAME
|
|
1012
1207
|
// local port. The remote GUI window points at that port and its WebSocket
|
|
1013
1208
|
// retries every 2s, so a same-port rebuild heals an open window without the
|
|
@@ -1075,25 +1270,17 @@ export class TunnelManager {
|
|
|
1075
1270
|
throw new Error(`No available local port found (tried ${DEFAULT_LOCAL_PORT}-${DEFAULT_LOCAL_PORT + MAX_PORT_ATTEMPTS - 1})`);
|
|
1076
1271
|
}
|
|
1077
1272
|
|
|
1273
|
+
// Deliberately does NOT kill the ssh processes. They are spawned detached and
|
|
1274
|
+
// are the user's live sessions: killing them on every daemon restart (app
|
|
1275
|
+
// upgrade, promote, crash) is what nuked remote windows mid-session. State is
|
|
1276
|
+
// persisted; the next daemon re-adopts whatever is still alive and serving.
|
|
1277
|
+
// Explicit disconnect()/delete() remain the paths that actually kill a tunnel.
|
|
1078
1278
|
shutdown() {
|
|
1079
1279
|
if (this._healthInterval) {
|
|
1080
1280
|
clearInterval(this._healthInterval);
|
|
1081
1281
|
this._healthInterval = null;
|
|
1082
1282
|
}
|
|
1083
|
-
|
|
1084
|
-
try {
|
|
1085
|
-
const conn = this.active.get(id);
|
|
1086
|
-
if (conn?.pid) {
|
|
1087
|
-
const cmd = execFileSync('ps', ['-p', String(conn.pid), '-o', 'command='], {
|
|
1088
|
-
encoding: 'utf8',
|
|
1089
|
-
timeout: 3000,
|
|
1090
|
-
}).trim();
|
|
1091
|
-
if (cmd.includes('ssh')) {
|
|
1092
|
-
process.kill(conn.pid, 'SIGTERM');
|
|
1093
|
-
}
|
|
1094
|
-
}
|
|
1095
|
-
} catch { /* ignore */ }
|
|
1096
|
-
}
|
|
1283
|
+
this._saveActive();
|
|
1097
1284
|
this.active.clear();
|
|
1098
1285
|
}
|
|
1099
1286
|
}
|