Skip to content

Close #210 — rig_https_banner.py can now go red on a truncated body - #227

Merged
JC-000 merged 1 commit into
masterfrom
fix/210-rig-truncation-oracle
Sep 8, 2026
Merged

Close #210 — rig_https_banner.py can now go red on a truncated body#227
JC-000 merged 1 commit into
masterfrom
fix/210-rig-truncation-oracle

Conversation

@JC-000

@JC-000 JC-000 commented Sep 8, 2026

Copy link
Copy Markdown
Owner

Closes #210.

The rig's PASS covered only the banner. Completeness rode on total >= 125_000 — a stale literal about a user-editable Wikipedia article — and the ok it computed was assigned and then never consulted by the verdict. A run reaching 73,720 B printed WARNING: body stalled and PASS, exit 0. Being the only rig that executes do_https_get, that is why #211's silent truncation went unnoticed.

The fix is not a better literal

The response frames its own length and the C64 has already parsed that framing into DMA-readable BSS, so tools/http_body_checks.py asks the same question the client asks, off the same parser state — Content-Length against the 24-bit consumed count, or the arrival of the terminal chunk. Neither framing present is INCONCLUSIVE (exit 78), never promoted to a pass. No PRG change was needed for any of it.

Two of four arms match http_recv_timeout_verdict; two diverge deliberately

The 6502 chunked arm rejects unconditionally and never reads http_chunk_state — right for code that only runs when the tick budget expired, since the parser's own success exit (@cb_donesink_finish_done, C=0) never reaches the verdict at all, but useless to a rig reading at an arbitrary moment. The unframed arm returns success because the PRG must return something.

What is not a divergence is the Content-Length-before-chunked precedence, and that is now pinned against src/http.s. Reversing those arms in the 6502 previously left every host-side test green — the old symbol check grepped src/http.s for name substrings while five of the seven symbols are declared in src/data.s. The pin is labelled for what it is: source shape, not behaviour, and weaker evidence than the link-time asserts this repo uses elsewhere. Review confirmed that label is exact — two mutants that change PRG behaviour while preserving the shape survive it, precisely inside the stated limit.

The exit-code decision moved into the module

While it was five lines in the rig, an AST taint guard was the only thing testing it — and that guard proves a verdict name reaches an if containing a return, nothing about polarity, list membership, or which constant is returned. Five one-token mutants kept it green, two of which restore #210 exactly: exit 0 on a body just reported as truncated.

decide_exit is now a pure function in the module, executed by the suite rather than inspected. The rig calls it by keyword, because a positional settled/status transposition would revive the cry-wolf failure while passing every guard.

This is a trade, not a closed seam: smaller in consequence, marginally larger in surface. The decision left the seam and is executed; five untestable decision mutants were traded for one untestable binding mutant, which keywords remove. read_state's slicing, the span_lo/span_hi arithmetic, last_moved and grace are unchanged and still untested — the runner says so.

Red-green

  • The suite against master's rig — the real pre-fix artifact — reddens on the rig never calls decide_exit. That assertion is deliberately ordered ahead of the stale-literal one: with the literal first, the pre-fix red never reached the substantive guard, which is part of why the weak taint check went unnoticed.
  • tools/mutate_http_body_checks.py: 27/27, KNOWN_EQUIVALENT empty — the five exit-code mutants, six written independently against decide_exit by a second reviewer and folded in (an independent set that lands is worth more than the same set re-run, and only stays worth something if it lives in the file), two that mutate src/http.s, and two against the rig's delegation.
  • M15 behaviourally on the real numbers: shipped decide_exit exits 1 on a 162,996 B shortfall; any(all( exits 0.
  • pytest 163 passed; test_pytest_boundary.py and test_runner_coverage.py green.

The mutation runner stages src/http.s and src/data.s into a tempdir and never writes the repo — verified empirically with a clean git status after a full run.

Hardware

U64E @ 48 MHz, uci-onchip, HTTPS_HOST=en.wikipedia.org HTTPS_PATH=/wiki/Commodore_64, PRG 44e6c0dd605db65682131d6621fc9ad7b8b03cb0d22b0b241691abd3c35ed32a, FETCH_TIMEOUT=900two runs of that one artifact:

run result
1 PASS — 754,413 B consumed == Content-Length, HTTP 200
2 FAIL — frozen at 299,123 B, 455,290 B short

One of each: treat neither as the norm, and do not quote the PASS on its own. #211 reproduces on the shipped path, and the stall is abrupt — the counter freezes and stays frozen for hundreds of seconds, which is what #219's fast-expiry path predicts (errored socket → net_poll is a 6-cycle RTS). The old rig would have printed WARNING: body stalled at 299,123 B and PASS, exit 0.

An earlier 300 s run is what produced check_fetch_settled: it reported TRUNCATED while describing the rig's own budget. A still-growing body at the deadline is inconclusive now, and settled is not in checks — it can only turn FAIL → 78, never anything → PASS.

No PRG bytes change — no .s, .cfg or Makefile input is touched; all seven build variants rebuilt to prove it, uci-onchip reproducing 971b074f65c77eb53b84c9f8f10074bbff81105953e6a04c69533289937977dc.

Scope

#211's cause is untouched — this makes the truncation observable, not absent. boot.s still ignores the carry, so the menu path renders a truncated body normally; wiring the UI is separate work. A pass is a framing-agreement statement, not byte fidelity: nothing here observes what the server sent.

Owed work

This rig still does not call preflight_reu(), unlike the five other crypto-path rigs. Pre-existing, but this PR promotes it to a completeness oracle with a 900 s budget, so a comb PRG on a REU-disabled device now costs far more before failing. PR #225 lists it in KNOWN_UNPREPPED naming this lane as owner; the follow-up is to wire prepare_device(...) after enable_uci once both land, asserting 48 MHz to match this rig's new default.

Throughput follow-up filed as #226: a truncated run polls a frozen counter for the rest of its budget.

🤖 Generated with Claude Code

The rig's PASS covered only the banner. Body completeness rode on
`total >= 125_000` — a stale literal about a user-editable Wikipedia
article — and the `ok` it computed was assigned and never consulted by
the verdict. A run reaching 73,720 B of ~125,703 B printed
`WARNING: body stalled` and `PASS`, exit 0. Being the only rig that
executes `do_https_get`, that is why #211's silent truncation went
unnoticed.

The fix is not a better literal. The response frames its own length and
the C64 has already parsed that framing, so `tools/http_body_checks.py`
asks the same question the client asks, off the same DMA-read parser
state: Content-Length vs the 24-bit consumed count, or the arrival of
the terminal chunk. Neither framing present is INCONCLUSIVE, exit 78,
never promoted to a pass.

Two of four arms match `http_recv_timeout_verdict` and two diverge on
purpose; the module docstring has the table and is the authority. The
6502 chunked arm rejects unconditionally and never reads
`http_chunk_state`, which is right for code that only runs when the tick
budget expired — a chunked response that got there is short by
construction, and the parser's own success exit (`@cb_done` ->
`sink_finish_done`, C=0) never passes through the verdict at all — but
useless to a rig reading at an arbitrary moment, which needs a positive
signal. The 6502 unframed arm returns success because it must return
something. What is NOT a divergence is the Content-Length-before-chunked
precedence, and that is now pinned against `src/http.s`: reversing those
two arms in the 6502 previously left every host-side test green.
`test_symbols_...` was also grepping `src/http.s` for names when five of
the seven are declared in `src/data.s`; it reads the `.res` widths now.

The exit-code decision lives in `decide_exit`, not in the rig. While it
was five lines at the bottom of the rig, the only thing testing it was an
AST guard, and five one-token mutants kept that guard green — `any(` ->
`all(`, `return EXIT_FAIL` -> `EXIT_PASS`, a `[:2]` slice and two dead
conjuncts — two of which restore #210 exactly. Name-taint cannot see
polarity, list membership, or which constant is returned. As a module
function it is executed by the suite instead; the rig calls it BY
KEYWORD, because a positional settled/status transposition would hand
the still-growing gate a verdict that is never inconclusive and revive
the cry-wolf failure while passing every guard.

That is a trade, not a closed seam: smaller in consequence, marginally
larger in surface. Five untestable decision mutants became one untestable
binding mutant, which keywords remove. `read_state`'s slicing, the
span arithmetic, the `last_moved` tracking and the `grace` computation
are unchanged and still untested — `tools/uci/` rigs need hardware and
this one is not importable without the sibling harness.

Two things the hardware runs forced:

  * `check_fetch_settled`. A 300 s budget expired at 591,417 B of a
    754,413 B body that was still growing, and the completeness verdict
    said TRUNCATED while describing the rig's own budget. Still-growing
    at the deadline is inconclusive. It is not in `checks`, so it can
    only turn FAIL -> 78, never anything -> PASS.
  * TURBO_MHZ. The rig inherited whatever clock the previous lane left
    and got away with it because the banner verdict is clock-independent.
    A completeness verdict is not: the device was at 1 MHz and the fetch
    never left "tcp connected".

Red-green, all captured:

  * The suite against master's rig — the real pre-fix artifact — reddens
    on `the rig never calls decide_exit`. That assertion is ordered ahead
    of the stale-literal one on purpose: with the literal first, the
    pre-fix red never reached the substantive guard.
  * `tools/mutate_http_body_checks.py`: 27/27, `KNOWN_EQUIVALENT` empty.
    Includes the five exit-code mutants, six more written independently
    against `decide_exit` by a second review, two that mutate
    `src/http.s`, and two against the rig's delegation.
  * M15 behaviourally, on the real numbers: shipped `decide_exit` exits 1
    on a 162,996 B shortfall, the mutant exits 0.
  * Hardware, U64E @ 48 MHz, uci-onchip HTTPS_HOST=en.wikipedia.org
    HTTPS_PATH=/wiki/Commodore_64, PRG 44e6c0dd…, FETCH_TIMEOUT=900: one
    PASS at 754,413 B == Content-Length and one FAIL frozen at
    299,123 B, on the same artifact. #211 reproduces on the shipped path;
    treat neither run as the norm. CLAUDE.md records both. The exact
    tuple the failing run produced is an assertion in the suite.

No PRG bytes change: no .s, .cfg or Makefile input is touched, and all
seven build variants were rebuilt to prove it.

Closes #210.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@JC-000
JC-000 merged commit 0e0c07c into master Sep 8, 2026
JC-000 added a commit that referenced this pull request Sep 8, 2026
fix(rigs): wire rig_https_banner.py through prepare_device — repairs red master after #225 + #227
Sign up for free to 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.

rig_https_banner.py cannot fail on a truncated body

1 participant