Compare commits

...
Author SHA1 Message Date
netcatty-bot 1cfe2c6a23 fix(#3459): automated AI fix 2026-09-20 13:36:45 +00:00
2 changed files with 102 additions and 0 deletions

No files matched your search

+41
View File
@@ -215,18 +215,59 @@ function executeBoundedSshCommand(sshClient, command, options = {}) {
null,
{ terminate: true },
);
// A well-behaved sshd closes the channel right after the command
// exits, and the ssh2 client answers that CHANNEL_CLOSE, so the
// channel never outlives the command on a direct connection.
// Some relays (JumpServer-style) open a full downstream SSH
// connection per exec channel but never propagate CHANNEL_CLOSE
// back upstream — the client then keeps the channel (and the
// relay's downstream session) open until the run timeout, leaking
// one `sshd: user@notty` pair per auxiliary command (#3459). The
// command is complete once the remote reports its exit status and
// both output streams have hit EOF, so settle at that point and
// hang the channel up ourselves instead of waiting for the run
// timeout. Waiting for both guards against servers that report
// exit-status while a background child still holds the pipe.
let exitSeen = false;
let exitCode = null;
let stdoutEnded = false;
let stderrEnded = !stream.stderr;
const maybeFinishAfterEof = () => {
if (exitSeen && stdoutEnded && stderrEnded) {
finish(null, exitCode, { terminate: true });
}
};
const onExit = (code) => {
exitSeen = true;
if (typeof code === "number") exitCode = code;
maybeFinishAfterEof();
};
const onStdoutEnd = () => {
stdoutEnded = true;
maybeFinishAfterEof();
};
const onStderrEnd = () => {
stderrEnded = true;
maybeFinishAfterEof();
};
cleanupStreamListeners = () => {
stream.removeListener?.("data", onStdout);
stream.removeListener?.("close", onClose);
stream.removeListener?.("error", onError);
stream.removeListener?.("exit", onExit);
stream.removeListener?.("end", onStdoutEnd);
stream.stderr?.removeListener?.("data", onStderr);
stream.stderr?.removeListener?.("error", onError);
stream.stderr?.removeListener?.("end", onStderrEnd);
};
stream.on("data", onStdout);
stream.on("close", onClose);
stream.on("error", onError);
stream.on("exit", onExit);
stream.on("end", onStdoutEnd);
stream.stderr?.on?.("data", onStderr);
stream.stderr?.on?.("error", onError);
stream.stderr?.on?.("end", onStderrEnd);
runTimer = setTimeoutFn(() => {
const timeoutError = new Error(`SSH command execution timed out after ${runTimeoutMs} ms`);
timeoutError.code = "SSH_EXEC_RUN_TIMEOUT";
+61
View File
@@ -288,3 +288,64 @@ test("bounded SSH exec preserves UTF-8 split across stream chunks", async () =>
assert.deepEqual(await result, { stdout: "你🙂", stderr: "", code: 0 });
});
test("bounded SSH exec settles on exit-status plus EOF and closes the channel itself", async () => {
const stream = createStream();
const timers = trackedTimerApi();
const sshClient = { exec(_command, next) { next(null, stream); } };
const result = executeBoundedSshCommand(sshClient, "relay exec", {
runTimeoutMs: 60_000,
...timers,
});
// Relay-style server: output, exit-status and EOF, but the channel-close
// never comes — the client must hang up on its own (#3459).
stream.emit("data", Buffer.from("out\n"));
stream.stderr.emit("data", Buffer.from("err\n"));
stream.stderr.emit("end");
stream.emit("exit", 0);
stream.emit("end");
assert.deepEqual(await result, { stdout: "out\n", stderr: "err\n", code: 0 });
assert.equal(timers.active.size, 0, "run deadline must be released");
assert.equal(stream.listenerCount("data"), 0);
assert.equal(stream.listenerCount("close"), 0);
assert.equal(stream.listenerCount("exit"), 0);
assert.equal(stream.listenerCount("end"), 0);
assert.equal(stream.stderr.listenerCount("data"), 0);
// The client answered the finished command with its own CHANNEL_CLOSE
// instead of holding the channel open until the run timeout.
assert.ok(stream.closed > 0 || stream.destroyed > 0);
assert.deepEqual(stream.signals, ["KILL"]);
});
test("bounded SSH exec does not settle before EOF when only exit-status arrives", async () => {
const stream = createStream();
const sshClient = { exec(_command, next) { next(null, stream); } };
const result = executeBoundedSshCommand(sshClient, "background child", {
runTimeoutMs: 60_000,
});
// A backgrounded child can still hold the output pipe after the shell
// reported its exit status: completion must wait for EOF (or close).
stream.emit("exit", 0);
assert.equal(stream.listenerCount("close"), 1, "must stay subscribed until EOF");
assert.equal(stream.closed, 0, "must not close the channel before EOF");
stream.stderr.emit("end");
stream.emit("end");
assert.deepEqual(await result, { stdout: "", stderr: "", code: 0 });
assert.ok(stream.closed > 0 || stream.destroyed > 0);
});
test("bounded SSH exec still honors the channel close when no exit-status is reported", async () => {
const stream = createStream();
const sshClient = { exec(_command, next) { next(null, stream); } };
const result = executeBoundedSshCommand(sshClient, "weird server", {
runTimeoutMs: 60_000,
});
stream.emit("data", Buffer.from("done"));
stream.emit("close", 7);
assert.deepEqual(await result, { stdout: "done", stderr: "", code: 7 });
});