fix: class RpcProcessInstance as state machine

This commit is contained in:
Cristina Poncela Cubeiro
2026-06-26 12:47:09 +02:00
parent 9505389beb
commit 2f853bbce0
2 changed files with 289 additions and 190 deletions
+135 -136
View File
@@ -16,187 +16,186 @@ interface PendingRequest {
reject(error: Error): void;
}
export interface RpcProcessInstance {
process: ChildProcess;
send(command: RpcCommand): Promise<RpcResponse>;
handleUiResponse(response: RpcExtensionUIResponse): void;
setUiRequestHandler(handler?: (request: RpcExtensionUIRequest) => void): void;
onEvent(listener: (event: AgentSessionEvent) => void): () => void;
onExit(listener: (error?: Error) => void): () => void;
dispose(): Promise<void>;
}
const require = createRequire(import.meta.url);
function getRpcSpawnCommand(): { command: string; args: string[] } {
if (isBunBinary) {
return {
command: join(dirname(process.execPath), process.platform === "win32" ? "pi.exe" : "pi"),
args: ["--mode", "rpc"],
};
}
return {
command: process.execPath,
args: [require.resolve("@earendil-works/pi-coding-agent/rpc-entry")],
};
}
function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
export function createRpcProcessInstance(options: { cwd: string }): RpcProcessInstance {
const rpcCommand = getRpcSpawnCommand();
const child = spawn(rpcCommand.command, rpcCommand.args, {
cwd: options.cwd,
env: process.env,
stdio: ["pipe", "pipe", "pipe"],
});
if (!child.stdin || !child.stdout) {
throw new Error("Failed to create RPC process stdio");
export class RpcProcessInstance {
readonly process: ChildProcess;
private exited = false;
private nextRequestId = 0;
private stdoutBuffer = "";
private stderrBuffer = "";
private readonly pendingRequests = new Map<string, PendingRequest>();
private readonly eventListeners = new Set<(event: AgentSessionEvent) => void>();
private readonly exitListeners = new Set<(error?: Error) => void>();
private uiRequestHandler: ((request: RpcExtensionUIRequest) => void) | undefined;
constructor(options: { cwd: string }) {
const rpcCommand = this.getSpawnCommand();
this.process = spawn(rpcCommand.command, rpcCommand.args, {
cwd: options.cwd,
env: process.env,
stdio: ["pipe", "pipe", "pipe"],
});
if (!this.process.stdin || !this.process.stdout) {
throw new Error("Failed to create RPC process stdio");
}
this.attachListeners();
}
let exited = false;
let nextRequestId = 0;
let stdoutBuffer = "";
let stderrBuffer = "";
const pendingRequests = new Map<string, PendingRequest>();
const eventListeners = new Set<(event: AgentSessionEvent) => void>();
const exitListeners = new Set<(error?: Error) => void>();
const rejectAllPending = (error: Error) => {
for (const [id, pending] of pendingRequests) {
pendingRequests.delete(id);
pending.reject(error);
private getSpawnCommand(): { command: string; args: string[] } {
if (isBunBinary) {
return {
command: join(dirname(process.execPath), process.platform === "win32" ? "pi.exe" : "pi"),
args: ["--mode", "rpc"],
};
}
};
return {
command: process.execPath,
args: [require.resolve("@earendil-works/pi-coding-agent/rpc-entry")],
};
}
const notifyExit = (error?: Error) => {
for (const listener of exitListeners) {
listener(error);
}
};
private attachListeners(): void {
this.process.stdout?.setEncoding("utf8");
this.process.stdout?.on("data", (chunk: string) => {
this.stdoutBuffer += chunk;
while (true) {
const newlineIndex = this.stdoutBuffer.indexOf("\n");
if (newlineIndex === -1) {
break;
}
const line = this.stdoutBuffer.slice(0, newlineIndex).trim();
this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
if (!line) {
continue;
}
this.handleLine(line);
}
});
let uiRequestHandler: ((request: RpcExtensionUIRequest) => void) | undefined;
this.process.stderr?.setEncoding("utf8");
this.process.stderr?.on("data", (chunk: string) => {
this.stderrBuffer += chunk;
});
const handleLine = (line: string) => {
this.process.once("error", (error) => {
this.exited = true;
const wrapped = new Error(`RPC process error: ${error.message}. Stderr: ${this.stderrBuffer}`);
this.rejectAllPending(wrapped);
this.notifyExit(wrapped);
});
this.process.once("exit", (code, signal) => {
this.exited = true;
const error = new Error(`RPC process exited (code=${code} signal=${signal}). Stderr: ${this.stderrBuffer}`);
this.rejectAllPending(error);
this.notifyExit(error);
});
}
private handleLine(line: string): void {
const parsed = JSON.parse(line) as { type?: string; id?: string };
switch (parsed.type) {
case "response": {
if (!parsed.id) {
return;
}
const pending = pendingRequests.get(parsed.id);
const pending = this.pendingRequests.get(parsed.id);
if (!pending) {
return;
}
pendingRequests.delete(parsed.id);
this.pendingRequests.delete(parsed.id);
pending.resolve(parsed as RpcResponse);
return;
}
case "extension_ui_request": {
uiRequestHandler?.(parsed as RpcExtensionUIRequest);
this.uiRequestHandler?.(parsed as RpcExtensionUIRequest);
return;
}
default: {
for (const listener of eventListeners) {
for (const listener of this.eventListeners) {
listener(parsed as AgentSessionEvent);
}
}
}
};
}
child.stdout.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
stdoutBuffer += chunk;
while (true) {
const newlineIndex = stdoutBuffer.indexOf("\n");
if (newlineIndex === -1) {
break;
}
const line = stdoutBuffer.slice(0, newlineIndex).trim();
stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1);
if (!line) {
continue;
}
handleLine(line);
private rejectAllPending(error: Error): void {
for (const [id, pending] of this.pendingRequests) {
this.pendingRequests.delete(id);
pending.reject(error);
}
});
}
child.stderr?.setEncoding("utf8");
child.stderr?.on("data", (chunk: string) => {
stderrBuffer += chunk;
});
child.once("error", (error) => {
exited = true;
const wrapped = new Error(`RPC process error: ${error.message}. Stderr: ${stderrBuffer}`);
rejectAllPending(wrapped);
notifyExit(wrapped);
});
child.once("exit", (code, signal) => {
exited = true;
const error = new Error(`RPC process exited (code=${code} signal=${signal}). Stderr: ${stderrBuffer}`);
rejectAllPending(error);
notifyExit(error);
});
const send = async (command: RpcCommand): Promise<RpcResponse> => {
if (exited) {
throw new Error(`RPC process is not running. Stderr: ${stderrBuffer}`);
private notifyExit(error?: Error): void {
for (const listener of this.exitListeners) {
listener(error);
}
const id = command.id ?? `orchestrator_${++nextRequestId}_${randomUUID()}`;
}
send(command: RpcCommand): Promise<RpcResponse> {
if (this.exited) {
throw new Error(`RPC process is not running. Stderr: ${this.stderrBuffer}`);
}
const id = command.id ?? `orchestrator_${++this.nextRequestId}_${randomUUID()}`;
const fullCommand = { ...command, id };
return new Promise<RpcResponse>((resolve, reject) => {
pendingRequests.set(id, { resolve, reject });
child.stdin.write(`${JSON.stringify(fullCommand)}\n`, (error) => {
this.pendingRequests.set(id, { resolve, reject });
this.process.stdin?.write(`${JSON.stringify(fullCommand)}\n`, (error) => {
if (!error) {
return;
}
pendingRequests.delete(id);
this.pendingRequests.delete(id);
reject(toError(error));
});
});
};
}
return {
process: child,
send,
handleUiResponse(response) {
if (exited) {
return;
}
child.stdin.write(`${JSON.stringify(response)}\n`);
},
setUiRequestHandler(handler) {
uiRequestHandler = handler;
},
onEvent(listener) {
eventListeners.add(listener);
return () => {
eventListeners.delete(listener);
};
},
onExit(listener) {
exitListeners.add(listener);
return () => {
exitListeners.delete(listener);
};
},
async dispose() {
uiRequestHandler = undefined;
rejectAllPending(new Error("RPC process disposed"));
if (exited) {
return;
}
child.kill("SIGTERM");
await new Promise<void>((resolve) => {
child.once("exit", () => resolve());
});
},
};
handleUiResponse(response: RpcExtensionUIResponse): void {
if (this.exited) {
return;
}
this.process.stdin?.write(`${JSON.stringify(response)}\n`);
}
setUiRequestHandler(handler?: (request: RpcExtensionUIRequest) => void): void {
this.uiRequestHandler = handler;
}
onEvent(listener: (event: AgentSessionEvent) => void): () => void {
this.eventListeners.add(listener);
return () => {
this.eventListeners.delete(listener);
};
}
onExit(listener: (error?: Error) => void): () => void {
this.exitListeners.add(listener);
return () => {
this.exitListeners.delete(listener);
};
}
async dispose(): Promise<void> {
this.uiRequestHandler = undefined;
this.rejectAllPending(new Error("RPC process disposed"));
if (this.exited) {
return;
}
this.process.kill("SIGTERM");
await new Promise<void>((resolve) => {
this.process.once("exit", () => resolve());
});
}
}
export function createRpcProcessInstance(options: { cwd: string }): RpcProcessInstance {
return new RpcProcessInstance(options);
}