test(integration): pace raw socket-chardev writes to stop UART RX overrun - #128
Merged
Conversation
…rrun Fixes the ~3% flake in `test_upload_to_mcuboot_recovery[mps2_an385 .serial_recovery_raw-raw]`, which failed CI on an unrelated PR (#127) and reproduced locally at 1-in-30. ## What was happening The raw transport writes a 1024 B SMP chunk as a single burst. While the server is still busy flashing the *previous* chunk, its UART RX pool overruns and the overflow is silently dropped. Nothing recovers from that: the raw protocol is `[8-byte header][header.length bytes]` with no delimiter and no CRC, so the server waits forever for a message whose tail it never received, and the client burns its whole 15 s request timeout. That predicts a bimodal latency distribution, and measuring one confirms it. Worst single request per run, across 9 runs: run 1..7 PASS worst_request_s = 0.276 .. 0.309 run 9 FAIL worst_request_s = 15.001 There is nothing in between. A 50x gap with no tail means this is not "qemu is slow" -- raising the timeout would only make the test hang longer before failing the same way. `ServerFixture.bursty_fragment_drop` already documents this mechanism for native_sim PTY serial ("no baud pacing, so a >2-fragment message written all at once is dropped"). Its claim that "Emulated (socket) and UDP fixtures are unaffected" is what is wrong: mps2_an385 is affected too, just rarely, because it also needs the server to be mid-flash-write. ## The fix, and why it is scoped to the raw transport A real UART paces the client -- bytes leave at the baud rate, so the server's RX pool drains about as fast as it fills. A socket chardev has no pacing at all, so `_PacedSocketChardev` supplies it, splitting each write and spacing the pieces by wall-clock time. Pacing every socket chardev is wrong, and measurably so. The first version of this change put the pacing in the shared `_connect_socket_chardev`, which also affected `SMPSerialTransport`; that traded one flake for another, destabilising `qemu_cortex_m0.serial_buf256` (2/15 failures, against 0/15 unpaced) -- a 16 KB target that `max_reliable_line_packets` already flags as fragile once a transaction stays open too long. The encoded transport writes one small base64 line packet at a time, which paces it well enough on its own. So the chardev class is now a parameter of `_connect_socket_chardev` and the caller names it: only `QemuSocketSerialRawTransport` binds the paced one. The encoded transport is unaffected by construction rather than by exclusion. The pause must be wall-clock. Measured, 40 runs each: unpaced 1/30, then 2/40 failures 64 B chunks + asyncio.sleep(0) 3/40 failures (no better) 64 B chunks + 1 ms sleep 0/40 failures An event-loop yield does nothing here: the guest needs real time on a real CPU, not a turn of the event loop. The chunk size is incidental; the interleaved delay is the whole fix. Splitting the chardev into `_SocketChardev` and `_PacedSocketChardev` also retires the `object.__setattr__(conn, "out_waiting", 0)` monkeypatch in favour of a plain, type-checked class attribute. `src/` is untouched. `write_timeout` also had to become non-zero: pyserial reads a zero write timeout as "non-blocking" and its socket `write()` then issues one `socket.send()` and returns that count without looping, silently dropping any remainder. That is a real latent hazard -- `send()` discards `write()`'s return value -- but it is not this flake: fixing it alone still failed 2/40. Here it is simply required for `super().write()` to put the whole chunk out. ## Verification - The original flake: 25/25, from 1-in-30. - The regression the first version caused: 25/25, from 2-in-15. - Full integration suite: 229 passed, 101 skipped, 0 failures. - mypy and pyright both clean on the changed file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JPHutchins
force-pushed
the
fix/qemu-socket-write-pacing
branch
from
August 22, 2026 01:48
1a50aa9 to
f4e5419
Compare
This was referenced Aug 22, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Warning
LLM Disclosure
This PR was authored by
claude-opus-5[1m]on behalf of @JPHutchins, who saw the integration job fail on #127, asked whether the flake could be fixed, directed that the fix land as its own PR, and — after the first version of it caused a regression — pushed back that a structural fix was wanted over monkey-patching with side effects. The pacing is confined to the test harness (src/untouched) at their direction.Caution
Force-pushed. The first version of this PR paced every socket chardev and regressed
qemu_cortex_m0.serial_buf256. It has been amended into a single commit that scopes the pacing to the raw transport. See The regression, and the scoping fix below.The flake
test_upload_to_mcuboot_recovery[mps2_an385.serial_recovery_raw-raw]failed the integration job on #127 — a change touching none of this — and reproduces locally at ~1 in 30.What was actually happening
The raw transport writes a 1024 B SMP chunk as a single burst. While the server is still busy flashing the previous chunk, its UART RX pool overruns and the overflow is silently dropped. Nothing recovers: the raw protocol is
[8-byte header][header.length bytes]with no delimiter and no CRC, so the server waits forever for a message whose tail never arrived, and the client burns its entire 15 s request timeout.That predicts a bimodal latency distribution — and measuring one confirms it. Worst single request per run:
Nothing in between. A 50× gap with no tail means this is not "qemu is slow" — raising the timeout would only make the test hang longer before failing identically.
ServerFixture.bursty_fragment_dropalready documents this mechanism for native_sim PTY serial:That last sentence is what's wrong. mps2_an385 is affected too — just rarely, because it also needs the server to be mid-flash-write.
The fix
A real UART paces the client: bytes leave at the baud rate, so the server's RX pool drains about as fast as it fills. A socket chardev has no pacing at all, so
_PacedSocketChardevsupplies it.The pause has to be wall-clock. Measured, 40 runs each:
asyncio.sleep(0)An event-loop yield does nothing: the guest needs real time on a real CPU, not a turn of the event loop. The chunk size is incidental; the interleaved delay is the whole fix.
The regression, and the scoping fix
The first version put the pacing in the shared
_connect_socket_chardev, so it also hitSMPSerialTransport. That traded one flake for another:test_max_payload_roundtrip[qemu_cortex_m0.serial_buf256]max_reliable_line_packetsalready flags that 16 KB target as fragile once a transaction stays open too long, and the encoded transport writes one small base64 line packet at a time — it paces itself well enough.So the chardev class became a parameter of
_connect_socket_chardev, and the caller names it. The encoded transport is now unaffected by construction, not by exclusion:Splitting the chardev into
_SocketChardev/_PacedSocketChardevalso retires a monkeypatch —out_waitingbecomes a plain, type-checked class attribute:A latent bug found on the way, deliberately not claimed as the cause
pyserial silently short-writes when
write_timeout=0I first blamed this, and I was wrong — fixing it alone still failed 2/40.
pyserial reads a zero
write_timeoutas "non-blocking". Its socketwrite()then issues onesocket.send()and returns that count without looping:Any remainder is silently discarded, and
_SerialTransportBase.send()discardswrite()'s return value — so a short write truncates an SMP message with no error anywhere. A genuine latent hazard worth its own issue (it belongs with #56), but not this flake. Here a non-zerowrite_timeoutis simply required forsuper().write()to put the whole chunk out.Verification
Scope
src/is untouched. A real USB CDC raw UART has no baud pacing either, so the production transport arguably wants an explicit pacing option — an API question for thescreaming-goblinwindow, filed separately rather than smuggled into a flake fix.