fix(miner-server): stop racing a cancel-unsafe read against job sends - #699
fix(miner-server): stop racing a cancel-unsafe read against job sends#699Bortlesboat wants to merge 1 commit into
Conversation
`connection_handler` polled `read_message` as a `tokio::select!` branch against `job_rx.recv()`. `read_message` is two `read_exact` calls, both documented as cancel-unsafe: when the job branch won while a frame was half-read, the read future was dropped along with the bytes it had already taken off the stream, and those bytes cannot be put back. The next iteration then built a fresh `read_message` that started mid-frame, reading body bytes as a length prefix -- either `Message size N exceeds maximum 1024` / `InvalidData`, or garbage handed to `serde_json`. `connection_handler` returned `Err`, `serve_authenticated_miner` called `remove_miner`, and the in-flight seal was lost and logged as a read error rather than as a lost result, so from the operator's side it was indistinguishable from a miner that found nothing. Broadcasts happen on every block-template rebuild, so under transaction load this became a repeated silent disconnect/reconnect cycle, firing most often at new-block time. `biased` did not mitigate it: it only controls poll order. The read is polled first, returns `Pending` after consuming some bytes, and is still dropped when the job branch becomes ready. Split the two directions into `read_loop` and `write_loop` and run them as independent long-lived futures under a single outer `select!`. A job broadcast is now handled inside `write_loop` without completing it, so an in-progress read is only dropped when the connection is being torn down. Running the directions concurrently also removes the related hazard that a miner which stops reading could park the handler inside `write_message` and starve its own reads. `connection_handler` becomes generic over `AsyncRead`/`AsyncWrite` so it can be driven by an in-memory duplex in tests; production still instantiates it with quinn's `SendStream`/`RecvStream`, and callers are unchanged. Adds a regression test that delivers a `JobResult` frame in two halves with a burst of job broadcasts in between. Against the previous loop the handler dies and the seal never arrives; the test asserts it is forwarded intact and tagged with the miner id. Fixes Quantus-Network#674
n13
left a comment
There was a problem hiding this comment.
Read through the diff and the test. Summary of what this fixes, in plain terms, for anyone landing here later:
The bug. The node reads each miner message with read_message, which reads a 4-byte length first and then the body. In the current code that read runs as a select! branch racing against "send this miner a new job". When the other branch wins, the in-progress read future is dropped, and the bytes it already consumed are gone. So if the node has read the 4-byte length of an incoming JobResult and the body hasn't arrived yet, and a job broadcast fires in that gap, the next read starts in the middle of the frame, interprets body bytes as a length, fails with Message size N exceeds maximum, and the node disconnects the miner. The seal it was sending is lost.
How likely. Rare. It needs the JobResult to arrive in two pieces (length prefix in one packet, body in a later one, e.g. after a lost/retransmitted packet, or when the miner's two write_alls get packetized separately) and a job broadcast to land in that window. On a LAN this is essentially never. Over the internet with some packet loss it can happen, and the timing is the worst possible: it fires exactly when a miner has found a block, and the failure eats that block and forces a reconnect.
The fix. Reading and writing become two independent loops under one outer select!, so a read is only ever dropped when the connection itself is going away. Broadcasts are handled inside the write loop without completing it. A side benefit: a miner that stops reading can no longer park the handler inside write_message and stall its own result submission.
Verdict. Diff is correct, contained to one file, no wire-protocol change, and the duplex test reproduces the corruption against the old loop. Not required for the latency work in #700 (that one doesn't depend on this), but it's cheap insurance against a failure mode that costs a found block. Approving.
n13
left a comment
There was a problem hiding this comment.
Bot approved automatically, didn't mean to
n13
left a comment
There was a problem hiding this comment.
Reviewer model: GPT 5.6 Sol
APPROVE — no blocking findings at 3c6eee942027092b50fadddc4644a16149a887a9.
The refactor fixes the cancellation bug without changing the wire protocol: read_loop keeps each read_message alive to completion while write_loop handles job broadcasts independently, and either direction still ends the connection when it returns. The regression test covers the important interleaving by pausing a framed JobResult mid-body, delivering and draining broadcasts, then verifying the intact result and server-assigned miner ID.
Validation:
git diff --check: passedscripts/fmt.sh --all -- --check: passedSKIP_WASM_BUILD=1 cargo test --locked -p quantus-node --bins: 62 passed- Split-frame regression test: 100/100 repeated runs passed
SKIP_WASM_BUILD=1 cargo clippy --locked -p quantus-node --bins -- -D warnings: passed
No blocking findings found. GitHub reported no checks for this head at review time.
Summary
Stop polling the cancel-unsafe
read_messageas atokio::select!branch inconnection_handler, so a job broadcast can no longer discard a half-read frame and desynchronise a miner's stream.This change:
read_loopandwrite_loopselect!, soread_messageis always awaited to completionconnection_handlergeneric overAsyncRead/AsyncWriteso the loop can be driven by an in-memory duplex in testsJobResultin two halves with job broadcasts in betweenRoot cause
node/src/miner_server.rsusedread_messageas aselect!branch againstjob_rx.recv():read_messageis tworead_exactcalls:AsyncReadExt::read_exactis explicitly documented as cancel-unsafe: if it is used as aselect!branch and another branch completes first, the buffer may have been partially filled and those bytes are lost.quinn::RecvStreamimplementsAsyncRead, so the loss is from the stream itself and the bytes cannot be put back.The failure sequence:
JobResult; the length prefix (or part of the JSON body) has been consumed byread_exact, which then returnsPendingbecause the rest is still in flight.select!, the node broadcasts a new job — which happens on every block-template rebuild.job_rx.recv()completes, theread_messagefuture is dropped, and the consumed bytes are gone.read_messagethat starts mid-frame. It either reads body bytes as a length prefix (Message size N exceeds maximum 1024,InvalidData) or handsserde_jsongarbage.connection_handlerreturnsErr,serve_authenticated_minercallsremove_miner, and the miner is disconnected.The seal that was in flight is lost and logged as a read error rather than as a lost result, so from the operator's side it is indistinguishable from a miner that simply did not find anything. Under transaction load this becomes a repeated silent disconnect/reconnect cycle, most likely to fire exactly at new-block time.
biased;does not help — it only controls poll order. The read is polled first, returnsPendingafter consuming some bytes, and is still dropped when the job branch becomes ready.Proposed solution
Split send and receive so the read is never in a
select!with anything else, which is the second option suggested in #674.read_loopandwrite_loopare now separate futures, raced by one outerselect!instead of aselect!re-entered on every iteration. A job broadcast is handled insidewrite_loopwithout completing it, so an in-progress read is only ever dropped when the connection itself is being torn down — at which point the partial frame is irrelevant.This also removes the related hazard noted in the issue: previously a miner that stopped reading could park the handler inside
write_messageand stop servicing its own reads. The two directions now make progress independently.Spawned tasks were deliberately avoided — two futures under one
select!need noSend + 'staticbounds, noJoinHandleplumbing and no abort-on-drop handling, and the first direction to finish still returns its result straight out ofconnection_handlerexactly as before.biasedis kept so the read is polled first and a disconnect is still noticed promptly.Impact
JobResultthat arrives split across a job broadcast is now read intact instead of desynchronising the stream and costing the miner its seal.serve_authenticated_mineris unchanged and still callsremove_mineron return; the generic parameters are inferred at the existing call site.Verification
SKIP_WASM_BUILD=1 cargo test --locked -p quantus-node --bins: 62 passedSKIP_WASM_BUILD=1 cargo clippy --locked -p quantus-node --bins -- -D warnings: cleanscripts/fmt.sh --all -- --check: cleanThe new test
job_broadcast_does_not_corrupt_a_partially_read_resultdrives a realconnection_handlerovertokio::io::duplex: it writes the first half of a framedJobResult, lets the handler park mid-frame, sends eight job broadcasts (draining them so only the read's cancel-safety is under test), then writes the remainder and asserts the result is forwarded intact and tagged with the miner id.Confirmed to fail against the previous loop — the handler dies on the desynchronised frame and the result channel closes without ever yielding the seal:
Fixes #674