Skip to content

Commit 7ecf5a7

Browse files
CyberShadowclaude
andcommitted
ae.net.jsonrpc.codec: Normalize JSON-RPC IDs for reliable correlation
Parse and re-serialize response IDs to a canonical form before using them as associative array keys. This handles peers that re-format IDs differently from how they were sent (e.g. whitespace differences in pretty-printed JSON, number format variations like 1 vs 1.0, or string encoding differences like "A" vs "\u0041"). Also change the pendingRequests key type from string to JSONFragment for semantic clarity. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1bd9a67 commit 7ecf5a7

1 file changed

Lines changed: 38 additions & 8 deletions

File tree

net/jsonrpc/codec.d

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,36 @@ import ae.utils.jsonrpc;
2727
import ae.utils.promise : Promise, all;
2828
import ae.utils.text : asText;
2929

30+
/// Normalize a JSON-RPC ID value to a canonical form for use as an
31+
/// associative array key. This handles differences in whitespace,
32+
/// number formatting (1 vs 1.0), and string encoding ("A" vs "\u0041")
33+
/// by parsing and re-serializing through a canonical JSON encoder.
34+
private JSONFragment normalizeJsonId(scope const(char)[] raw)
35+
{
36+
if (raw.length == 0)
37+
return JSONFragment.init;
38+
39+
// Skip leading whitespace
40+
size_t i = 0;
41+
while (i < raw.length && (raw[i] == ' ' || raw[i] == '\t' ||
42+
raw[i] == '\n' || raw[i] == '\r'))
43+
i++;
44+
45+
if (i >= raw.length)
46+
return JSONFragment.init;
47+
48+
switch (raw[i])
49+
{
50+
case '"':
51+
return JSONFragment(raw.jsonParse!string.toJson());
52+
case 'n':
53+
return JSONFragment("null");
54+
default:
55+
// Number: parse as long to normalize formatting
56+
return JSONFragment(raw.jsonParse!long.toJson());
57+
}
58+
}
59+
3060
// ************************************************************************
3161

3262
/// Bidirectional JSON-RPC codec.
@@ -59,7 +89,7 @@ import ae.utils.text : asText;
5989
class JsonRpcCodec
6090
{
6191
private IConnection conn;
62-
private Promise!JsonRpcResponse[string] pendingRequests;
92+
private Promise!JsonRpcResponse[JSONFragment] pendingRequests;
6393
private uint nextId = 1;
6494

6595
/// Handler for incoming requests.
@@ -79,11 +109,11 @@ class JsonRpcCodec
79109
Promise!JsonRpcResponse sendRequest(JsonRpcRequest request)
80110
{
81111
auto id = nextId++;
82-
auto idJson = id.toJson();
83-
request.id = JSONFragment(idJson);
112+
auto idFrag = JSONFragment(id.toJson());
113+
request.id = idFrag;
84114

85115
auto responsePromise = new Promise!JsonRpcResponse;
86-
pendingRequests[idJson] = responsePromise;
116+
pendingRequests[idFrag] = responsePromise;
87117
conn.send(Data(request.toJson().asBytes));
88118

89119
return responsePromise;
@@ -209,15 +239,15 @@ private:
209239
return doDisconnect(reason);
210240
}
211241

212-
auto idJson = response.id.json;
242+
auto idKey = normalizeJsonId(response.id.json);
213243

214-
if (auto pending = idJson in pendingRequests)
244+
if (auto pending = idKey in pendingRequests)
215245
{
216246
pending.fulfill(response);
217-
pendingRequests.remove(idJson);
247+
pendingRequests.remove(idKey);
218248
}
219249
else
220-
return doDisconnect("Unexpected response ID: " ~ idJson);
250+
return doDisconnect("Unexpected response ID: " ~ response.id.json);
221251
}
222252

223253
void doDisconnect(string reason) nothrow

0 commit comments

Comments
 (0)