fix(desktop): omit undefined fields from renderer IPC payloads (#50599)

This commit is contained in:
Luke Parker
2026-09-22 20:52:35 +10:00
committed by GitHub
Unverified
parent 956de96d8b
commit 8e62ad7adc
3 changed files with 67 additions and 2 deletions
@@ -5,9 +5,51 @@ import type { MessagePortMain, WebContents } from "electron"
import { Context, Effect, Layer, ManagedRuntime, Option, Queue, Schema, Stream } from "effect"
import { Rpc, RpcClient, RpcClientError, RpcGroup, RpcMessage, RpcServer } from "effect/unstable/rpc"
import { Transferable } from "effect/unstable/workers"
import { omitUndefined } from "../shared/ipc-transport"
import { FilesOpenFilePicker } from "../shared/ipc-rpc/files"
import { IpcPortHandoff, IpcServerProtocolLive } from "./ipc-transport"
describe("desktop RPC transport", () => {
test("decodes renderer payloads whose optional fields are undefined", async () => {
let received: unknown
const rpcs = RpcGroup.make(FilesOpenFilePicker)
const handlers = rpcs.toLayer({
FilesOpenFilePicker: ({ options }) =>
Effect.sync(() => {
received = options
return null
}),
})
const live = RpcServer.layer(rpcs).pipe(Layer.provide(handlers), Layer.provideMerge(IpcServerProtocolLive))
const runtime = ManagedRuntime.make(live)
const handoff = await runtime.runPromise(IpcPortHandoff)
const channel = new MessageChannel()
handoff.bind(sender(1), serverPort(channel.port1))
// What openAttachmentPickerDialog sends for the composer's attach button.
const payload = { options: { multiple: true, title: undefined, defaultPath: "C:\\project", extensions: undefined } }
// The renderer posts the wire format itself, so a present-but-undefined key reaches the JSON codec.
const rejected = await rawRequest(channel.port2, 0, payload)
expect(rejected).toMatchObject({
_tag: "Exit",
exit: { _tag: "Failure", cause: [{ _tag: "Die", defect: expect.stringContaining('["options"]["title"]') }] },
})
const accepted = await rawRequest(channel.port2, 1, omitUndefined(payload))
expect(accepted).toMatchObject({ _tag: "Exit", exit: { _tag: "Success", value: null } })
expect(received).toEqual({ multiple: true, defaultPath: "C:\\project" })
channel.port2.close()
await runtime.dispose()
})
test("omitting undefined fields leaves bytes and defined values alone", () => {
const data = new Uint8Array([0, 255, 2])
const result = omitUndefined({ data, nested: [{ keep: null, drop: undefined }], count: 0 }) as { data: Uint8Array }
expect(result).toEqual({ data, nested: [{ keep: null }], count: 0 })
expect(result.data).toBe(data)
})
test("keeps multiple renderer ports independent", async () => {
let received: unknown
const handlers = TestRpcs.toLayer(
@@ -145,6 +187,12 @@ function clientProtocol(port: MessagePort) {
)
}
function rawRequest(port: MessageChannel["port2"], id: number, payload: unknown) {
const response = new Promise<RpcMessage.FromServerEncoded>((resolve) => port.once("message", resolve))
port.postMessage({ _tag: "Request", id, tag: "FilesOpenFilePicker", payload, headers: [] })
return response
}
function sender(id: number) {
const events = new EventEmitter()
return {
+8 -2
View File
@@ -2,7 +2,7 @@ import type { Effect } from "effect"
import type { RpcMessage } from "effect/unstable/rpc"
import type { DesktopRpcClient } from "../shared/ipc-rpc"
import type { DesktopEvent } from "../shared/ipc-rpc/events"
import { IpcTransportPort } from "../shared/ipc-transport"
import { IpcTransportPort, omitUndefined } from "../shared/ipc-transport"
// The main process serves Effect's RpcServer over a MessagePort; this side speaks its wire format
// directly. Messages cross by structured clone (no serialization layer, binary stays binary), every
@@ -80,7 +80,13 @@ function request(tag: string, payload: unknown, chunk?: Pending["chunk"]) {
const id = nextId++
return new Promise<unknown>((resolve, reject) => {
pending.set(id, { resolve, reject, chunk })
const message: RpcMessage.RequestEncoded = { _tag: "Request", id, tag, payload, headers: [] }
const message: RpcMessage.RequestEncoded = {
_tag: "Request",
id,
tag,
payload: omitUndefined(payload),
headers: [],
}
void port.then((p) => p.postMessage(message))
})
}
@@ -2,6 +2,17 @@ export const IpcTransportPort = "desktop-rpc-port"
export const DragCancelEvent = "opencode:drag-cancel"
export const StorageSnapshotChannel = "desktop-storage-snapshot"
// The main process decodes RPC payloads with the JSON codec, and JSON has no undefined: an
// optional field the caller left as `undefined` decodes only when its key is absent. Structured
// clone keeps the key, so drop such fields the way JSON.stringify would, leaving bytes untouched.
export function omitUndefined(value: unknown): unknown {
if (Array.isArray(value)) return value.map(omitUndefined)
if (typeof value !== "object" || value === null || value instanceof Uint8Array) return value
return Object.fromEntries(
Object.entries(value).flatMap(([key, item]) => (item === undefined ? [] : [[key, omitUndefined(item)]])),
)
}
export type StorageSnapshot = Record<string, { items: Record<string, string>; revision: number }>
// The namespaces a window reads while its shell mounts. The preload asks for them before the page