mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
fix: exit process when ACP connection closes (#530)
## 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 <dove.h14@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
This commit is contained in:
@@ -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<void> {
|
||||
await Promise.all(Object.keys(this.sessions).map((id) => this.teardownSession(id)));
|
||||
}
|
||||
|
||||
async unstable_closeSession(params: CloseSessionRequest): Promise<CloseSessionResponse> {
|
||||
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) {
|
||||
|
||||
14
src/index.ts
14
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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user