Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 3d15abbc41 fix(ai): keep shell-kind probe timeouts off the terminal SSH connection
A 3s login-shell probe on the interactive client called Client.end() when
the exec channel failed to open, dropping every tab on that connection.
Preserve the terminal transport and keep teardown for dedicated Mosh/ET
helpers.

Fixes #1181

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 04:47:29 +00:00
2 changed files with 76 additions and 3 deletions

No files matched your search

+11 -3
View File
@@ -177,15 +177,21 @@ function parseRemoteWindowsLoginShellProbeOutput(stdout) {
/**
* Build an execProbe(command, timeoutMs) => Promise<string|null> from an
* ssh2-like connection (conn.exec(command, cb)).
*
* `invalidateOnOpenTimeout` defaults to true so a stuck open on a dedicated
* Mosh/ET helper connection still tears that helper down. Pass false when
* `conn` is the terminal's own SSH client (#1181).
*/
function createSshConnExecProbe(conn) {
function createSshConnExecProbe(conn, options = {}) {
if (!conn || typeof conn.exec !== "function") return null;
const invalidateOnOpenTimeout = options.invalidateOnOpenTimeout !== false;
return async function execProbe(command, timeoutMs = DEFAULT_PROBE_TIMEOUT_MS) {
try {
const result = await executeBoundedSshCommand(conn, command, {
openingTimeoutMs: timeoutMs,
runTimeoutMs: timeoutMs,
maxOutputBytes: 64 * 1024,
invalidateOnOpenTimeout,
});
// Include stderr so Windows `reg query` missing-value diagnostics
// (and any probe that only prints errors) still reach the parser.
@@ -212,9 +218,11 @@ function createSessionExecProbe(session) {
// would disconnect them before the command ever runs, so skip it and let the
// PTY live shell probe (probeLiveShell) determine the wrapper (#3146).
if (sessionDisallowsExtraSshChannel(session)) return null;
// A slow login-shell probe must not Client.end() the interactive transport.
// Dedicated Mosh/ET companions keep timeout teardown so recovery can reconnect.
return (
createSshConnExecProbe(session.conn)
|| createSshConnExecProbe(session.sshClient)
createSshConnExecProbe(session.conn, { invalidateOnOpenTimeout: false })
|| createSshConnExecProbe(session.sshClient, { invalidateOnOpenTimeout: false })
|| createSshConnExecProbe(session.moshStatsConn)
|| createSshConnExecProbe(session.etStatsConn)
|| null
@@ -775,6 +775,71 @@ test("createSshConnExecProbe closes a channel that arrives after timeout", async
assert.equal(closed, true);
});
test("terminal shell-kind probe open timeout keeps the SSH connection (#1181)", async () => {
let execCallback;
let execCalls = 0;
let endCalls = 0;
let destroyCalls = 0;
const conn = {
exec(_command, cb) {
execCalls += 1;
execCallback = cb;
},
end() { endCalls += 1; },
destroy() { destroyCalls += 1; },
};
const probe = createSessionExecProbe({ conn });
assert.equal(await probe("true", 15), null);
assert.equal(execCalls, 1);
assert.equal(endCalls, 0);
assert.equal(destroyCalls, 0);
// The hung open stays fenced until ssh2 delivers the late callback.
assert.equal(await probe("true", 1000), null);
assert.equal(execCalls, 1);
assert.equal(endCalls, 0);
assert.equal(destroyCalls, 0);
let closed = false;
const stream = {
on() { return stream; },
stderr: { on() { return this; } },
close() { closed = true; },
};
execCallback(null, stream);
assert.equal(closed, true);
assert.equal(endCalls, 0);
assert.equal(destroyCalls, 0);
});
test("sshClient shell-kind probe open timeout keeps the terminal connection (#1181)", async () => {
let ended = false;
const sshClient = {
exec() {},
end() { ended = true; },
destroy() { ended = true; },
};
const probe = createSessionExecProbe({ sshClient });
assert.equal(await probe("true", 15), null);
assert.equal(ended, false);
});
test("helper shell-kind probe open timeout still tears down Mosh and ET connections", async () => {
for (const key of ["moshStatsConn", "etStatsConn"]) {
let endCalls = 0;
let destroyCalls = 0;
const helper = {
exec() {},
end() { endCalls += 1; },
destroy() { destroyCalls += 1; },
};
const probe = createSessionExecProbe({ [key]: helper });
assert.equal(await probe("true", 15), null);
assert.equal(endCalls, 1, key);
assert.equal(destroyCalls, 1, key);
}
});
test("createSessionExecProbe prefers session.conn over companions", () => {
const session = {
conn: { exec() {} },