From 8707ec3ebcaf3d8da7de21b82922b14077639a81 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:54:43 +0000 Subject: [PATCH] fix(tui): port upstream failed RPC request reply (anomalyco/opencode#54936) --- packages/opencode/src/util/rpc.ts | 47 ++++++++++++++++++------- packages/opencode/test/util/rpc.test.ts | 42 ++++++++++++++++++++++ 2 files changed, 77 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/util/rpc.ts b/packages/opencode/src/util/rpc.ts index 02586ebcfc..27b239c5cd 100644 --- a/packages/opencode/src/util/rpc.ts +++ b/packages/opencode/src/util/rpc.ts @@ -1,3 +1,7 @@ +// altimate_change start — upstream_fix: answer a failed RPC request instead of hanging the caller (anomalyco/opencode#54936) +import { errorMessage } from "@/util/error" +// altimate_change end + type Definition = { [method: string]: (input: any) => any } @@ -5,13 +9,26 @@ type Definition = { export function listen(rpc: Definition) { onmessage = async (evt) => { const parsed = JSON.parse(evt.data) - if (parsed.type === "rpc.request") { - const result = await rpc[parsed.method](parsed.input) - postMessage(JSON.stringify({ type: "rpc.result", result, id: parsed.id })) - } + // altimate_change start — upstream_fix: answer a failed RPC request instead of hanging the caller (anomalyco/opencode#54936) + if (parsed.type !== "rpc.request") return + // A request that throws must still be answered, or its caller waits forever. + const reply = await Promise.resolve() + .then(() => rpc[parsed.method](parsed.input)) + .then((result) => JSON.stringify({ type: "rpc.result", result, id: parsed.id })) + .catch((error) => JSON.stringify({ type: "rpc.result", error: failureMessage(error), id: parsed.id })) + postMessage(reply) + // altimate_change end } } +// altimate_change start — upstream_fix: answer a failed RPC request instead of hanging the caller (anomalyco/opencode#54936) +// Some failures keep their text on the cause: a taken --port rejects with an empty ServeError. +function failureMessage(error: unknown): string { + if (error instanceof Error && !error.message && error.cause) return failureMessage(error.cause) + return errorMessage(error) +} +// altimate_change end + export function emit(event: string, data: unknown) { postMessage(JSON.stringify({ type: "rpc.event", event, data })) } @@ -20,17 +37,21 @@ export function client(target: { postMessage: (data: string) => void | null onmessage: ((this: Worker, ev: MessageEvent) => any) | null }) { - const pending = new Map void>() + // altimate_change start — upstream_fix: answer a failed RPC request instead of hanging the caller (anomalyco/opencode#54936) + const pending = new Map void; reject: (error: Error) => void }>() + // altimate_change end const listeners = new Map void>>() let id = 0 target.onmessage = async (evt) => { const parsed = JSON.parse(evt.data) if (parsed.type === "rpc.result") { - const resolve = pending.get(parsed.id) - if (resolve) { - resolve(parsed.result) - pending.delete(parsed.id) - } + // altimate_change start — upstream_fix: answer a failed RPC request instead of hanging the caller (anomalyco/opencode#54936) + const request = pending.get(parsed.id) + if (!request) return + pending.delete(parsed.id) + if (typeof parsed.error === "string") return request.reject(new Error(parsed.error)) + return request.resolve(parsed.result) + // altimate_change end } if (parsed.type === "rpc.event") { const handlers = listeners.get(parsed.event) @@ -44,8 +65,10 @@ export function client(target: { return { call(method: Method, input: Parameters[0]): Promise> { const requestId = id++ - return new Promise((resolve) => { - pending.set(requestId, resolve) + // altimate_change start — upstream_fix: answer a failed RPC request instead of hanging the caller (anomalyco/opencode#54936) + return new Promise((resolve, reject) => { + pending.set(requestId, { resolve, reject }) + // altimate_change end target.postMessage(JSON.stringify({ type: "rpc.request", method, input, id: requestId })) }) }, diff --git a/packages/opencode/test/util/rpc.test.ts b/packages/opencode/test/util/rpc.test.ts index 77156d0355..edfdfbd0c3 100644 --- a/packages/opencode/test/util/rpc.test.ts +++ b/packages/opencode/test/util/rpc.test.ts @@ -104,3 +104,45 @@ describe("Rpc: client protocol", () => { expect(b).toEqual([1]) }) }) + +// altimate_change start — upstream_fix: answer a failed RPC request instead of hanging the caller (anomalyco/opencode#54936) +describe("Rpc: failed requests", () => { + test("client rejects a call whose result carries an error", async () => { + const target: any = { + onmessage: null, + postMessage: (data: string) => { + const parsed = JSON.parse(data) + Promise.resolve().then(() => + target.onmessage!({ data: JSON.stringify({ type: "rpc.result", error: "boom", id: parsed.id }) } as MessageEvent), + ) + }, + } + const rpc = Rpc.client<{ fail: (input: undefined) => void }>(target) + await expect(rpc.call("fail", undefined)).rejects.toThrow("boom") + }) + + test("listen answers a throwing handler with its message, falling back to the cause", async () => { + const g = globalThis as any + const previous = { onmessage: g.onmessage, postMessage: g.postMessage } + const replies: any[] = [] + g.postMessage = (data: string) => replies.push(JSON.parse(data)) + try { + Rpc.listen({ + ok: (input: number) => input + 1, + fail: async () => { + throw new Error("", { cause: new Error("port 4096 in use") }) + }, + }) + await g.onmessage({ data: JSON.stringify({ type: "rpc.request", method: "fail", input: undefined, id: 1 }) }) + await g.onmessage({ data: JSON.stringify({ type: "rpc.request", method: "ok", input: 1, id: 2 }) }) + } finally { + g.onmessage = previous.onmessage + g.postMessage = previous.postMessage + } + expect(replies).toEqual([ + { type: "rpc.result", error: "port 4096 in use", id: 1 }, + { type: "rpc.result", result: 2, id: 2 }, + ]) + }) +}) +// altimate_change end