Skip to content
This repository was archived by the owner on Sep 23, 2026. It is now read-only.

Commit dcdce80

Browse files
committed
Hex-escape the byte a decode error names, and drop the asyncio figures no table here carries
A DecodeError message spliced a raw input byte into the rejected ack, so a malformed frame produced an ack the Python poller could not decode as UTF-8. Both splice sites now render the byte as hex, with a test that feeds the three frames that reached it. The 5.0x and 1.2us figures appear in no table and no CSV in this repository, so the headline is now the uvloop numbers that are committed. Also: the v2 golden frame asserts the two later fields it was missing, the 84/5 percent split is gone because both inputs round to 100ns, and the second benchmark run is named as unrecorded instead of checkable.
1 parent a1dae9e commit dcdce80

8 files changed

Lines changed: 138 additions & 40 deletions

File tree

‎README.md‎

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,16 @@ against the same wire protocol, so the Python poller drives either one and the t
88
each other in the same run on the same machine.
99

1010
The Python poller drove this executor across 4400 frames with `poller_client.py` unmodified, every
11-
frame accepted and no telemetry row dropped. Executor-side `wake_recv` dropped by a factor of 2.4 to
12-
5.0 depending on the event loop and the percentile, 0.0045ms to 0.0016ms at p50 under uvloop. The
13-
caveat is that the two configurations differ by more than language: the Python baseline runs the
14-
poller and the executor on one event loop and the C++ configuration spawns a second process, so
15-
every ratio here is an upper bound on what the rewrite bought. [`RESULTS.md`](RESULTS.md) is where
16-
those numbers live and it carries the confound in full.
11+
frame accepted and no telemetry row dropped. Executor-side `wake_recv` dropped by a factor of 2.4 at
12+
p99 and 2.8 at p50 under uvloop, 0.0045ms to 0.0016ms. The caveat is that the two configurations
13+
differ by more than language: the Python baseline runs the poller and the executor on one event loop
14+
and the C++ configuration spawns a second process, so every ratio here is an upper bound on what the
15+
rewrite bought. [`RESULTS.md`](RESULTS.md) is where those numbers live and it carries the confound
16+
in full.
1717

1818
The pre-registered expectation in [`BENCHMARK.md`](BENCHMARK.md) section 6 was written before any
1919
code existed, and it was wrong in both halves. It predicted `wake_recv` would fall by roughly an
20-
order of magnitude, and 2.4x to 5.0x is not that. It predicted end-to-end `wake_send` could not move
20+
order of magnitude, and 2.4x to 2.8x is not that. It predicted end-to-end `wake_send` could not move
2121
because the span brackets the executor with Python work on both sides, and `wake_send` halved,
2222
because the span ends at `drain()` and never contained the executor at all.
2323

@@ -31,12 +31,14 @@ is a production A/B, and no number here will be presented as one.
3131

3232
The frames the C++ is tested against were produced by running the Python, not by reading it. Ten of
3333
them: five `WakeMessage` shapes, the v1 through v3 legacy frames, and both `WakeAck` statuses. The
34-
C++ suite asserts field-for-field equality against each, and the legacy three assert that every
35-
field added since arrives at its Python default, two of which are refusals rather than permissions.
36-
The rejection cases are separate, in `test_protocol.cpp` and `test_executor.cpp`: an oversize length
34+
C++ suite asserts field-for-field equality against the five message shapes and the two acks. The v1
35+
frame asserts that every field added since arrives at its Python default, two of which are refusals
36+
rather than permissions; the v2 and v3 frames assert the fields added after each of them. The
37+
rejection cases are separate, in `test_protocol.cpp` and `test_executor.cpp`: an oversize length
3738
prefix closes the connection, and a malformed body produces a `rejected` ack whose reason names the
38-
field. The reason text is this port's own, where the Python sends `str(exc)`; the poller only logs
39-
it, and [`PORT-FIDELITY.md`](PORT-FIDELITY.md) records the difference.
39+
field when the failure is a missing or unexpected one, and names the parse error otherwise. The
40+
reason text is this port's own, where the Python sends `str(exc)`; the poller only logs it, and
41+
[`PORT-FIDELITY.md`](PORT-FIDELITY.md) records the difference.
4042

4143
The encoder is byte identical to `orjson.dumps` over `WakeMessage` and `WakeAck`. That is a stricter
4244
claim than valid JSON, because `orjson` serializes a dataclass in field-declaration order, so the
@@ -77,13 +79,14 @@ n=2000 after 200 discarded warm-up iterations:
7779
| cpp-to-cpp | 0.0019 | 0.0020 | 0.0026 |
7880

7981
Those bottom two rows are one executor measured from two different clients, and they should agree,
80-
because the executor does not know what is on the other end of the socket. Adding the asyncio-driven
81-
run, the same binary reports 1.2us, 1.6us and 1.9us across three clients. The 0.7us spread is the
82-
resolution of this measurement at this scale, and any claim about `wake_recv` finer than "between 1
83-
and 2 microseconds" is reading noise.
84-
85-
Inside the C++ executor the span decomposes cleanly. `decode` is 84% of `wake_recv`, `encode_ack` is
86-
5%, and the remainder is the ack's `send`. The decoder is the only place with anything in it.
82+
because the executor does not know what is on the other end of the socket. The same binary reports
83+
1.6us and 1.9us across the two. The 0.3us spread is the resolution of this measurement at this
84+
scale, and any claim about `wake_recv` finer than "between 1 and 2 microseconds" is reading noise.
85+
86+
Inside the C++ executor, `decode` measured standalone accounts for most of `wake_recv` and
87+
`encode_ack` for a small remainder, with the ack's `send` inside the difference. Both figures are
88+
rounded to 100ns and the two are separate measurements, so no percentage split should be read off
89+
them. The decoder is the only place with anything in it.
8790

8891
The signer, benchmarked in the same session, turned out not to compare what it was built to
8992
compare. That row is 2.8x in this port's favour and it is not a language result: `cryptography`

‎RESULTS.md‎

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,9 @@ average was 1.2 at the start of the run rather than the sub-1.0 that was wanted.
2222

2323
What stands in for the reboot is a repeat. The C++ benchmark was run twice back to back and the two
2424
runs agree: `sign` within 1.3%, `roundtrip` within 3.8%, `wake_recv` within 5.3%, `decode` within
25-
12.5% on an absolute difference of 200ns. Only the first run was written to the CSV. A reader can
26-
check that agreement; nobody can check an assertion that the machine was quiet.
25+
12.5% on an absolute difference of 200ns. Only the first run was written to the CSV, so the
26+
percentages above are the only record of the second; nobody can check them, and nobody can check an
27+
assertion that the machine was quiet either.
2728

2829
## The three configurations
2930

@@ -64,17 +65,16 @@ such spans:
6465
Section 6 said: "Executor-side `wake_recv` drops by roughly an order of magnitude, and end-to-end
6566
`wake_send` barely moves."
6667

67-
**`wake_recv` did not drop by an order of magnitude.** It dropped by a factor of 2.4 to 5.0
68-
depending on the event loop and the percentile: 2.4x at p99 under uvloop, 5.0x at p99 under stock
69-
asyncio. The prediction was off by roughly half in log terms, and it was off in the direction that
70-
flattered the port.
68+
**`wake_recv` did not drop by an order of magnitude.** It dropped by a factor of 2.4 at p99 and 2.8
69+
at p50 under uvloop. The prediction was off by roughly half in log terms, and it was off in the
70+
direction that flattered the port.
7171

7272
There is a floor under this, and it is worth naming because it bounds how much better the number
73-
could get. The same C++ executor, measured three ways, reports `wake_recv` at 1.2us, 1.6us and
74-
1.9us: driven by the asyncio poller, by the uvloop poller, and by the C++ client. Those three
75-
should be identical, because the executor does not know what is on the other end of the socket.
76-
They differ by 0.7us, which is the resolution of this measurement at this scale. Any claim about
77-
`wake_recv` finer than "between 1 and 2 microseconds" is reading noise.
73+
could get. The same C++ executor, measured two ways, reports `wake_recv` at 1.6us and 1.9us: driven
74+
by the uvloop poller and by the C++ client. Those two should be identical, because the executor does
75+
not know what is on the other end of the socket. They differ by 0.3us, which is the resolution of
76+
this measurement at this scale. Any claim about `wake_recv` finer than "between 1 and 2
77+
microseconds" is reading noise.
7878

7979
**`wake_send` moved, and it should not have.** It halved at p50, 0.0257 to 0.0123 under uvloop.
8080
Section 6 predicted it would barely move, and the reasoning given was that the span brackets the
@@ -136,9 +136,11 @@ comparable to any number in the `wake_send` column. PORT-FIDELITY.md carries the
136136

137137
## What the run does support
138138

139-
Inside the C++ executor the span decomposes cleanly. `decode` at 1.6us is 84% of `wake_recv` at
140-
1.9us; `encode_ack` at 0.1us is 5%; the remainder is the ack's `send`. If the executor's half of
141-
the wire is ever worth optimizing further, the decoder is the only place with anything in it.
139+
Inside the C++ executor, `decode` measured standalone at 1.6us accounts for most of `wake_recv` at
140+
1.9us and `encode_ack` at 0.1us for a small remainder, with the ack's `send` inside the difference.
141+
Both figures are rounded to 100ns and the two are separate measurements, so no percentage split
142+
should be read off them. If the executor's half of the wire is ever worth optimizing further, the
143+
decoder is the only place with anything in it.
142144

143145
`sign` moved 0.3% across the executor swap, 0.9227 to 0.9256. That is the control working. Signing
144146
lives in `rest_client.py`, past the `dispatch()` boundary, so it is in neither executor process and
@@ -162,7 +164,7 @@ three rows in `history.csv` from before 2026-08-30 are not comparable to anythin
162164
be separated from the process separation.
163165
2. Both signers against one OpenSSL build, or the `sign` row dropped as a cross-language comparison
164166
and kept only as a per-library note.
165-
3. A measurement floor better than 0.7us if `wake_recv` is ever to be reported more precisely than
167+
3. A measurement floor better than 0.3us if `wake_recv` is ever to be reported more precisely than
166168
an order of magnitude band.
167169

168170
None of these change the protocol result, and none of them are done here.

‎include/hotpath/clock.hpp‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
// Ported from `runtime/clock.py`. The poller stamps `recv_ns` and this process measures against
44
// it, so the two must agree on which monotonic clock they mean. `clock_domain()` is the token that
55
// says so, and it has to be built the same way on both sides or every cross-process span is
6-
// silently dropped.
6+
// silently dropped. Nothing in this process consumes the token yet.
77

88
#include <cstdint>
99
#include <string>
@@ -14,7 +14,8 @@ namespace hotpath {
1414

1515
[[nodiscard]] std::int64_t wall_clock_ms() noexcept;
1616

17-
// Hostname and quantized boot epoch, as `gethostname():1787290000`. Computed once on first call.
17+
// Hostname and boot epoch floored to a 60-second bucket, as `gethostname():1787289960`. Computed
18+
// once on first call.
1819
[[nodiscard]] const std::string& clock_domain();
1920

2021
} // namespace hotpath

‎src/executor.cpp‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ void ExecutorServer::serve_connection(int fd) {
200200

201201
// Stamped after the read returns, not before it. The read blocks until a frame arrives, so
202202
// a stamp taken ahead of it measures the gap between wakes rather than the cost of one. In
203-
// the Python that mistake put `wake_recv` at 1181 ms against a sub-millisecond budget,
203+
// the Python that mistake put `wake_recv` at 1186 ms against a sub-millisecond budget,
204204
// tracking wake inter-arrival time, and the burst-shaped benchmark never showed it.
205205
const std::int64_t started_at_ms = wall_clock_ms();
206206
const std::int64_t started_ns = monotonic_ns();

‎src/json.cpp‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,15 @@ constexpr std::byte as_byte(char c) noexcept {
2626
return static_cast<std::byte>(static_cast<unsigned char>(c));
2727
}
2828

29+
// A `DecodeError` message goes back out on the wire inside a rejected ack, and the frame that
30+
// produced it is not trusted to be UTF-8. A byte lifted out of one goes out as hex, because a raw
31+
// 0xFF spliced into the reason makes the whole ack undecodable to the poller.
32+
std::string hex_byte(char c) {
33+
static constexpr std::string_view kByteHexDigits = "0123456789abcdef";
34+
const auto value = static_cast<std::size_t>(static_cast<unsigned char>(c));
35+
return std::string{kByteHexDigits[value >> 4U], kByteHexDigits[value & 0x0FU]};
36+
}
37+
2938
// `std::string_view::compare(pos, ...)` throws when `pos` is past the end, which the parser never
3039
// does but the compiler cannot know, and one potentially-throwing call is enough to cost `at_null`
3140
// its noexcept.
@@ -251,7 +260,7 @@ bool Reader::expect(char c) {
251260
}
252261
if (seen != c) {
253262
return fail(DecodeErrorCode::MalformedJson,
254-
std::string("expected '") + c + "', got '" + seen + "'");
263+
std::string("expected '") + c + "', got byte 0x" + hex_byte(seen));
255264
}
256265
++pos_;
257266
return true;
@@ -531,7 +540,8 @@ bool Reader::append_escape(std::string& out) {
531540
case 'u':
532541
return append_code_point(out);
533542
default:
534-
return fail(DecodeErrorCode::MalformedJson, std::string("invalid escape: \\") + c);
543+
return fail(DecodeErrorCode::MalformedJson,
544+
std::string("invalid escape: byte 0x") + hex_byte(c));
535545
}
536546
}
537547

‎src/json.hpp‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626

2727
namespace hotpath::json {
2828

29-
// Longest output `format_double` can produce is 25 bytes, from a 17-digit mantissa laid out in
29+
// Longest output `format_double` can produce is 24 bytes, from a 17-digit mantissa laid out in
3030
// fixed notation at the smallest exponent that still uses it.
3131
inline constexpr std::size_t kDoubleBufferSize = 32;
3232

‎tests/test_executor.cpp‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,54 @@ hotpath::WakeAck ack_from(const std::vector<std::byte>& body) {
230230
return decoded.value();
231231
}
232232

233+
// The poller calls `orjson.loads` on the whole ack frame before it reaches the `reason` it logs,
234+
// so a rejected ack has to be valid UTF-8 even when the frame that caused it was not. Strict, the
235+
// way CPython's decoder is: an overlong form is refused, and so is any code point in the surrogate
236+
// range or past U+10FFFF.
237+
bool is_valid_utf8(std::span<const std::byte> body) {
238+
std::size_t i = 0;
239+
while (i < body.size()) {
240+
const auto lead = static_cast<unsigned char>(body[i]);
241+
if (lead < 0x80U) {
242+
++i;
243+
continue;
244+
}
245+
std::size_t extra = 0;
246+
std::uint32_t code_point = 0;
247+
if ((lead & 0xE0U) == 0xC0U) {
248+
extra = 1;
249+
code_point = lead & 0x1FU;
250+
} else if ((lead & 0xF0U) == 0xE0U) {
251+
extra = 2;
252+
code_point = lead & 0x0FU;
253+
} else if ((lead & 0xF8U) == 0xF0U) {
254+
extra = 3;
255+
code_point = lead & 0x07U;
256+
} else {
257+
return false;
258+
}
259+
if (i + extra >= body.size()) {
260+
return false;
261+
}
262+
for (std::size_t k = 1; k <= extra; ++k) {
263+
const auto continuation = static_cast<unsigned char>(body[i + k]);
264+
if ((continuation & 0xC0U) != 0x80U) {
265+
return false;
266+
}
267+
code_point = (code_point << 6U) | (continuation & 0x3FU);
268+
}
269+
const bool overlong = (extra == 1 && code_point < 0x80U) ||
270+
(extra == 2 && code_point < 0x800U) ||
271+
(extra == 3 && code_point < 0x10000U);
272+
if (overlong || (code_point >= 0xD800U && code_point <= 0xDFFFU) ||
273+
code_point > 0x10FFFFU) {
274+
return false;
275+
}
276+
i += extra + 1;
277+
}
278+
return true;
279+
}
280+
233281
} // namespace
234282

235283
TEST_CASE("a wake is acked with its own correlation id and then fired", "[executor]") {
@@ -331,6 +379,37 @@ TEST_CASE("a frame that does not decode is rejected and the connection survives"
331379
CHECK(harness.fires().size() == 1);
332380
}
333381

382+
// Three frames that make the decoder build a reason string out of a byte from the frame itself.
383+
// Each byte goes out as hex, so the ack stays decodable to a poller that only wanted to log the
384+
// reason.
385+
TEST_CASE("a rejected ack is valid UTF-8 when the frame that caused it is not", "[executor]") {
386+
Harness harness("reject-utf8");
387+
const int client = connect_to(harness.socket_path());
388+
389+
const std::array<std::string, 3> bodies{
390+
std::string("{\"schema_version\":4\xff}"),
391+
std::string("{\"asset\"\xc3:1}"),
392+
std::string("{\"asset\":\"\\\xff\"}"),
393+
};
394+
395+
for (const std::string& body : bodies) {
396+
INFO(body);
397+
send_all(client, frame_of(body));
398+
const std::vector<std::byte> rejection = read_frame(client);
399+
REQUIRE_FALSE(rejection.empty());
400+
CHECK(is_valid_utf8(rejection));
401+
const hotpath::WakeAck ack = ack_from(rejection);
402+
CHECK(ack.status == hotpath::WakeAckStatus::Rejected);
403+
CHECK_FALSE(ack.reason.value_or("").empty());
404+
}
405+
406+
::close(client);
407+
harness.join_after_disconnect();
408+
409+
CHECK(harness.stats().acks_rejected == 3);
410+
CHECK(harness.fires().empty());
411+
}
412+
334413
TEST_CASE("an oversize length prefix closes the connection", "[executor]") {
335414
Harness harness("oversize");
336415
const int client = connect_to(harness.socket_path());

‎tests/test_protocol_golden.cpp‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,9 @@ TEST_CASE("a v2 frame decodes with an untradeable wire price", "[golden]") {
204204
CHECK(decoded.value().clock_domain == "host-a:1787290000");
205205
CHECK(decoded.value().wire_price_yes_dollars == 0.0);
206206
CHECK(decoded.value().exchange_index == -1);
207+
CHECK(decoded.value().available_size_contracts == 0.0);
208+
CHECK(decoded.value().price_ranges ==
209+
std::vector<hotpath::PriceRange>{hotpath::kDefaultPriceRange});
207210
CHECK(decoded.value().correlation_group.empty());
208211
}
209212

0 commit comments

Comments
 (0)