Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 35 additions & 12 deletions packages/opencode/src/util/rpc.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,34 @@
// 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
}

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 }))
}
Expand All @@ -20,17 +37,21 @@ export function client<T extends Definition>(target: {
postMessage: (data: string) => void | null
onmessage: ((this: Worker, ev: MessageEvent<any>) => any) | null
}) {
const pending = new Map<number, (result: any) => void>()
// altimate_change start — upstream_fix: answer a failed RPC request instead of hanging the caller (anomalyco/opencode#54936)
const pending = new Map<number, { resolve: (result: any) => void; reject: (error: Error) => void }>()
// altimate_change end
const listeners = new Map<string, Set<(data: any) => 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)
Expand All @@ -44,8 +65,10 @@ export function client<T extends Definition>(target: {
return {
call<Method extends keyof T>(method: Method, input: Parameters<T[Method]>[0]): Promise<ReturnType<T[Method]>> {
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 })
Comment on lines +68 to +70

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟠 MEDIUM] If JSON.stringify(input) throws an error (e.g., due to circular references in the input argument), the Promise executor will correctly catch it and reject the promise. However, because pending.set was already called, the requestId will remain in the pending map indefinitely. Since the request is never actually sent via postMessage, a response will never arrive to clear it, resulting in a minor memory leak for that map entry.

Consider evaluating JSON.stringify before setting the request in the pending map. If serialization fails, the error will be caught by the Promise executor before the map is mutated, thus preventing the leak:

      return new Promise((resolve, reject) => {
        const payload = JSON.stringify({ type: "rpc.request", method, input, id: requestId })
        pending.set(requestId, { resolve, reject })
        target.postMessage(payload)
      })

// altimate_change end
target.postMessage(JSON.stringify({ type: "rpc.request", method, input, id: requestId }))
})
},
Expand Down
42 changes: 42 additions & 0 deletions packages/opencode/test/util/rpc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading