fix(uci): abort the transaction on net_tcp_send timeout exits (#194) - #223
Open
JC-000 wants to merge 3 commits into
Open
fix(uci): abort the transaction on net_tcp_send timeout exits (#194)#223JC-000 wants to merge 3 commits into
JC-000 wants to merge 3 commits into
Conversation
RED BY DESIGN AT THIS COMMIT. The fix is the next one; this ordering is
deliberate so the red-green is reproducible from history rather than only
from a transcript:
git checkout <this commit> && make clean && \
make BACKEND=uci USE_NISTCURVES_ONCHIP=1 && \
python3 tools/test_uci_timeout_recovery.py # exit 1, 3/4 red
Three exits in net_tcp_send are bounded waits that can expire in the
middle of a transaction: uci_push_wait on the SOCKET_WRITE response, and
either drain on the error path or on the success path. They report the
failure honestly (C=1, net_last_error $89) but return without accepting
or aborting, so the command interface is left where the firmware put it.
The suite runs the shipped 6502 bytes out of build/c64-https.prg — the
same technique as tools/test_uci_data_acc.py, whose interpreter it
imports — against a model of $DF1B-$DF1F built from
command_protocol.vhd and command_intf.cc. That model needs three things
the DATA_ACC suite's does not: the four-state machine with its
`else error_busy <= '1'` rejection branch, a firmware that answers after
a delay so CMD_BUSY and the data phase are observable, and a CIA1 TOD
that actually advances so the 5 s bounds can expire on purpose.
Four checks: each of the two distinct leftover states (a drain timeout
leaves "10", a push timeout leaves "01"), the consequence for the next
net_poll, and a control proving a clean send neither aborts nor leaves
the phase open.
Registered in pytest.ini testpaths, which tools/test_pytest_boundary.py
requires of any pure-logic tools/test_*.py; like test_uci_data_acc.py it
needs a BACKEND=uci build and answers C64_UCI_TESTS_OPTIONAL=1.
Refs #194.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The three bounded-wait exits in net_tcp_send returned with the command interface half-way through a transaction: uci_push_wait timing out on the SOCKET_WRITE response left it in state "01", and either drain timing out left it in "10" with the data phase open. Nothing on any error path called uci_abort — it had exactly one call site, in net_init — so the only thing that cleared a mid-transaction bail was the next boot. All three now share one exit, @sb_bail, which writes ABORT and then waits for idle. The carry, the C=1 contract and net_last_error ($89 UCI_ERR_WAIT_TIMEOUT, already set by the wait that expired) are unchanged; only the state left behind for the next command changes. ABORT rather than DATA_ACC because DATA_ACC is gated on `state(1) = '1'` in command_protocol.vhd and is therefore a no-op on the push-wait exit, while the abort bit sets handshake_in(2) unconditionally and command_intf.cc's task answers it with HANDSHAKE_RESET (0x87), whose bit 7 forces `state <= "00"`. That is a firmware round trip, so uci_abort's own 32-iteration settle — shorter than one uci_fence — cannot cover it; the existing bounded uci_wait_idle does, and its carry is discarded because the failure is already reported. Scope: net_tcp_send only. net_poll's equivalent bails set NET_TCP_ERROR, and net_poll is a 6-cycle RTS on a non-CONNECTED socket, so those are self-limiting; a net_tcp_send bail leaves net_tcp_state at CONNECTED and the next poll runs. The wait_idle timeout at @begin_chunk is untouched: it expires before any command is begun, so there is no transaction to close. Cost: +1 byte of UCI_CODE on all three UCI profiles, entirely inside the NET_CODE region; the ip65 PRGs are byte-identical (default and en.wikipedia.org targets, both profiles). tools/test_uci_timeout_recovery.py, red at the previous commit, is green here: 4/4, 18 assertions. Closes #194. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nostic Adversary review of #194. Comments and test-diagnostic text only: all seven PRGs (three UCI profiles, both ip65 profiles, both ip65 wikipedia targets) rebuild byte-identical to the previous commit, so the hardware run against a3452651179e22d3328cda4298d2dfbf0b03f1d40747203cd3436dbd6ccc3a92 still describes this tree. The test defect was real and only visible under mutation. The rejected push's state was read at ASSERTION time, not at the rejection. With the fix mutated to abort without waiting, the abort completes during net_poll's later status reads, so the check printed "PUSH_CMD arrived while the interface was in state 00 (idle)" — a branch command_protocol.vhd does not have, since "00" takes the ACCEPT arm. In the un-mutated red the two states coincide because nothing ever aborts, which is exactly why it read correctly there and hid the defect. CommandInterface now captures the state at the moment pushes_rejected increments and the message reports both that and the end state. Pass/fail is unchanged in every case. Three limitations now stated where they belong rather than left for a reader to rediscover: * The abort is serviced by the SAME FreeRTOS task that services commands (command_intf.cc's run_task), so a task blocked in lwip_send — the likeliest reason uci_push_wait timed out at all — cannot action it either. Against that failure the recovery achieves nothing but a second expired wait. This helps the transient-slowness class only. * A bail can now block ~10 s where it blocked ~5 s. One extra wait per net_tcp_send CALL, not per chunk, and no caller retries, so the exposure is bounded at one doubling per send. * The model's TOD advances a tenth per $DC08 read, so the suite proves the code waits at all — not that a real abort round trip fits in 5 s. That remains unmeasured and is not reachable on demand. Also recorded: HANDSHAKE_RESET rewinds command_pointer where DATA_ACC does not, and NetworkTarget::abort() only calls discard_read_reply() and closes no socket (close_all_sockets() is reachable only from c64_reset()), so aborting cannot desync our socket id from the firmware's table. Both are further arguments for ABORT that the fix did not claim. Scope now names net_tcp_connect's four dirty exits and net_tcp_close's one as knowingly out of scope, tracked as #221: they cannot produce $86 (both open with uci_wait_idle, mask $31, rather than net_poll's uci_wait_not_busy, mask $01) but they can leave the interface unusable until reboot. Refs #194, #221. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Closes #194.
Three bounded-wait exits in
net_tcp_sendreturned with the command interface half-way through a transaction:uci_push_waittiming out on the SOCKET_WRITE response left it in state"01", and either drain timing out left it in"10"with the data phase open. Nothing on any error path calleduci_abort— it had exactly one call site in the entire codebase, innet_init— so the only thing that ever cleared a mid-transaction bail was the next boot.All three now share one exit that writes ABORT and then waits for idle. Carry, the
C=1contract andnet_last_error($89, already set by the wait that expired) are unchanged; only the state left behind for the next command changes.ABORT rather than DATA_ACC
DATA_ACC is gated on
state(1) = '1'incommand_protocol.vhd, so it is a no-op on the push-wait exit (state"01"). The abort bit setshandshake_in(2)unconditionally andcommand_intf.cc's task answers withHANDSHAKE_RESET(0x87), whose bit 7 forcesstate <= "00".Two further arguments, both found during review:
HANDSHAKE_RESETalso rewindscommand_pointerwhere DATA_ACC does not, so a partially-pushed command cannot be prepended to the next; andNetworkTarget::abort()only callsdiscard_read_reply()and closes no socket (close_all_sockets()is reachable only fromc64_reset()), so this cannot desync our socket id from the firmware's table — fw #814's socket table is unaffected.The reset is a firmware round trip, so
uci_abort's own 32-iteration settle (~160 cycles, shorter than oneuci_fenceat ~5,450) cannot cover it; the existing boundeduci_wait_idledoes.On the
$86claim in the issue: not demonstratedNo captured run pairs a send
$89with a following$86, and this PR does not claim one. What is shown is the mechanism:net_pollreachesPUSH_CMDthroughuci_wait_not_busy(mask$01), notuci_wait_idle(mask$31), so a left-open data phase does not hold it back — it pushes straight intoelse error_busy <= '1', the single setter of$DF1Cbit 3. The test counts that rejection: 1 before the fix, 0 after.A detail worth recording for whoever next chases a field
$86: in the model the survivingnet_last_erroris usually$89, not$86, becausenet_poll's own drain then times out over the leftover queue and overwrites it. The byte that survives depends on what happens after the rejection, so the field signature is not reliably a literal$86. The test asserts on the rejection, not on the byte.Known limits, stated in the code rather than discovered later
lwip_send— the likeliest reasonuci_push_waittimed out in the first place — cannot action an abort either; thenuci_wait_idlealso expires and the interface stays dirty, 5 s spent and nothing recovered. This helps the transient-slowness class only.net_tcp_sendcall (not per chunk —@sb_bailexits the routine, not the chunk loop), and no caller retries (tls_send_record bcs @fail,http_get_plain bcs @plain_close_err).$DC08read, so it proves the code waits at all, not that a real abort round trip fits inside 5 s. That assumption is unmeasured and not reachable on demand.Scope
net_tcp_connect(4 exits) andnet_tcp_close(1) have the same defect and are knowingly out of scope — tracked as #221. Review confirmed the scope choice is exactly right for the$86mechanism: those three all open withuci_wait_idle(mask$31), and only anet_tcp_sendbail leavesnet_tcp_stateat CONNECTED so polling continues. They cannot produce$86, but they can leave the interface unusable until reboot, with every later connect burning 5 s and returningCONNECT_FAIL.@sb_bailis the remedy to reuse there.Red-green
Reproducible from history — the test commit is red by design:
Mutating the fix to abort without waiting is caught 3/4 red — the model answers after a delay, so a recovery that does not wait cannot pass by accident.
test_uci_data_acc.py3/3; barepytest157 passed.The test runs the shipped 6502 bytes from
build/c64-https.prgatbuild/labels.txtaddresses, against a$DF1B-$DF1Fmodel built fromcommand_protocol.vhdandcommand_intf.cc: the four-state machine with itserror_busybranch, a delayed-response firmware, and a running CIA1 TOD so the bounds expire on purpose. If the model is wrong the test is wrong with it, and the module's docstring says so. Review verified all four load-bearing firmware claims against primary source, and confirmed the model never producing state"11"is correct rather than a gap (SOCKET_WRITE's reply is single-block).Hardware
rig_https_live.pyvs github.com, U64E @ 48 MHz, uci-onchip PRGa3452651179e22d3328cda4298d2dfbf0b03f1d40747203cd3436dbd6ccc3a92— HTTP 200,carry=0,net_last_error=$00(no$86, no$89), CFIN 48.4 s, in line with CLAUDE.md's ~45 s onchip figure. Happy path unregressed on real silicon. The timeout paths themselves are not reachable on demand and were not exercised there.The third commit is comments and test text only: all seven profiles rebuild byte-identical to the second, so the hardware result above still describes the tip — the SHA moved, the image did not.
Cost
+1 B of
UCI_CODEon all three UCI profiles ($813 → $814; NET_CODE tail 366 → 365 B); every other region byte-identical, verified by diffing full segment lists rather than by arithmetic.9eaee61d0fe4e40d2dfe2311abdefd0988718ee4692620d8980adfa14fdff960240b383a2b7841439780f8d3f9a87957c2e8d2d3f535632a84d616520cddb236971b074f65c77eb53b84c9f8f10074bbff81105953e6a04c69533289937977dca3452651179e22d3328cda4298d2dfbf0b03f1d40747203cd3436dbd6ccc3a9200e4370ce3086e8b30df243861e56725a09797f08d8e668535c5166c628821527c3addb092f14066b82f83419a439d963374edad3f67dac206e9db80db197317All four ip65 PRGs — both profiles × default and
en.wikipedia.orgtargets — byte-identical to master, as expected since nothing outsidesrc/net/uci/moved.Review note
The adversarial pass found the suite's headline failure message printing a firmware-impossible claim under mutation: it sampled the interface state at assertion time while asserting it was the state at rejection time, so it reported a rejection from state
"00", whichcommand_protocol.vhdshows takes the accept branch. In the un-mutated red the two states coincide, which is exactly why it read correctly and hid the defect. Now captured at thepushes_rejectedincrement and reported separately from the end state, with a comment warning against reconstructing it from assertion-time state again.🤖 Generated with Claude Code