From cf539e1d176e15a9f4b260c3370c642e865df3fc Mon Sep 17 00:00:00 2001 From: Antisophy <293439221+Antisophy@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:18:48 -0700 Subject: [PATCH] ae.utils.jsonrpc: Ignore unknown members in protocol objects JSON-RPC peers add members to the objects they send as their protocols evolve. The JSON-RPC 2.0 specification enumerates the members of the request, response and error objects, but does not require an implementation to reject additional ones, so a codec that rejects them cannot interoperate across peer versions. Rejecting them is also disproportionately severe here: deserialization throws, and JsonRpcCodec.processRequests translates that into assert(false), which is a halt instruction in a release build. One unrecognized member from a peer therefore terminates the process, and without a message. A real instance: openai-codex 0.145.0 emits an app-server notification carrying a top-level "emittedAtMs" member, which halts any ae-based client that reads it. Annotate the three peer-deserialized structs with @JSONPartial so that unknown members are drained instead. The accompanying test in ae.net.jsonrpc.codec feeds such a request through the codec's read handler, single and batched, and asserts that both dispatch without disconnecting; without the annotations it fails at the assert(false). --- net/jsonrpc/codec.d | 53 +++++++++++++++++++++++++++++++++++++++++++++ utils/jsonrpc.d | 3 +++ 2 files changed, 56 insertions(+) diff --git a/net/jsonrpc/codec.d b/net/jsonrpc/codec.d index 037d9587..29682a78 100644 --- a/net/jsonrpc/codec.d +++ b/net/jsonrpc/codec.d @@ -20,6 +20,7 @@ module ae.net.jsonrpc.codec; import std.exception : assumeUnique; import ae.net.asockets : IConnection, DisconnectType; +version(unittest) import ae.net.asockets : ConnectionState; import ae.sys.data : Data; import ae.utils.array : asBytes; import ae.utils.serialization.json; @@ -224,6 +225,58 @@ private: } } +debug(ae_unittest) unittest +{ + import ae.utils.promise : resolve; + + // A peer may add members to the request object as its protocol + // evolves. JSON-RPC 2.0 specifies the member set but does not oblige + // implementations to reject extra members, and a codec that fails on + // them cannot interoperate across peer versions. + static class MockConnection : IConnection + { + ReadDataHandler readHandler; + string disconnectReason; + bool disconnected; + + @property ConnectionState state() { return ConnectionState.connected; } + + void send(scope Data[] data, int priority = DEFAULT_PRIORITY) {} + alias send = IConnection.send; + + void disconnect(string reason = defaultDisconnectReason, DisconnectType type = DisconnectType.requested) + { + disconnected = true; + disconnectReason = reason; + } + @property void handleConnect(ConnectHandler value) {} + @property void handleReadData(ReadDataHandler value) { readHandler = value; } + @property void handleDisconnect(DisconnectHandler value) {} + @property void handleBufferFlushed(BufferFlushedHandler value) {} + } + + string[] dispatched; + auto conn = new MockConnection; + auto codec = new JsonRpcCodec(conn); + codec.handleRequest = (JsonRpcRequest request) { + dispatched ~= request.method; + return resolve(JsonRpcResponse.success(request.id, 0)); + }; + + // Unknown top-level member, and no "jsonrpc" member at all. + conn.readHandler(Data( + `{"method":"peer/notify","params":{},"emittedAtMs":1}`.asBytes)); + assert(dispatched == ["peer/notify"], dispatched.length ? dispatched[0] : "(none)"); + assert(!conn.disconnected, conn.disconnectReason); + + // Same, in a batch. + dispatched = null; + conn.readHandler(Data( + `[{"method":"a","extra":1},{"method":"b","extra":2}]`.asBytes)); + assert(dispatched == ["a", "b"]); + assert(!conn.disconnected, conn.disconnectReason); +} + /// Convenience alias for server-only usage. alias JsonRpcServerCodec = JsonRpcCodec; diff --git a/utils/jsonrpc.d b/utils/jsonrpc.d index e717e2a6..0e4ffc61 100644 --- a/utils/jsonrpc.d +++ b/utils/jsonrpc.d @@ -70,6 +70,7 @@ string getDefaultErrorMessage(int code) // ************************************************************************ /// JSON-RPC 2.0 Error object +@JSONPartial struct JsonRpcError { /// Error code @@ -127,6 +128,7 @@ class JsonRpcException : Exception // ************************************************************************ /// JSON-RPC 2.0 Request object +@JSONPartial struct JsonRpcRequest { /// Protocol version (always "2.0") @@ -172,6 +174,7 @@ struct JsonRpcRequest } /// JSON-RPC 2.0 Response object +@JSONPartial struct JsonRpcResponse { /// Protocol version (always "2.0")