diff --git a/CHANGELOG.md b/CHANGELOG.md index 96474d3..8e7e3ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 8a16ef6..d3ab0ec 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/crates/runa-cli/src/main.rs b/crates/runa-cli/src/main.rs index 2824c81..654a771 100644 --- a/crates/runa-cli/src/main.rs +++ b/crates/runa-cli/src/main.rs @@ -420,7 +420,10 @@ fn run_interactive_repl(server: &mut dyn Connection) -> Result<(), Box writeln!(output, "^C")?, + Err(ReadlineError::Interrupted) => { + writeln!(output, "^C")?; + break; + } Err(ReadlineError::Eof) => { writeln!(output)?; break; diff --git a/docs/protocol.md b/docs/protocol.md index 5f35b33..6205235 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -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. diff --git a/vscode/integration/repl.test.ts b/vscode/integration/repl.test.ts index 155abc3..dd9bfc1 100644 --- a/vscode/integration/repl.test.ts +++ b/vscode/integration/repl.test.ts @@ -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 },