fix: close the REPL connection on Ctrl+C

Exit the readline loop on interruption so normal WebSocket shutdown completes. Verify Ctrl+C in a Linux pseudo-terminal against the real image server and confirm it remains available after disconnect.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-11-03 17:21:41 +01:00
parent 750b709d35
commit d0850fe6d4
5 changed files with 59 additions and 3 deletions

View File

@ -13,6 +13,8 @@ All notable changes to this project will be documented in this file.
### Fixed
- Exit the REPL on Ctrl+C and complete the WebSocket close handshake instead
of returning to the prompt.
- Complete the WebSocket close handshake when the CLI exits, preventing normal
disconnects from producing a connection-reset protocol error on the server.

View File

@ -63,7 +63,8 @@ mise run repl
The REPL defaults to `127.0.0.1:3737`. To select another loopback endpoint,
use `cargo run -p runa-cli -- --connect 127.0.0.1:4000 repl` and start the server with
the corresponding `--listen` address. Quitting the REPL disconnects only that
client; actors and source files survive until the server exits.
client; actors and source files survive until the server exits. Ctrl+C and
Ctrl+D also exit the REPL and complete the WebSocket close handshake.
Reconnect and use `ps` to recover their references. REPL bindings and
command history are session-local and are not restored on reconnect.

View File

@ -420,7 +420,10 @@ fn run_interactive_repl(server: &mut dyn Connection) -> Result<(), Box<dyn std::
break;
}
}
Err(ReadlineError::Interrupted) => writeln!(output, "^C")?,
Err(ReadlineError::Interrupted) => {
writeln!(output, "^C")?;
break;
}
Err(ReadlineError::Eof) => {
writeln!(output)?;
break;

View File

@ -154,7 +154,7 @@ the actor tree and open snapshot documents. Manual refresh remains available.
## WebSocket transport and notifications
On `quit`, `exit`, or end-of-input, the CLI completes the WebSocket close
On `quit`, `exit`, Ctrl+C, or end-of-input, the CLI completes the WebSocket close
handshake with a 5-second deadline before releasing the connection. A failed
handshake is reported rather than treating an abrupt reset as a normal close.

View File

@ -7,6 +7,56 @@ import { promisify } from "node:util";
import { ImageClient } from "../src/client";
import { startImage } from "./server";
test(
"Ctrl+C exits a terminal REPL with a clean WebSocket handshake",
{ timeout: 10000, skip: process.platform !== "linux" },
async (t) => {
const image = await startImage();
t.after(() => image.stop());
const executable = resolve(__dirname, "../../../target/debug/runa-cli");
const terminal = spawn("script", ["-q", "-e", "-c", '"$RUNA_CLI_TEST" repl', "/dev/null"], {
env: {
...process.env,
TERM: "xterm",
RUNA_CONNECT: image.address,
RUNA_CLI_TEST: executable,
},
stdio: ["pipe", "pipe", "pipe"],
});
t.after(() => {
if (terminal.exitCode === null && terminal.signalCode === null) terminal.kill();
});
let stdout = "";
let stderr = "";
let interrupted = false;
terminal.stdout.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
if (!interrupted && stdout.includes("runa> ")) {
interrupted = true;
terminal.stdin.write("\x03");
}
});
terminal.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
const [code] = await once(terminal, "exit");
assert.ok(interrupted, stdout + stderr);
assert.equal(code, 0, stdout + stderr);
assert.equal(stderr, "");
assert.match(stdout, /\^C/);
assert.doesNotMatch(stdout, /protocol error|reset without closing handshake/i);
assert.doesNotMatch(image.diagnostics(), /protocol error|reset without closing handshake/i);
const inspector = await ImageClient.connect(image.address, (error) => {
assert.equal(error, undefined);
});
try {
assert.deepEqual(await inspector.listActors(), []);
} finally {
inspector.close();
}
},
);
test(
"one-shot CLI commands share the image, report errors, and close cleanly",
{ timeout: 15000 },