Files
oh-my-pi/packages/coding-agent/test/rpc-client.restart.test.ts
T
roboomp eb4d2a9e93 fix(rpc): dispatch bash concurrently and reset abort controller on restart
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
2026-07-01 07:15:09 +00:00

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);
});