From 5c81e99fe5ccdf774819b7ff3a2bc78a6519d730 Mon Sep 17 00:00:00 2001 From: Sway Chan Date: Tue, 14 Apr 2026 00:40:45 +0800 Subject: [PATCH] fix: exit process when ACP connection closes (#530) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Return `AgentSideConnection` from `runAcp()` instead of discarding it - Listen for `connection.closed` and call `process.exit(0)` on resolution - Fixes orphan process accumulation in oneshot mode (e.g. via ACPX) ## Problem When used in oneshot mode, `claude-agent-acp` does not exit after the client closes the stdin pipe. `process.stdin.resume()` keeps the Node.js event loop alive indefinitely. Over time, orphan processes accumulate on the host (~97MB RSS each), eventually exhausting system resources. ## Root cause `runAcp()` creates an `AgentSideConnection` but discards the reference. The `Connection.#receive()` loop detects stdin EOF and fires `abortController.abort()`, resolving `connection.closed` — but no code listens for it to trigger `process.exit()`. ## Fix 1. **`acp-agent.ts`**: `runAcp()` now returns the `AgentSideConnection` instance 2. **`index.ts`**: Awaits `connection.closed` and calls `process.exit(0)` ## Impact - **Oneshot sessions**: Process now exits cleanly when the client closes stdin (EOF) - **Persistent sessions**: Unaffected — the client keeps stdin open, so `connection.closed` does not fire until explicit disconnect ## Test plan - [x] Verified oneshot: process exits within 1s after stdin EOF - [x] Verified persistent: process remains alive while stdin is open - [x] Measured idle RSS: ~97MB → freed on exit - [x] Tested with ACPX runtime (OpenClaw gateway) in production 🤖 Generated with [Claude Code](https://claude.ai/code) --------- Co-authored-by: WayChan Co-authored-by: Claude Opus 4.6 Co-authored-by: Ben Brandt --- src/acp-agent.ts | 12 +++++++++++- src/index.ts | 14 ++++++++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 60b2f08f..86f82e60 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -991,6 +991,11 @@ export class ClaudeAcpAgent implements Agent { delete this.sessions[sessionId]; } + /** Tear down all active sessions. Called when the ACP connection closes. */ + async dispose(): Promise { + await Promise.all(Object.keys(this.sessions).map((id) => this.teardownSession(id))); + } + async unstable_closeSession(params: CloseSessionRequest): Promise { if (!this.sessions[params.sessionId]) { throw new Error("Session not found"); @@ -2285,7 +2290,12 @@ export function runAcp() { const output = nodeToWebReadable(process.stdin); const stream = ndJsonStream(input, output); - new AgentSideConnection((client) => new ClaudeAcpAgent(client), stream); + let agent!: ClaudeAcpAgent; + const connection = new AgentSideConnection((client) => { + agent = new ClaudeAcpAgent(client); + return agent; + }, stream); + return { connection, agent }; } function commonPrefixLength(a: string, b: string) { diff --git a/src/index.ts b/src/index.ts index d45d2907..88efcdff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,8 +24,18 @@ if (process.argv.includes("--cli")) { console.error("Unhandled Rejection at:", promise, "reason:", reason); }); - runAcp(); + const { connection, agent } = runAcp(); - // Keep process alive + // Exit cleanly when the ACP connection closes (e.g. stdin EOF, transport + // error). Without this, `process.stdin.resume()` keeps the event loop + // alive indefinitely, causing orphan process accumulation in oneshot mode. + connection.closed.then(async () => { + await agent.dispose().catch((err) => { + console.error("Error during cleanup:", err); + }); + process.exit(0); + }); + + // Keep process alive while connection is open process.stdin.resume(); }