Skip to content

fix(tls): per-backend post-ServerHello drain budget — restores UCI handshake wall-clock - #74

Merged
JC-000 merged 3 commits into
masterfrom
fix/drain-backend-budget
Aug 12, 2026
Merged

fix(tls): per-backend post-ServerHello drain budget — restores UCI handshake wall-clock#74
JC-000 merged 3 commits into
masterfrom
fix/drain-backend-budget

Conversation

@JC-000

Copy link
Copy Markdown
Owner

Fixes#73. Performance-only; correctness unaffected (every VICE and hardware
run passes either way).

The regression

#71's post-ServerHello drain is unconditional 8 x 250 = 2000 net_poll calls.
Measured on C64 Ultimate hardware (onchip UCI build, 48 MHz, local listener):

handshake+GET
baseline (v0.6.0 onchip @48)51.0 s
master post-#71125.4 s

~70 s of that is the drain; ~4.5 s is #69's on-chip X25519 rows at this clock
(expected, CPU-bound).

Why it is backend-asymmetric

The drain compensates for a property only ip65 has: it ACKs inbound data
only when the consumer pumps net_poll, so the server's flight tail otherwise
sits unACKed through multi-minute crypto stalls until an impatient peer drops
the connection. UCI firmware ACKs autonomously — precisely why that bug was
never observable on Ultimate hardware — so on UCI the drain buys nothing.

Cost, meanwhile, is wildly different: an ip65 net_poll is a cheap NIC pump; a
UCI net_poll is a full firmware command round-trip (SOCKET_READ + waits +
drains + ack, ~25 fenced register accesses plus FPGA turnaround). Measured
~37 ms/poll at 48 MHz, only ~2.8 ms of which is fence time — the rest is
clock-invariant firmware turnaround, so turbo does not amortize it.

Fix

Budget moves to a per-backend net_tuning.inc, resolved through the existing
-I src/net/$(BACKEND) include path so tls13.s stays backend-agnostic:

  • ip65: 8 x 250 = 2000 — unchanged (the validated figure)
  • uci: 1 x 16 = 16 (~0.6 s) — a deliberate small hedge rather than 0, so
    anything already queued still reaches the ring before the long stalls without
    relying on firmware autonomy being absolute. Non-zero matters: the loop's
    dex/bne shape turns an INNER of 0 into 256 iterations.

Verification

Process note

I approved #71's unconditional drain estimating "~1 s on UCI turbo" — that
counted fence time only and ignored firmware turnaround, off by ~70x. An
iteration-count budget calibrated on one backend's poll cost is exactly the
failure mode c64-lib-contract SPEC §13.4 (bounded waits must be wall-clock
based) exists to prevent. A TOD-bounded idle drain (poll until the ring stops
growing) is the principled backend-agnostic variant; it needs care around CIA1
TOD latch interaction with the UCI adapter's own TOD waits, so it is documented
as a refinement rather than bundled here.

🤖 Generated with Claude Code

JC-000and others added 3 commits July 29, 2026 16:38
…expect ~56 s
Fixes#73. Regression introduced by #71 (merged), measured on C64 Ultimate
hardware: the shipped UCI onchip handshake+GET went 51.0 s -> 125.4 s at
48 MHz, a ~2.5x wall-clock regression. Correctness was never affected.
The drain compensates for a property only ip65 has: it ACKs inbound TCP
data only when the consumer pumps net_poll, so without draining, the
server's post-SH flight tail sits unACKed through the multi-minute
ECDHE/verify stalls and impatient peers drop the connection. UCI firmware
ACKs autonomously — which is exactly why that bug was never observable on
Ultimate hardware — so on UCI the drain has nothing to buy.
Its cost, though, is anything but backend-neutral. An ip65 net_poll is a
cheap NIC pump; a UCI net_poll is a full firmware command round-trip
(SOCKET_READ: uci_wait_not_busy, uci_begin_cmd, 4x uci_put_byte,
uci_push_wait, uci_check_err, header read, uci_drain_resp +
uci_drain_status + uci_ack — ~25 fenced register accesses plus FPGA
turnaround). Measured ~37 ms/poll at 48 MHz, of which only ~2.8 ms is
fence time; the rest is clock-invariant firmware turnaround, so turbo
does not amortize it. 2000 polls = ~70 s.
Move the budget into a per-backend net_tuning.inc, resolved through the
existing `-I src/net/$(BACKEND)` include path so tls13.s stays
backend-agnostic:
ip65: 8 x 250 = 2000 polls (UNCHANGED — the validated figure)
uci: 1 x 16 = 16 polls (~0.6 s; a deliberate small hedge rather
than 0, so anything already queued still lands in the ring before
the long stalls without relying on firmware autonomy being
absolute. Non-zero matters: the loop's dex/bne shape turns an
INNER of 0 into 256 iterations.)
Verified in the assembled listing: ip65 emits A0 08 / A2 FA, uci emits
A0 01 / A2 10. All five profiles link with unchanged sizes (47,105 B ip65,
62,977 B uci). The ip65 images are byte-identical to master, so the ip65
e2e evidence from #71 carries over untouched; the UCI side needs a
hardware re-measure (expected ~56 s = 51.0 s baseline + ~4.5 s for #69's
on-chip X25519 rows at this clock).
Process note for the record: I approved #71's unconditional drain with an
"~1 s on UCI turbo" estimate that counted fence time only and ignored
firmware turnaround — off by ~70x. An iteration-count budget calibrated
on one backend's poll cost is precisely the failure mode that
c64-lib-contract SPEC §13.4 (bounded waits must be wall-clock-based)
exists to prevent. A TOD-bounded idle drain (poll until the ring stops
growing) would be the principled backend-agnostic variant; it needs care
around CIA1 TOD latch interaction with the UCI adapter's own TOD waits,
so it is left as a documented refinement rather than bundled here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The C64 Ultimate speed-switch quirk is sharper than CLAUDE.md records, and
in a way that made 64 MHz unmeasurable: the bridge glitch is caused by the
REST config WRITE itself, it SURVIVES the following reset, and it fires
even when the written value equals the current one.
Evidence (C64U 10.53.21.158, onchip UCI build, 2026-07-29): three 64 MHz
attempts each wrote "64" while the device was ALREADY at 64 MHz, and each
lost its first TCP_CONNECT — UCI_ERR_NO_SOCKET, net_tcp_state=CONNECT_FAIL,
all TLS/HTTP state zero, ring head==tail==0, no SYN on the wire. The same
PRG at the same 64 MHz setting passes under tools/uci/test_http_local.py,
whose only material difference is that it performs no config write before
its reset. At 48 MHz the identical pattern costs only the first attempt
(fail, pass on retry), which is why this hid for so long.
Fix: probe Turbo Control + CPU Speed first and skip the write entirely when
they already match (the common case for repeat runs at one speed), and give
a genuine change a 3.0 s settle instead of 0.5 s (TURBO_SETTLE overrides).
The probe is best-effort — if it raises, we write as before. Both sides of
the speed comparison are str()-normalised so a firmware type change cannot
silently restore always-write.
Not the wedge signature (that reaches ENC1 RX and stalls); this never opens
a socket. Found by the hardware worker while validating #72/#73 — it blocked
the 64 MHz headline number for PR #74.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…achable
The redundant-write skip added in e044f51 never fired on the C64U: the probe
read `get_config_item(...).get("value")`, but the REST config responses wrap
items in a `<Category>` key and put each value directly under the ITEM name —
there is no per-item "value" key. The probe therefore returned None/None on
every device, `str(None) != "48"`, and the code always took the write path
(log line: "Setting turbo to 48 MHz (from None/None)...").
Fix: mirror the harness's own get_reu_config — fetch the category, unwrap,
index by item name — using public API only (no reliance on the private
_unwrap). One request now covers both items instead of two. `.get(CAT, cat)`
tolerates a response with or without the wrapper.
Verified without hardware by parsing all three plausible shapes (wrapped,
flat, int-typed CPU Speed): the skip decision comes out True for each, so a
firmware shape or type change degrades to "write anyway" rather than to a
silent wrong answer.
Caught because the write path logs the probed values ("from None/None") —
keeping the observed state in that message is what made an unreachable branch
visible in a passing run. Worth remembering.
Note the 3.0 s settle from e044f51 is doing real work independently: the first
genuine speed change under it (64->48) connected on the first attempt, where
every pre-fix genuine change cost a NO_SOCKET retry.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@JC-000

Copy link
Copy Markdown
OwnerAuthor

Validated on C64 Ultimate hardware @48 MHz — and it net-improves the config

Direct before/after on the same device and build (onchip UCI, TURBO_MHZ=48,
local listener), drain budget the only variable:

handshake+GET
pre-#71 baseline (CLAUDE.md)51.0 s
master post-#71 (the regression)125.4 s
this PR44.6 s

Correctness intact — the 16-poll hedge is sufficient on UCI: http_status=200,
http_resp_len=21, body HELLO FROM TLS SERVER, net_last_error=0x00,
EE/CERT/CV all decrypted normally, no stall in the encrypted flight.

Attribution now measured from both directions: 1,984 polls removed saved
80.8 s ⇒ 40.7 ms/poll, matching the ~40.6 ms/poll derived independently from
a U64E REU-profile run. #73 is closed by measurement, not inference.

Correction to this PR's description: #69 is a speedup at turbo, not a ~4.5 s cost

44.6 s is 6.4 s below the 51.0 s pre-#71 baseline (5.2 s below the 49.8 s
packaged figure), against a historical spread of ~1.2 s for this config — and
the residual 16-poll drain still costs ~0.65 s of that total.

My "~4.5 s cost" estimate linearly scaled #69's 1 MHz penalty, which inverts the
actual physics: the 1 MHz penalty exists because REU DMA is cheap relative to
the CPU there, whereas on-chip row generation scales with the clock while REU
DMA stays anchored to the ~1 MHz bus. Above the crossover, on-chip wins — the
same mechanism that gives FP_ONCHIP_MUL its ~22 MHz crossover for P-256. So #69
is a gain at 48 MHz, and the correct post-fix expectation was baseline minus
a few seconds. Credit to the hardware validator for catching the sign.

Also in this PR (two follow-up commits): the C64U turbo-write glitch is sharper
than documented — the bridge glitch is caused by the config write, survives
the reset
, and fires even on a redundant write (3/3 at 64 MHz while
already at 64 MHz). Fixed by probing current state and skipping a matching
write, plus a 3.0 s settle for genuine changes; the first genuine change under
the wider settle connected on the first attempt. The probe's first version read
the wrong JSON shape (.get("value") — the values sit under a <Category>
wrapper indexed by item name) which made the skip unreachable; fixed and
verified against wrapped/flat/int-typed shapes without hardware.

64 MHz number to follow — measurable for the first time now that the turbo
write is skippable.

@JC-000

Copy link
Copy Markdown
OwnerAuthor

REU production path validated on U64E @48 MHz — 82.1 s, back at baseline

The default make BACKEND=uci build (the configuration dist/ packages), U64E
10.43.23.81, TURBO_MHZ=48, local listener:

handshake+GET
baseline (CLAUDE.md, U64E REU @48)82.1 s
pre-fix (post-#71)161.0 s
this PR82.1 s

http_status=200, http_resp_len=21, body HELLO FROM TLS SERVER,
tls_recv_progress/sub = 0x05/0x0A. Progress 0x03 at 0.6 s → 0x05 at 82.7 s.

Three independent confirmations now agree on the per-poll cost

sourcepolls removedsavingms/poll
C64U onchip @48 (125.4 → 44.6 s)1,98480.8 s40.7
U64E REU derivation (validator)~40.6
U64E REU @48 (161.0 → 82.1 s)1,98478.9 s39.8

Clock- and device-invariant, as expected for firmware turnaround rather than
fence time.

And a third confirmation that #69 is a speedup, not a cost

The REU profile cannot contain #69 (its fe25519.s change is inside
.ifdef USE_NISTCURVES_ONCHIP), and it lands exactly at baseline — while
the onchip profile lands 6.4 s (@48) and 6.0 s (@64) below baseline. Two
profiles, one containing #69 and one not, behaving precisely as the
"on-chip beats wall-clock-anchored REU DMA above the ~22 MHz crossover" model
predicts.

Also: the turbo-skip path is now validated

This run logged Turbo already 48 MHz (Manual) — skipping config write — the
skip executed for the first time (the device was already at 48 MHz from an
earlier run, the probe read real values, the write was avoided). That promotes
the skip from unvalidated to working, alongside the 3.0 s settle which was
already carrying the benefit on genuine changes.

Full re-test matrix for #73/#74 is now closed: ip65 unchanged (byte-identical
PRG), UCI onchip @48 and @64, UCI REU @48, all PASS with correct bodies.

@JC-000
JC-000 merged commit bf69f05 into masterAug 12, 2026
JC-000 added a commit that referenced this pull request Aug 12, 2026
The benchmark tables predated three merged changes and one open fix, and
several status claims had gone stale. Measured numbers only — no
extrapolated figures.
**e2e wall-clock.** The 2026-07-20 campaign rows are kept as history and
labelled as such; a new "Post-#74 e2e numbers" block records HEAD:
device profile clock pre-#71 post-#71 post-#74
C64U onchip 48 MHz 51.0 s 125.4 s 44.6 s
C64U onchip 64 MHz 39.7 s (unmeas.) 33.7 s
U64E REU 48 MHz 82.1 s 161.0 s 82.1 s
Both onchip rows land BELOW their pre-regression baselines and the REU row
lands exactly AT it — a REU build cannot contain #69 (its change is inside
.ifdef USE_NISTCURVES_ONCHIP), so the pair is a clean control showing #69
is a SPEEDUP at turbo, not a cost. The doc now states that explicitly,
including why the sign is easy to get wrong: the profile's 1 MHz penalty
exists only because REU DMA is cheap relative to the CPU down there, and
inverts above the crossover. (I got this backwards during the campaign;
recording the reasoning so the next reader doesn't.)
**New: ip65 / stock-C64 wall-clock**, the first ip65 e2e figures we have —
36.0 min honest 1 MHz REU-less, with the phase breakdown, plus the
accelerated runs. Notes that the verify stretch came in 1.4% off the
T(f)=D+C/f prediction three orders of magnitude from where that model was
fit, and that ip65's drain budget is byte-identically unchanged by #74 so
the numbers stand at HEAD.
**Corrected stale claims:**
- "ip65 is NOT packaged: does not link" — it links (#68). Explains the
SCRATCH_UNION lifetime argument and its guards, notes packaging it is now
a live option since a stock C64 + RR-Net has no shipped PRG today, and
demotes c64-nist-curves#54 from blocker to optional headroom.
- The CRYPTO_COLD_SHADOW "1,662 B overflow, cfg relief exhausted" entry,
same fix.
- The X25519-sibling entry claimed the old BSS overflow. Re-measured
2026-07-29: USE_X25519_SIBLING=1 under ip65 still fails, but on a
DIFFERENT problem — X25519_RODATA over CRYPTO_OVERLAY by 2,048 B and
LIB_NISTCURVES_P256_CODE over CRYPTO_RESIDENT by 103 B, i.e. code/rodata
placement (ip65's overlay slot is 4,212 B vs UCI's 7.5 KB), not BSS.
Better to state the measured failure than leave a fixed one on the page.
**New design note** for the post-ServerHello drain: the ip65 property that
motivates it (no MSS in SYN + ACK-only-when-polled), the offline-verify
failure signature it prevents, why the budget must be per-backend (~40 ms
per UCI net_poll vs a cheap ip65 pump — the #73 regression), current
values, and the two open follow-ups (in-crypto polling for large flights;
a wall-clock/idle bound instead of an iteration count, which is what the
section's own rule actually demands).
**New Smoke-tests subsection** for the hardware-free VICE ip65 rig, with
the two prerequisites that are easiest to lose: the patched
ethernet-capable VICE (stock macOS builds gate pcap on geteuid()==0) and
the /dev/bpf permissions that reset every reboot. Also replaces the
"blocked on an upstream ip65 bug (see lost memory note)" line with what is
actually known now.
Stacked on fix/drain-backend-budget: the post-#74 rows describe that PR's
tree, not master's.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
JC-000 added a commit that referenced this pull request Aug 13, 2026
Found while verifying my own branch's PRG-neutrality: an incremental
`make` after a `BACKEND=uci` build produced an ip65 PRG of exactly the
right size (47,105 B) and the wrong contents — d483d46f… against the
clean tree's db311110…, with no warning. The final make ran ld65 alone;
every object was "up to date" by timestamp.
The mechanism is worth naming because it is wider than the flag rule the
Packaging section already states. BACKEND= is not a -D flag, it selects
`-I src/net/$(BACKEND)`, and src/tls13.s includes net_tuning.inc from
that path. So the stale object carries the *other* backend's
post-ServerHello drain budget: an ip65 PRG built this way gets UCI's
1x16 instead of 8x250, which is precisely the #73 regression that #74
fixed — reintroduced by a build order rather than by a code change, in
the one backend where the drain is load-bearing (ip65 sends no MSS and
ACKs only when polled).
Documented at the top of Variables, where someone about to type
`make BACKEND=...` will read it, with the two hashes as evidence.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JC-000 added a commit that referenced this pull request Aug 13, 2026
master carried #74 (per-backend post-ServerHello drain budget) and #75
(span-input parser) independently of this branch. #74's change also
exists here, so both copies of its comment block conflicted.
Both conflicts are comment-only. Resolved in favour of this branch's
text, which carries the corrected per-poll cost: ~40 ms, not ~37 ms.
CLAUDE.md derives 40.7 ms (80.8 s / 1984 polls, C64U onchip) and
39.8 ms (78.9 s / 1984, U64E REU) from the measured regression; the
~37 ms figure on master is stale by one round of measurement.
No code differences were involved. Verified the load-bearing constants
are unchanged on both backends: UCI 1x16, ip65 8x250.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JC-000 added a commit that referenced this pull request Aug 14, 2026
VICE ip65 rig, onchip profile (REU-less stock-C64 config), master
2ceb5b1: PASS, http_status=200, resp_len=22, body match, 1,876.0 s
accelerated against the 1,813.9 s reference (+3.4%). Phase shape
unchanged.
The reason this run was owed: the ip65 PRG is no longer byte-identical
to the #71-era build (417c708594... vs db31111031e2...) because #75's
span-input parser is real code. That change had only ever been
exercised on UCI. This is also ip65's first e2e since July, so it is
the first to cover #74, #75 and the ten audit PRs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PERF REGRESSION (merged #71): unconditional post-ServerHello drain costs ~70 s on UCI — shipped handshake 51 s → 125 s @48 MHz

1 participant

@JC-000