Skip to content

fix(wasm): three Python-vs-Wasm parity fixes (parse_float helper, capability error text, variant receiver)#80

Merged
nelsonduarte merged 3 commits into
mainfrom
fix/wasm-parity-3
Jul 18, 2026
Merged

fix(wasm): three Python-vs-Wasm parity fixes (parse_float helper, capability error text, variant receiver)#80
nelsonduarte merged 3 commits into
mainfrom
fix/wasm-parity-3

Conversation

@nelsonduarte

Copy link
Copy Markdown
Owner

Three pre-existing parity bugs surfaced by the codegen work in #78/#79. Each passes capa --check and behaves one way on Python while the Wasm backend rejects it or diverges. Each was sibling-swept before fixing, and each is a separate commit.

1. parse_float was unusable on Wasm unless the program also formatted a Float

error: unknown func: failed to find name $pow10_i32, whether the result was bound or discarded. $pow10_i32 was gated on format_str AND float_format, but parse_float pulls in the bignum helpers whose $bn_mul_pow10 calls it. Emission now lives inside _emit_bignum_helpers behind an idempotent latch, so every caller of the bignum family pulls it in exactly once. That also covers parse_json of a float literal, which was a second instance of the same breakage.

Guard: a feature-agnostic WAT closure test over an 11-program corpus asserts called - defined - imported is empty, so any future callable-but-unemitted helper fails CI. Proven to bite by withholding the helper. parse_float moved out of the sweep's exemption list, and the parity probe no longer masks the bug by always formatting the Float.

2. Fs/Db/Proc error text diverged between the Python and Wasm hosts

fs.read of a missing file gave failed to read 'x': [Errno 2] ... on Python but a bare [Errno 2] ... on Wasm; deny messages were terser and dropped the allowed-prefix cause. The sweep found the same divergence across fs.read/write/mkdir/list_dir and the Fs/Db/Proc deny paths, on both the core and component hosts. net.* already had parity because its binder delegates to the Python object, which is the template.

Both hosts now wrap the OSError identically and route denies through the shared Fs._deny/Db._deny/Proc._deny. This is a text change only: each host keeps its own syscall for its TOCTOU and sandbox guarantees, and a real out-of-prefix read still denies with no leak. The --wasi compiled path is deliberately untouched, since it has a documented path-less contract with discriminant-only parity.

3. A method call or match on a payloadless-variant receiver failed on Wasm

let l = Leaf then l.val_of() raised MethodCall on receiver of type 'Leaf', and match l raised the scrutinee equivalent. The analyzer types the binding as the sum; the lowerer narrows it to the variant name, and the dispatch, trait-call and match lookups keyed on that name.

Fixed consumer-side only, resolving the head through _variant_to_sum exactly as _equality._normalize_eq_ty already does. The lowerer's variant-name type is left intact, because it is the load-bearing signal that a value is a payloadless-variant pointer for the encoding, tuple, struct and value sites. Three lookup sites needed it, including _emit_trait_method_call, which re-derives the receiver type and was proven load-bearing by neutralizing it in isolation.

Verification

Suite 4257 passed, 18 skipped. Adversarially reviewed per ticket: the closure guard and the third dispatch site were each proven to bite, deny enforcement was re-verified as unweakened, and the variant-in-tuple/list/struct/Result/equality positions were re-checked for regressions.

parse_float lowers to $parse_float, whose hard-rounding slow path calls
$bn_mul_pow10, which in turn calls $pow10_i32. That helper was emitted
only under the Float-format gate (_uses_format_str AND _uses_float_format),
so a program that parses a Float but never interpolates one left
$pow10_i32 undefined and the Wasm module failed to assemble with
"unknown func: failed to find name $pow10_i32", whether the parse result
was bound or discarded.

Give $pow10_i32 its own idempotency latch (_pow10_emitted) and have
_emit_bignum_helpers pull it in, mirroring how the Grisu float-format
path already emits it. Over-emitting an unused helper is harmless dead
code; a called-but-unemitted one is a hard build failure, so err toward
over-emission.

Tests:
- Un-exempt parse_float from the discarded-call sweep and add it to the
  free-function recipes so it is compiled + validated.
- Add TestWatCallClosure, a feature-agnostic guard asserting no emitted
  module calls a function it neither defines nor imports, over a corpus
  that lights up each gated helper family (including parse_float without
  any Float format). Confirmed it bites when $pow10_i32 is withheld.
- Add a no-format parse_float cross-backend parity case (the existing
  probe always formatted the Float, masking the bug).
Reading a missing file printed "failed to read 'x': [Errno 2] ..." on
the Python backend but a bare "[Errno 2] ..." on both Wasm hosts,
because the host binders passed the raw str(OSError) as the whole
message and dropped the "failed to <op> '<path>'" wrap. Permission
denials were terser too: they used "<op>: <path>" with no restriction
cause, where Python names the op, the path repr, and the current
allowed-prefix / restriction set via the shared <Cap>._deny helpers.

Mirror the Python wording in both capa/runtime/_wasm_host.py and
capa/runtime/_wasm_component_host.py:
- OSError arms now build message=f"failed to <op> {path!r}",
  cause=str(e), matching capa/runtime/_capabilities.py. The component
  host also stops using type(e).__name__ as the cause.
- Every deny arm (pre-check and post-open) now routes through the
  shared Fs._deny / Db._deny / Proc._deny so the message and the
  restriction cause are byte-identical to the Python backend.

The operation itself is unchanged: each host keeps its own syscall for
its TOCTOU / sandbox guarantees, only the surfaced text is aligned. The
--wasi compiled path (capa/ir/_emit_wasm/_wasi/_fs.py) is deliberately
left alone; its message is a documented path-less contract with a
discriminant-only parity test.

Adds TestCapErrorMessageParity: runs the same fs read/write/mkdir/
list_dir failure and one deny per cap on the Python backend, the core
WasmHost, and the Component Model host, asserting all three print the
identical Err payload. Existing TOCTOU substring tests still pass.
A payloadless variant literal bound by an unannotated let/var
(``let l = Leaf`` for ``type Tree = Leaf | Node(Int)``) is typed by the
lowerer as the VARIANT name (``Leaf``), not the owning sum (``Tree``).
The method-table, the multi-impl candidate table, and the sum-layout
table are all keyed by the sum, so a method call (``l.val_of()``) raised
"MethodCall on receiver of type 'Leaf'" and a match on the same binding
raised "Match on scrutinee of type 'Leaf'". Both ran fine on the Python
backend; only the Wasm emitter diverged.

Resolve the variant head to its owning sum via ``_variant_to_sum`` at
the consumer lookups, mirroring what _equality._normalize_eq_ty already
does for ==:
- _dispatch.py: normalise recv_head before the method-table /
  multi-impl routing gate.
- _traits.py: normalise recv_ty in _emit_trait_method_call, which
  re-derives the receiver type and performs the authoritative lookup.
- _match.py: normalise the sum head before the sum-layout lookup.

The lowerer's ``.ty = variant-name`` is deliberately relied on as the
payloadless-variant heap-pointer signal by the encoding / tuple /
struct / value sites, so it is left intact; only the three lookups
learn to fall back through _variant_to_sum. The variant-in-aggregate
positions (variant in tuple / list / struct / Result, and equality)
are unchanged and still pass.

Tests: emitter-only cases in TestWasmEmissionShape (the method call and
the match no longer raise; $Tree_val_of is emitted) and execution cases
in TestWasmSumAndStruct for both the let and var forms.
@nelsonduarte
nelsonduarte merged commit ed9f1b3 into main Jul 18, 2026
14 checks passed
@nelsonduarte
nelsonduarte deleted the fix/wasm-parity-3 branch July 18, 2026 14:27
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.

1 participant