diff --git a/apps/desktop/electron/remote-lifecycle.test.ts b/apps/desktop/electron/remote-lifecycle.test.ts index b04ddf5128..44b1af36d6 100644 --- a/apps/desktop/electron/remote-lifecycle.test.ts +++ b/apps/desktop/electron/remote-lifecycle.test.ts @@ -1845,7 +1845,7 @@ test('probes run under the remote watchdog so a hung CLI cannot orphan (#110478) assert.equal(await remoteSupportsSshOwnership(helpSsh, '/x/hermes'), true) assert.ok(helpProbe.includes('kill -9'), 'ownership probe wrapped in the remote watchdog') - assert.ok(helpProbe.includes('$( ('), 'watchdog nested around the inner serve --help') + assert.ok(/\$\(.*\(.*serve --help.*\) <\/dev\/null &/.test(helpProbe), 'watchdog nested around the inner serve --help') }) test('cleanupStale escalates to SIGKILL when the backend survives the graceful wait (#91668 quit-during-active-turn)', async () => { diff --git a/apps/desktop/electron/ssh-connection.test.ts b/apps/desktop/electron/ssh-connection.test.ts index ae0f6f25c9..5d9b223c87 100644 --- a/apps/desktop/electron/ssh-connection.test.ts +++ b/apps/desktop/electron/ssh-connection.test.ts @@ -1122,4 +1122,25 @@ test('withRemoteTimeout kills a hung probe remotely instead of orphaning it (#11 const { stdout: strays } = await execFileAsync('sh', ['-c', `ps -eo args | grep "[s]leep ${hungSecs}$" || true`]) assert.equal(strays.trim(), '', 'killed probe left no orphan process') + + // … including the grandchild of a launcher that runs the CLI without exec + // (the broken-launcher class of #110478). Needs a shell with job control + // off a tty; bash has it, dash does not. + const bash = await execFileAsync('sh', ['-c', 'command -v bash || true']).then(r => r.stdout.trim()) + + if (bash) { + const grandSecs = hungSecs + 1 + const launcher = `sh -c 'sleep ${grandSecs}; echo done'` + + const err2: any = await execFileAsync(bash, ['-c', withRemoteTimeout(launcher, 1)]).then( + () => null, + e => e + ) + + assert.ok(err2 && err2.code !== 0, 'hung launcher must exit non-zero') + + const { stdout: grandStrays } = await execFileAsync('sh', ['-c', `ps -eo args | grep "[s]leep ${grandSecs}$" || true`]) + + assert.equal(grandStrays.trim(), '', 'watchdog killed the launcher’s grandchild too') + } }) diff --git a/apps/desktop/electron/ssh-connection.ts b/apps/desktop/electron/ssh-connection.ts index c4d3469020..0af6437f82 100644 --- a/apps/desktop/electron/ssh-connection.ts +++ b/apps/desktop/electron/ssh-connection.ts @@ -348,9 +348,13 @@ function buildInteractiveSshArgs(conn, remoteCwd, connectTimeoutMs?, remoteComma function withRemoteTimeout(remoteCommand, timeoutSecs = REMOTE_PROBE_TIMEOUT_SECS) { const secs = Number.isFinite(timeoutSecs) && timeoutSecs > 0 ? Math.floor(timeoutSecs) : REMOTE_PROBE_TIMEOUT_SECS + // Job control (`set -m`) puts the probe in its own process group so the + // watchdog can also reach a grandchild left behind by a launcher that runs + // the CLI without exec. Shells that cannot enable it without a tty fall + // back to killing the direct child. return ( - `(${remoteCommand}) /dev/null 2>&1; kill -9 $__htp 2>/dev/null) & __htw=$!; ` + + `set -m 2>/dev/null; (${remoteCommand}) /dev/null; ` + + `(sleep ${secs} /dev/null 2>&1; kill -9 -- -$__htp 2>/dev/null; kill -9 $__htp 2>/dev/null) & __htw=$!; ` + `wait $__htp; __htrc=$?; ` + `kill $__htw 2>/dev/null; wait $__htw 2>/dev/null; ` + `exit $__htrc`