Skip to content

ae.utils.jsonrpc: Ignore unknown members in protocol objects - #60

Merged
CyberShadow merged 1 commit into
CyberShadow:masterfrom
Antisophy:fix/jsonrpc-unknown-members
Jul 24, 2026
Merged

ae.utils.jsonrpc: Ignore unknown members in protocol objects#60
CyberShadow merged 1 commit into
CyberShadow:masterfrom
Antisophy:fix/jsonrpc-unknown-members

Conversation

@Antisophy

Copy link
Copy Markdown
Contributor

JSON-RPC peers gain members in 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.

Here the rejection is also disproportionately severe. deserializeTo!JsonRpcRequest throws Unknown field <name>, and JsonRpcCodec.processRequests translates that into an assert:

catch (Exception e)
    assert(false, "Unexpected exception in processRequests: " ~ e.msg);

In a release build assert(false) is a halt instruction, so one unrecognized member from a peer terminates the whole process, and without a message. For contrast, processMessage two functions above handles the same class of failure with doDisconnect("Malformed message: " ~ e.msg).

Trigger seen in the wild

openai-codex 0.145.0 emits an app-server notification of this shape (host-specific values elided):

{"method":"remoteControl/status/changed",
 "params":{"status":"disabled","serverName":"...",
           "installationId":"...","environmentId":null},
 "emittedAtMs":1784741558897}

The top-level emittedAtMs member is not part of the JSON-RPC request object. An ae-based client reading that notification dies with SIGILL (si_code: ILL_ILLOPN) the moment it arrives, and since the halt carries no message, nothing is logged to explain it.

The same notification also omits "jsonrpc":"2.0", which the specification does require. That part is already harmless: the field simply defaults to JSONRPC_VERSION, and is not validated.

The change

@JSONPartial on JsonRpcRequest, JsonRpcResponse and JsonRpcError, the three structs deserialized from peer input, so unknown members are drained rather than throwing.

Test

A debug(ae_unittest) unittest in ae.net.jsonrpc.codec drives the codec through its connection's read handler with a request carrying an unknown member, both single and batched, and asserts that both dispatch without the connection being disconnected.

Removing the three annotations makes that test fail at the assert(false) in processRequests, so it covers exactly the path described above rather than just the deserializer.

dub test --debug=ae_unittest: 146 modules pass.

Scope

This only stops the codec from rejecting such messages. It deliberately leaves the assert(false) alone, since that is a separate concern: a genuinely undeserializable request still halts the process instead of dropping the one connection. Happy to follow up on that separately if you want it changed.

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).
@CyberShadow
CyberShadow merged commit 0291293 into CyberShadow:master Jul 24, 2026
15 of 16 checks passed
@CyberShadow

Copy link
Copy Markdown
Owner

Thanks!

@CyberShadow

Copy link
Copy Markdown
Owner

CyDo bumped: CyberShadow/CyDo@013f08b

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants