eb4d2a9e93
The RPC transport did not treat cancellation as an independent, resettable control plane, so two lifecycle contract violations shared a root cause: A. Server-side: the stdin loop in `rpc-mode.ts` awaited each commands `handleCommand` before pulling the next frame, so `abort_bash` queued behind the `bash` it must cancel. Extracted `dispatchRpcInputFrame` and dispatch `bash` in the background: the input loop keeps reading, so `abort_bash` (or any other command) can preempt an in-flight shell. Response correlation still rides `command.id`; ordering across concurrent commands is documented as not guaranteed. B. Client-side: `RpcClient.stop()` aborted the shared `#abortController` but never replaced it, so a subsequent `start()` handed a pre-aborted signal to `readJsonl` and the stdout reader exited immediately with a spurious "Agent process exited before ready" while the spawned child leaked in `#process`. Mint a fresh `AbortController` inside `start()` and clean up the child on any post-spawn failure. Adds: - `dispatchRpcInputFrame` unit tests covering the concurrent bash + abort ordering, serial dispatch of other commands, and background error reporting. - `RpcClient` lifecycle tests covering start->stop->start on the same instance (via a mock fixture agent) and retry after a failed start. - Documents the bash concurrency contract in `docs/rpc.md`. Fixes #4079
43 lines
1.6 KiB
TypeScript
43 lines
1.6 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import * as path from "node:path";
|
|
import { RpcClient } from "@oh-my-pi/pi-coding-agent/modes/rpc/rpc-client";
|
|
|
|
const MOCK_AGENT = path.join(import.meta.dir, "fixtures", "mock-rpc-agent.ts");
|
|
|
|
describe("RpcClient lifecycle (issue #4079 B)", () => {
|
|
test("start() succeeds a second time after stop() on the same instance", async () => {
|
|
using client = new RpcClient({
|
|
cliPath: MOCK_AGENT,
|
|
});
|
|
|
|
// First lifecycle: start + stop.
|
|
await client.start();
|
|
client.stop();
|
|
|
|
// Second start on the same instance must NOT reuse the aborted
|
|
// controller from the previous stop(). Before the fix, this rejected
|
|
// with "Agent process exited before ready" because the JSONL reader
|
|
// short-circuited on the pre-aborted signal.
|
|
await client.start();
|
|
client.stop();
|
|
}, 20000);
|
|
|
|
test("start() may be retried after a failed start (child is cleaned up on failure)", async () => {
|
|
using client = new RpcClient({
|
|
cliPath: path.join(import.meta.dir, "..", "src", "cli.ts"),
|
|
cwd: path.join(import.meta.dir, ".."),
|
|
provider: "__missing_provider__",
|
|
model: "claude-sonnet-4-5",
|
|
env: { PI_NO_TITLE: "1" },
|
|
});
|
|
|
|
await expect(client.start()).rejects.toThrow(/Unknown provider.*__missing_provider__/);
|
|
|
|
// Before the fix, #process stayed set after the failed spawn so the
|
|
// second start() rejected with "Client already started". Post-fix,
|
|
// state is cleared and the second attempt fails with the same
|
|
// legitimate startup error.
|
|
await expect(client.start()).rejects.toThrow(/Unknown provider.*__missing_provider__/);
|
|
}, 30000);
|
|
});
|