Split app-server JSON-RPC connection

This commit is contained in:
murashit 2026-06-15 08:29:45 +09:00
parent c4cd4b9287
commit 39cabcd99d
2 changed files with 188 additions and 126 deletions

View file

@ -38,15 +38,9 @@ import type { TurnStartResponse } from "../../generated/app-server/v2/TurnStartR
import type { TurnSteerResponse } from "../../generated/app-server/v2/TurnSteerResponse";
import type { UserInput } from "../../generated/app-server/v2/UserInput";
import { CLIENT_VERSION } from "../../constants";
import { JsonRpcClient } from "./json-rpc-client";
import { StdioAppServerTransport, type AppServerTransport, type AppServerTransportHandlers } from "./transport";
import type {
ClientRequestMethod,
ClientRequestParams,
PendingRequest,
RpcError,
RpcInboundMessage,
RpcOutboundMessage,
} from "./rpc-messages";
import type { ClientRequestMethod, ClientRequestParams, RpcOutboundMessage } from "./rpc-messages";
import type { ServerNotification } from "../../generated/app-server/ServerNotification";
import type { ServerRequest } from "../../generated/app-server/ServerRequest";
import type { JsonValue } from "../../generated/app-server/serde_json/JsonValue";
@ -56,7 +50,6 @@ import { appServerThreadGoalUpdate, type ThreadGoalUpdate } from "../protocol/th
import { appServerRuntimeSettingsPatch, type RuntimeServiceTierRequest, type RuntimeSettingsPatch } from "../protocol/thread-settings";
const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;
const MAX_SUPPRESSED_ORPHAN_RESPONSES = 256;
export interface AppServerClientHandlers {
onNotification: (notification: ServerNotification) => void;
@ -119,24 +112,6 @@ export interface AppServerStartStructuredTurnOptions {
runtime?: AppServerTurnRuntimeOverrides;
}
class AppServerRpcError extends Error {
readonly code?: number;
readonly data?: unknown;
readonly method: ClientRequestMethod;
constructor(method: ClientRequestMethod, error: RpcError) {
super(error.message || "Codex app-server request failed.");
this.name = "AppServerRpcError";
if (error.code !== undefined) this.code = error.code;
this.data = error.data;
this.method = method;
}
}
function isRpcError(value: unknown): value is RpcError {
return value !== null && typeof value === "object" && typeof (value as { message?: unknown }).message === "string";
}
interface ClientResponseByMethod {
initialize: InitializeResponse;
"config/batchWrite": ConfigWriteResponse;
@ -194,9 +169,7 @@ function appServerTurnRuntimeParams(runtime: AppServerTurnRuntimeOverrides | und
export class AppServerClient {
private lifecycle: AppServerClientLifecycleState = { kind: "disconnected" };
private nextId = 1;
private pending = new Map<RequestId, PendingRequest>();
private suppressedOrphanResponses = new Set<RequestId>();
private readonly rpc: JsonRpcClient;
constructor(
private readonly codexPath: string,
@ -204,7 +177,17 @@ export class AppServerClient {
private readonly handlers: AppServerClientHandlers,
private readonly requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
private readonly transportFactory?: AppServerTransportFactory,
) {}
) {
this.rpc = new JsonRpcClient({
requestTimeoutMs: this.requestTimeoutMs,
send: (message) => {
this.send(message);
},
onNotification: this.handlers.onNotification,
onServerRequest: this.handlers.onServerRequest,
onLog: this.handlers.onLog,
});
}
async connect(): Promise<InitializeResponse> {
if (this.activeTransport()?.isRunning()) {
@ -213,15 +196,15 @@ export class AppServerClient {
const transportHandlers: AppServerTransportHandlers = {
onLine: (line) => {
this.handleLine(line);
this.rpc.handleLine(line);
},
onLog: this.handlers.onLog,
onError: (error) => {
this.rejectAll(error);
this.rpc.rejectAll(error);
},
onExit: (code, signal) => {
this.lifecycle = { kind: "disconnected" };
this.rejectAll(new Error(`Codex app-server exited: ${String(code ?? signal ?? "unknown")}`));
this.rpc.rejectAll(new Error(`Codex app-server exited: ${String(code ?? signal ?? "unknown")}`));
this.handlers.onExit(code, signal);
},
};
@ -250,7 +233,7 @@ export class AppServerClient {
disconnect(): void {
this.activeTransport()?.stop();
this.lifecycle = { kind: "disconnected" };
this.rejectAll(new Error("Codex app-server disconnected."));
this.rpc.rejectAll(new Error("Codex app-server disconnected."));
}
isConnected(): boolean {
@ -492,11 +475,11 @@ export class AppServerClient {
}
respondToServerRequest(requestId: RequestId, result: unknown): void {
this.send({ id: requestId, result });
this.rpc.respond(requestId, result);
}
rejectServerRequest(requestId: RequestId, code: number, message: string): void {
this.send({ id: requestId, error: { code, message } });
this.rpc.reject(requestId, code, message);
}
private request<M extends TypedClientRequestMethod>(
@ -504,37 +487,11 @@ export class AppServerClient {
params: ClientRequestParams<M>,
options: { timeoutMs?: number } = {},
): Promise<ClientResponseByMethod[M]> {
const id = this.nextId++;
const promise = new Promise<ClientResponseByMethod[M]>((resolve, reject) => {
const timeout = window.setTimeout(() => {
this.pending.delete(id);
this.suppressOrphanResponse(id);
reject(new Error(`Codex app-server request timed out: ${method}`));
}, options.timeoutMs ?? this.requestTimeoutMs);
this.pending.set(id, {
method,
resolve: resolve as (value: unknown) => void,
reject,
timeout,
});
});
try {
this.send({ id, method, params } as RpcOutboundMessage);
} catch (error) {
const pending = this.pending.get(id);
if (pending) {
window.clearTimeout(pending.timeout);
this.pending.delete(id);
}
throw error;
}
return promise;
return this.rpc.request<M, ClientResponseByMethod[M]>(method, params, options);
}
private notify(message: RpcOutboundMessage): void {
this.send(message);
this.rpc.notify(message);
}
private send(message: RpcOutboundMessage): void {
@ -545,67 +502,6 @@ export class AppServerClient {
transport.send(message);
}
private handleLine(line: string): void {
if (line.trim().length === 0) return;
let message: RpcInboundMessage;
try {
message = JSON.parse(line) as RpcInboundMessage;
} catch {
this.handlers.onLog(`Invalid app-server JSON: ${line}`);
return;
}
if ("id" in message && "method" in message) {
this.handlers.onServerRequest(message);
return;
}
if ("id" in message) {
const pending = this.pending.get(message.id);
if (!pending) {
if (this.suppressedOrphanResponses.delete(message.id)) return;
this.handlers.onLog(`Orphan app-server response: ${JSON.stringify(message)}`);
return;
}
window.clearTimeout(pending.timeout);
this.pending.delete(message.id);
if ("error" in message) {
if (!isRpcError(message.error)) {
pending.reject(new Error(`Codex app-server returned an invalid error response for ${pending.method}.`));
return;
}
pending.reject(new AppServerRpcError(pending.method, message.error));
} else {
pending.resolve(message.result);
}
return;
}
if ("method" in message) {
this.handlers.onNotification(message);
}
}
private rejectAll(error: Error): void {
for (const pending of this.pending.values()) {
window.clearTimeout(pending.timeout);
pending.reject(error);
}
this.pending.clear();
this.suppressedOrphanResponses.clear();
}
private suppressOrphanResponse(id: RequestId): void {
this.suppressedOrphanResponses.add(id);
while (this.suppressedOrphanResponses.size > MAX_SUPPRESSED_ORPHAN_RESPONSES) {
for (const oldest of this.suppressedOrphanResponses) {
this.suppressedOrphanResponses.delete(oldest);
break;
}
}
}
private activeTransport(): AppServerTransport | null {
return this.lifecycle.kind === "disconnected" ? null : this.lifecycle.transport;
}

View file

@ -0,0 +1,166 @@
import type {
ClientRequestMethod,
ClientRequestParams,
PendingRequest,
RpcError,
RpcInboundMessage,
RpcOutboundMessage,
} from "./rpc-messages";
import type { ServerNotification, ServerRequest } from "./rpc-messages";
import type { RequestId } from "../../generated/app-server/RequestId";
const MAX_SUPPRESSED_ORPHAN_RESPONSES = 256;
interface RpcResponseMessage {
id: RequestId;
result?: unknown;
error?: RpcError;
}
interface JsonRpcClientHandlers {
onNotification: (notification: ServerNotification) => void;
onServerRequest: (request: ServerRequest) => void;
onLog: (message: string) => void;
}
export interface JsonRpcClientOptions extends JsonRpcClientHandlers {
requestTimeoutMs: number;
send(message: RpcOutboundMessage): void;
}
class AppServerRpcError extends Error {
readonly code?: number;
readonly data?: unknown;
readonly method: ClientRequestMethod;
constructor(method: ClientRequestMethod, error: RpcError) {
super(error.message || "Codex app-server request failed.");
this.name = "AppServerRpcError";
if (error.code !== undefined) this.code = error.code;
this.data = error.data;
this.method = method;
}
}
export class JsonRpcClient {
private nextId = 1;
private pending = new Map<RequestId, PendingRequest>();
private suppressedOrphanResponses = new Set<RequestId>();
constructor(private readonly options: JsonRpcClientOptions) {}
request<M extends ClientRequestMethod, R>(
method: M,
params: ClientRequestParams<M>,
requestOptions: { timeoutMs?: number } = {},
): Promise<R> {
const id = this.nextId++;
const promise = new Promise<R>((resolve, reject) => {
const timeout = window.setTimeout(() => {
this.pending.delete(id);
this.suppressOrphanResponse(id);
reject(new Error(`Codex app-server request timed out: ${method}`));
}, requestOptions.timeoutMs ?? this.options.requestTimeoutMs);
this.pending.set(id, {
method,
resolve: resolve as (value: unknown) => void,
reject,
timeout,
});
});
try {
this.options.send({ id, method, params } as RpcOutboundMessage);
} catch (error) {
const pending = this.pending.get(id);
if (pending) {
window.clearTimeout(pending.timeout);
this.pending.delete(id);
}
throw error;
}
return promise;
}
notify(message: RpcOutboundMessage): void {
this.options.send(message);
}
respond(requestId: RequestId, result: unknown): void {
this.options.send({ id: requestId, result });
}
reject(requestId: RequestId, code: number, message: string): void {
this.options.send({ id: requestId, error: { code, message } });
}
handleLine(line: string): void {
if (line.trim().length === 0) return;
let message: RpcInboundMessage;
try {
message = JSON.parse(line) as RpcInboundMessage;
} catch {
this.options.onLog(`Invalid app-server JSON: ${line}`);
return;
}
if ("id" in message && "method" in message) {
this.options.onServerRequest(message);
return;
}
if ("id" in message) {
this.handleResponse(message);
return;
}
if ("method" in message) {
this.options.onNotification(message);
}
}
rejectAll(error: Error): void {
for (const pending of this.pending.values()) {
window.clearTimeout(pending.timeout);
pending.reject(error);
}
this.pending.clear();
this.suppressedOrphanResponses.clear();
}
private handleResponse(message: RpcResponseMessage): void {
const pending = this.pending.get(message.id);
if (!pending) {
if (this.suppressedOrphanResponses.delete(message.id)) return;
this.options.onLog(`Orphan app-server response: ${JSON.stringify(message)}`);
return;
}
window.clearTimeout(pending.timeout);
this.pending.delete(message.id);
if ("error" in message) {
if (!isRpcError(message.error)) {
pending.reject(new Error(`Codex app-server returned an invalid error response for ${pending.method}.`));
return;
}
pending.reject(new AppServerRpcError(pending.method, message.error));
} else {
pending.resolve(message.result);
}
}
private suppressOrphanResponse(id: RequestId): void {
this.suppressedOrphanResponses.add(id);
while (this.suppressedOrphanResponses.size > MAX_SUPPRESSED_ORPHAN_RESPONSES) {
for (const oldest of this.suppressedOrphanResponses) {
this.suppressedOrphanResponses.delete(oldest);
break;
}
}
}
}
function isRpcError(value: unknown): value is RpcError {
return value !== null && typeof value === "object" && typeof (value as { message?: unknown }).message === "string";
}