Repository navigation
Description
c64-test-harness found two handshake races in its own UCI command routines and has fixed one of them. The same two shapes appear in this repo's UCI adapter, src/net/uci/uci_cmd.s and src/net/uci/net.s at 1b9b49e.
Evidence grade: source-read in c64-https; not run there. The mechanisms and the measurements come from c64-test-harness on a U64E. Nothing below has been run against this repo's code. No code change is proposed here.
(a) The command is written before the firmware has taken the opening ABORT
- Harness issue: uci_get_ip(turbo_safe=True) at 48 MHz intermittently gets a zero-length reply with no status string on the U64E c64-test-harness#419
- Harness fix: fix(uci): wait for the firmware to take the ABORT before writing the command (#419) c64-test-harness#476
Mechanism
Writing ABORT ($04) to $DF1C only sets a pending flag, which reads back as bit 2 of $DF1C. The firmware services it later, from its command task, with HANDSHAKE_RESET. That write also resets the command pointer and forces the state to idle.
So any command byte written before HANDSHAKE_RESET lands is lost, and the firmware parses whatever is left of the command. The harness saw three results of this:
- target 5 with an unknown command, status
"\x04"; 21,UNKNOWN COMMAND;- no reply and no status at all ("Null command.").
The idle wait does not cover this. The pending abort does not show in STATE or in bit 0, so a $31 idle check can pass while the reset is still pending.
The fix in #476 polls $DF1C until bit 2 clears after the ABORT, instead of running a fixed delay.
Firmware source
software/io/command_interface/command_intf.cc:
- at
bce4535e(U64E v3.15-132): lines 136-143; - at
1.1.0(C64U): lines 121-128, the same code.
136: if(status_byte & CMD_ABORT_DATA) {
...
142: CMD_IF_HANDSHAKE_OUT = HANDSHAKE_RESET;
143: CMD_IF_IRQMASK_CLEAR = CMD_ABORT_DATA;
The same shape here
uci_abort follows the ABORT with a fixed spin (src/net/uci/uci_cmd.s):
69: uci_abort:
70: lda #UCI_CTRL_ABORT
71: sta UCI_CONTROL
72: uci_fence
73: ldx #$20
74: @spin:
75: dex
76: bne @spin
The next command starts with uci_wait_idle, which waits on STATE and bit 0 only (uci_cmd.s:178-181):
179: lda UCI_STATUS
180: uci_fence ; settle read before testing bits
181: and #(UCI_STAT_STATE | UCI_STAT_CMD_BUSY) ; $31
uci_regs.inc defines no bit-2 status flag, and nothing in uci_cmd.s or net.s waits for one. Two paths lead from the ABORT to the next command write:
- Init:
net.s:110jsr uci_abort, then the ID read, then the first DHCP probe atnet.s:480jsr uci_wait_idle/:484jsr uci_begin_cmd. - The
@sb_bailrecovery:net.s:893-894jsr uci_abort/jsr uci_wait_idle, with the next command's ownuci_wait_idlebefore itsuci_begin_cmd.
The harness routines had the same shape before #476: ABORT, a fixed delay, a $31 idle wait, then the target byte.
Measured in the harness (U64E, fw bce4535e, 2026-09-23, 48 MHz, paired and interleaved, n=10 per arm)
From JC-000/c64-test-harness#419 (comment):
| master (with #476) | pre-#476 | |
|---|---|---|
first socket_write reached the host |
10/10 | 4/10 |
uci_get_ip returned the IP |
10/10 | 3/10 |
| instrument control received | 10/10 | 10/10 |
An earlier mechanism run (n=20 per arm) failed 8/20 on master against 0/20 with the bit-2 wait, and 0/20 at 1 MHz. $DF1C bit 2 was set just before the target byte in every failure and in none of the passes.
(b) The routine waits on $DF1C bit 0 after the PUSH, which clears before the reply is valid
- Harness issue: uci_socket_read single-block path intermittently drains nothing (status 00,OK) on the U64E c64-test-harness#486
- Harness fix, in review: fix(uci): wait for the reply to be valid after every push, not for bit 0 (#486) c64-test-harness#490
Mechanism
$DF1C bit 0, which both code bases call CMD_BUSY, is the new-command flag, handshake_in(0) (command_protocol.vhd:85-89 at bce4535e). The firmware clears it with HANDSHAKE_ACCEPT_COMMAND before copy_result copies the reply and status and writes VALIDATE.
In between, STATE reads 01, and both DATA_AV and STAT_AV read low. A drain that checks DATA_AV once in that window stores nothing. The status becomes valid a few instructions later and reads 00,OK. The window is the copy_result memcpy, so a longer reply widens it.
#490 waits instead for STATE bit 5 (reply valid) or the error bit ($28). The error bit is included because a PUSH while not idle sets only the error bit.
Firmware source
software/io/command_interface/command_intf.cc at bce4535e, lines 164-177:
167: command_targets[target]->parse_command(&incoming_command, &data, &status);
168:
169: CMD_IF_HANDSHAKE_OUT = HANDSHAKE_ACCEPT_COMMAND; // clears CMD_NEW_COMMAND
170: if (no_reply) {
171: CMD_IF_HANDSHAKE_OUT = HANDSHAKE_RESET; // forces state to 00
172: } else {
173: copy_result(data, status); // sets state to 10 or 11
174: }
VALIDATE is written inside copy_result, at :202-204. The ordering is the same at 1.1.0: ACCEPT at :154, copy_result at :158, VALIDATE at :187-189.
The same shape here
uci_push_wait runs a fixed settle delay, then waits on bit 0 alone (uci_cmd.s:289-302, :228-231):
289: uci_push_wait:
290: lda #UCI_CTRL_PUSH_CMD
291: sta UCI_CONTROL
292: uci_fence
...
298: ldx #$FF
299: @pw_settle:
300: dex
301: bne @pw_settle
302: jmp uci_wait_not_busy
228: @wnb_loop:
229: lda UCI_STATUS
230: uci_fence ; settle read before testing bits
231: and #UCI_STAT_CMD_BUSY
Where it matters most: net_poll's SOCKET_READ. After jsr uci_push_wait at net.s:233 and uci_check_err, the header read checks DATA_AV per byte with one fence before it. If DATA_AV is clear, it takes the "no data" exit, which drains and ACKs (net.s:266-273, :285-290):
266: uci_fence ; give firmware time to stage response
267: ldy #$00
268: @hdr_loop:
269: lda UCI_STATUS
270: uci_fence ; settle before testing DATA_AV
271: and #UCI_STAT_DATA_AV
272: bne @hdr_got ; branch past trampoline
273: jmp @hdr_done_short ; long branch: fence too wide for BEQ
...
285: @hdr_done_short:
286: ; Firmware returned fewer than 2 bytes. Treat as "no data".
287: jsr uci_drain_resp
In the harness, a read that landed in the window consumed the datagram: a second read found 02,NO DATA.
The other push sites (DHCP probe, TCP connect, socket write) read their reply through uci_read_resp_bytes, which spins up to 16 bits on DATA_AV. That spin probably rides out the window for data. uci_drain_resp and uci_drain_status check their available bit once and return, so they are exposed the same way. None of this is measured here.
Measured in the harness (U64E, fw bce4535e, 2026-09-23, 1 MHz, 253-byte UDP reads, 4 arms interleaved, n=8 each)
From JC-000/c64-test-harness#486:
- The first read was whole 16/16 with a wait for STATE bit 5 after the push/busy wait, against 5/16 with the stock bit-0 wait.
- The host delay before the read made no difference (2/8 against 3/8).
- On all 11 stock failures the first read drained 0, returned status
00,OK, and left$C200untouched. The second read then got02,NO DATA: 11.
Not established
- Whether either race fires in c64-https in practice. Its fences, spin counts and read paths differ from the harness, and nothing here was run against this repo.
- The C64U (fw 1.1.0) was not measured for either race. Its firmware ordering is source-read only.
Activity
fix(uci): clear ERROR before the push; correct the no-reply claim (#230…
test(uci): pin empty replies, the 50-tenth budget and a stale ERROR b…
docs(uci): master was not fast on the no-reply path; quantify the res…
fix(uci): wait for ABORT's reset; abort on every connect/close timeout (
fix(uci): FIFO-true abort model, $35 idle mask, CLOSED at exit (#230 a,
docs(uci): scope "bit 2 clear"; no-reply clears abort in the model (#230
Merge pull request #238 from JC-000/fix/230b-uci-reply-valid-wait
Merge pull request #241 from JC-000/fix/230a-221-uci-abort
JC-000 commentedon Sep 24, 2026
Post-flash re-baseline (fw ef9937a7, core 1.50)
Tree: origin/master
35ea3b9(includes #232 #233 #238 #241 #244), submodules at their gitlinks (nistcurvesc05ead66, x2551916157a14, ip6525a9c5aa).Device: U64E 10.43.23.81.
/v1/inforead once inside DeviceLock, 2026-09-24 ~16:43 CDT:product=Ultimate 64 Elite, firmware_version=3.15, git_commit_hash=ef9937a7, fpga_version=125, core_version=1.50, unique_id=601A96.Every HTTPS run went DeviceLock →
prepare_device→preflight_reu. The turbo setting was already 48 MHz Manual, so no turbo write was made on any run. The REU was written Enabled/16 MB for the comb run only./TempGC ran in the banner rig and removed 0 files each time; the live rig callsgc_tempitself. Post-run values were read only after the sentinel fired (trampoline rigs) or after the rig saw a complete body (banner rig). Every fetch closed cleanly: the banner rig sawCONNECTION CLOSEDafterQ, and there was no reset over a live socket.boot_check.py10e614c9…a381baa7rig_https_banner.py, menu path, en.wikipedia.org/wiki/Commodore_64,FETCH_TIMEOUT=900HTTPS_HOST=en.wikipedia.org HTTPS_PATH=/wiki/Commodore_64c7b24019…ca44a6d42rig_https_local.py, back to back,PHASE_TIMING=1HTTPS_SNI=www.foo.invalid8f8d5d43…89d04264HELLO FROM TLS SERVER, HTTP 200, server-side criteria met. No PASS/$88alternation: all three consecutive runs passed$00×3rig_https_live.pygithub.com10e614c9…a381baa7$00×2rig_https_live.pylwn.net$00rig_https_banner.pycomb,C64_INIT_WAIT=120,FETCH_TIMEOUT=900ld65 -C cfg/c64-https-uci-onchip.cfg) wikipedia target85d0413a…aa340b0102fAll 11 runs were at 48 MHz. Clock-sensitive figures are labelled as the core 1.50 baseline and deliberately not compared with pre-flash numbers.
Findings
$86/$88/$89anywhere:net_last_errorwas$00on all 6 trampoline runs. No stalls: 4/4 wikipedia fetches (3 onchip + 1 comb) completed to Content-Length through the menu path, which is the http_get reports success (carry=0, HTTP 200) on a truncated body — silent data loss #211 shape under fix(uci): wait for the reply to be valid, not for CMD_BUSY to clear (#230 b, #211) #238/fix(uci): wait for ABORT's reset; abort on every connect/close timeout (#230 a, #221) [stacked on #238] #241.$88alternation did not reproduce in 3 consecutive runs on the new gateware.net_last_errorand does not timestamp its poll lines, so the banner-row timings above are cadence estimates and the error byte is missing for those 4 runs.boot_check.pyis not a crypto-path rig, so it ran withoutprepare_device.prepare_devicefor the comb run) and turbo 48 MHz Manual.🤖 Generated with Claude Code
JC-000 commentedon Sep 25, 2026
Resting-image smoke (c47155b7 = ef9937a7 + #835)
Tree: origin/master
742e0b1, submodules at their gitlinks (nistcurvesc05ead66, x2551916157a14, ip6525a9c5aa). This is later than the re-baseline's35ea3b9: it adds #240 (AEAD fail-closed, which touches TLS code) and #236 (tooling only). So every PRG hash below differs from the re-baseline's. That comparison therefore varies both the firmware and the image, not the firmware alone.Device: U64E 10.43.23.81.
/v1/inforead once inside DeviceLock, 2026-09-24 22:01 CDT:product=Ultimate 64 Elite, firmware_version=3.15, git_commit_hash=c47155b7, fpga_version=125, core_version=1.50, unique_id=601A96.Every run went DeviceLock →
prepare_device→preflight_reu. The device came up reset (Turbo Off / 1 MHz, REU Disabled). The first run wrote turbo 48 MHz Manual and every later run found it already set. The REU was written Enabled/16 MB for the comb run only. Post-run values were read only after the sentinel fired (trampoline rigs) or after the rig saw a complete body (banner rig). Every banner fetch closed cleanly (CONNECTION CLOSEDafterQ), and there was no reset over a live socket.rig_https_local.py, back to back,PHASE_TIMING=1HTTPS_SNI=www.foo.invalid505f4078…f0dfaabbHELLO FROM TLS SERVER, HTTP 200,http_getcarry=0. No PASS/$88alternation$00×3rig_https_banner.py, menu path, en.wikipedia.org/wiki/Commodore_64,FETCH_TIMEOUT=900748c8bdd…b03d1ed9rig_https_live.pygithub.comea3e8857…13d90983$00×3rig_https_banner.pycomb,C64_INIT_WAIT=120,FETCH_TIMEOUT=900ld65 -C cfg/c64-https-uci-onchip.cfg) wikipedia target82c6ecce…3e0aaa06Full hashes:
505f40788f5e8963968f358d3de50f0c7d236ad74412fdf305f07159f0dfaabb(sni),748c8bdd6d3419e3d8b59d18aeb00460bc8d1d90b03a3958e30c4b71b03d1ed9(onchip wiki),ea3e8857d5cfd9aafff278c463a28df26c3abcca2b88b44e229cc5fb13d90983(onchip default),82c6ecce38b55cdaf370b7fdb68842991686a5369e60984040704a323e0aaa06(comb wiki).Findings
$86/$88/$89:net_last_errorwas$00on all 6 trampoline runs. No stalls and no short bodies: all 3 wikipedia fetches (2 onchip + 1 comb) completed to Content-Length through the menu path.$00.net_tcp_state=$00,tls_state=$00andtls_last_state=$00at the dump. Two follow-up runs of the same image came in at 112.6 s and 107.9 s, which is inside the re-baseline's spread (68.2-108.8 s). So it is recorded as a run-to-run outlier in the post-handshake GET/drain, n=1. It is not attributed to #835 or to tls: fail closed on AEAD tag failure instead of polling on (#239) #240. The body size is not captured by this rig, so server-side page-size variation cannot be excluded. Snapshots are in/tmp/uci_https_live_debug/20260924_221902/on the host.rig_https_local.pyruns 1 and 2 logged 240 and 611 debug-stream packet gaps (run 3: 2; baseline: 0/3/1). This is host-side capture loss, and the verdicts do not depend on that stream.🤖 Generated with Claude Code