Skip to content

RQ-59-STARTFN (#1046): the (start ...) section was silently discarded by ALL THREE backends — decode it and refuse loudly; fourth silent drop (globals inits, #1052) found and filed - #1053

Merged
avrabe merged 2 commits into
mainfrom
fix/start-section-silent-drop-1046
Aug 26, 2026

Conversation

@avrabe

@avrabe avrabe commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

A FOURTH silent drop was found and FILED first — it outranks this fix

#1052 — the ARM plain-relocatable path silently drops nonzero GLOBAL INITIALIZERS, and it is OUTSIDE the documented embedder contract (verdict + executed repro below). Filed with the repro executed on the v0.59 tree per the RQ-59-DATASEG lane's open-observation handoff. The element-segment sibling observation resolves the other way: inside the contract, language quoted below.


Closes the third silent drop of the session: all three backends discarded the (start ...) section — the decoder had no Payload::StartSection arm at all, so the section fell through the catch-all. #1041 (data segments), #1046 (this), #1048 (i64-shift operand): synth accepted input, discarded part of its semantics, and reported success.

Start-function INVOCATION is deliberately NOT this artifact — it is a capability question (the self-contained Reset_Handler could call it before any export; the relocatable contract would need an exported init hook). This PR is the honest-frontier minimum: refuse loudly, the #851/#1049 house shape.

Rivet: RQ-59-STARTFN — the artifact entry lives on PR #1050 (plan/v59-i64shift-startfn), not yet on main; this branch is off main and does not duplicate it.

Red-first transcript

Before (main, 18a4786) — the filed repro ((start $init) writes 42 to memory 0; wasmtime get() == 42):

$ synth compile start_repro.wat --all-exports --relocatable --target cortex-m4 -o start_arm.o
INFO Found 1 exported functions: 'get' (index 1)      # $init never compiled
INFO Generated 431 byte ELF file
ARM exit=0                                            # no warning, no mention of start
# same on -b riscv -t rv32imac (exit 0) and -b aarch64 (exit 0)

After (this branch):

$ synth compile start_repro.wat --all-exports --relocatable --target cortex-m4 -o start_arm.o
Error: module declares a start function (function index 0), but no synth backend invokes it —
the (start ...) section's instantiation-time initialization (WASM Core §4.5.5: it runs before
any export is callable) would silently never run, and every export would read state the start
function was supposed to establish; refusing (#1046). Start-function invocation is a documented
capability follow-on; until it lands, remove the (start ...) section and perform the
initialization through an export your embedder calls first.
ARM exit=1

Same refusal on ARM self-contained, cortex-r5 (A32), RISC-V, AArch64, the single-function path, and the WAST merge path.

The assertion shape is the point (commit 1, red on its parent): the test asserts a non-zero exit + a reason string naming the start section — it deliberately does NOT assert "the start function was not called", which was already true on the broken behaviour and would have been vacuously green. All 7 refusal tests failed on main; the start-free control passed throughout; all 8 pass now.

No escape hatch — deliberately unlike #1049's --embedder-data-init: the start function's CODE is not in the artifact (reachability walks exports only), so there is nothing an embedder could call. A (start) naming an exported function still refuses (nothing invokes it at instantiation; test pins this).

Verdict on the two open RQ-59-DATASEG observations

1. Nonzero global initializers on the plain relocatable path — OUTSIDE the contract. Fourth silent drop. Filed as #1052.

Executed repro: (global $g (mut i32) (i32.const 42)) + global.get export, --relocatable --target cortex-m4 → exit 0, and the whole object is push {r4,lr}; ldr.w r0,[r9]; pop {r4,pc} — 0x2A appears nowhere in the ELF, no .data, no __synth_globals. wasmtime: 42.

What the contract actually documents is the BASE, not the values: the selector says "R9 = globals base"; the only relocatable-path globals harness (i64_globals_643_differential.py) maps "a zeroed scratch region" and annotates it "# zeroed globals table (inits are 0)" — correct only because that fixture's inits ARE zero. There is no sentence anywhere assigning initializer evaluation to the embedder — contrast the data-segment sentence that exists ("the embedder populates its init segments", multi_segment_static_data_differential.py). Every other path handles inits explicitly: ARM self-contained materializes them at reset (#649 — which treated exactly this class as a BUG), --native-pointer-abi ships them as .data, aarch64 ships __synth_globals ("explicitly NOT preconditions: synth EMITS both" — FEATURE_MATRIX), RV32 loud-skips. The plain relocatable path alone neither ships, nor declines, nor documents.

2. Active element-segment table images — INSIDE the contract. call_indirect_594_differential.py: "ArmOp::CallIndirect contract (same as the Thumb-2 path): R11 holds the function-pointer table base; entry i is a 4-byte code address. The harness builds that table with func_0's address"; encoder: "Table base setup must be done by caller/runtime." The table entries are final linked code addresses that cannot exist in an ET_REL object as runtime-RAM contents; the object exports every func_N the segments name (verified on the executed elem repro: func_0, func_1, pick all in the symtab), and the embedder holds the module's segment layout. Building the table is the integrator's documented step. (One-line residual noted in #1052: the #676 type-id sidecar's structural-class-id derivation rule is documented only in wasm_decoder.rs doc comments.)

Decoder section class sweep (decode_wasm_module)

Section Status Loud?
Type / Import / Function / Table / Memory / Global / Export / Element / Data (active) / Code handled n/a
Start was DISCARDED SILENTLY — the #1046 defect now recorded + refused (this PR)
Data, active non-const offset recorded reason refused: aarch64 #851; ARM relocatable via PR #1049
Data, passive no record kept op-guarded: memory.init/data.drop hit convert_operator → None → per-function LOUD-SKIP with the op named (#369 lane) — semantics unreachable without the ops
Element, passive/declared/non-const recorded as statically-unverifiable call_indirect guard declines; targets still enter reachability
Tag (exception handling) no arm, silently ignored op-guarded: throw/try/... loud-skip per function via the same #369 lane — a tag with no using op is inert
DataCount no arm safe: pure validation metadata (count only), zero runtime semantics
Custom (non-name, non-wsc.facts) ignored safe by spec: custom sections are non-semantic
Version / CodeSectionStart / End structural n/a
Component-model payloads (ModuleSection, ComponentStartSection, ...) no arms probed: a (component) binary ERRORS ("No exported functions found in module") — not silent, but the diagnostic is misleading; components route through synth-frontend. Residual, not a drop

The defect class was specifically: a section whose semantics are reachable with NO operator involvement (start, active data, global inits) — those are invisible to the #369 op-level loud-skip lane and need section-level handling. Start was the last unhandled one in this decoder; global inits (#1052) are its sibling one layer up (decoded but never emitted on one path).

Harness impact

0 harnesses/fixtures affected. Swept three ways: grep -E '\(start[[:space:]]+[$0-9]' over all .wat/.wast/.rs/.py (only this PR's own test), a binary scan of all 21 in-repo .wasm files for section id 8 (none), and the full workspace suite (green). EXPECTED_DECLINES (#992) unchanged — it keys on scripts/repro/*.wat, none of which carry (start). No escape-hatch flag needed (and none would be honest — see above). Frozen anchors: emitted bytes for start-free modules are untouched (the guard only fires on Some); all frozen-fixture differentials in the suite pass. synth verify (analysis, no artifact) is deliberately not gated, matching #1041's compile-only scope.

Gates

Refs #1046. Fourth-drop filing: #1052.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L

avrabe and others added 2 commits August 25, 2026 23:28
…le, asserted as a loud refusal naming the start section

All 7 refusal cases FAIL on current main (exit 0, no warning, start
function absent from the object); the start-free control passes. The
assertions deliberately name the refusal (non-zero exit + reason string
containing 'start function' and '#1046'), NOT the absence of invocation —
which was already true on the broken behaviour and is exactly why the bug
survived every existing test.

Refs #1046

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
…ery compile path — the decoder had no StartSection arm at all

The section fell through the catch-all and was discarded outright: all
three backends compiled a (start)-carrying module, exited 0, printed no
warning, and the start function was not even in the object. wasmtime runs
it at instantiation (WASM Core §4.5.5) and returns 42 on the repro;
synth-compiled code read never-initialized memory and returned 0.

- wasm_decoder: Payload::StartSection arm + DecodedModule.start_function
- main.rs: refuse_dropped_start_function at all three compile decode
  sites (module path, single-function path, WAST merge path) — non-zero
  exit + a reason naming the start section, the #851/#1041 refusal shape
- deliberately NO escape hatch: the start function's code is not in the
  artifact, so no embedder contract can honor it (unlike #1041's
  --embedder-data-init, where the embedder holds the bytes)
- start-function INVOCATION is a capability follow-on, not this fix

0 fixtures/harnesses affected (no .wat/.wast/.wasm in the repo carries a
start section — swept textually AND by binary section id); emitted bytes
for start-free modules are unchanged (guard only fires on Some), frozen
anchors untouched; EXPECTED_DECLINES (#992) unchanged (keys on
scripts/repro/*.wat, none of which carry (start)).

Red-first: the 7 refusal tests in start_section_refusal_1046.rs failed on
the parent commit and pass now; the start-free control passes throughout.

Gates: fmt, clippy -D warnings, cargo test --workspace (true exit 0),
claim_check 50/50, model_coverage_audit ok, rivet validate 50 errors
(= main baseline, #1012).

Refs #1046

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
@codecov

codecov Bot commented Aug 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@avrabe
avrabe merged commit c960903 into main Aug 26, 2026
62 checks passed
@avrabe
avrabe deleted the fix/start-section-silent-drop-1046 branch August 26, 2026 00:56
avrabe added a commit that referenced this pull request Aug 26, 2026
…inks, and a duplicate `issue:` key broke the typed parse

My conflict resolution spliced RQ-59-I64SHIFT INTO the middle of
RQ-59-DATASEG's mapping — between its `tags:` and its `links:`/`fields:` —
so DATASEG silently lost its trace links to BR-001 and I64SHIFT inherited
them, and appending `issue: "#1048"` produced a DUPLICATE KEY next to
`issue: "#1041"` in one mapping.

WHY THE CHECK MISSED IT: I validated the resolution with `yaml.safe_load`,
which SILENTLY KEEPS THE LAST VALUE on a duplicate key and reported
"YAML OK". rivet's serde-typed loader rejects it, and reports the position
of the FIRST top-level key (`unknown field 'metadata' ... line 56`), which
reads like a header problem 700 lines away from the actual fault.
A permissive parser is not an oracle for a typed schema.

Baseline-compared rather than read absolute (rivet FAILS on main by
construction — the federated half is unresolved, #1012):
  main:            50 errors, OURS=0
  before this fix: 51 errors, OURS=1   <- the parse error
  after this fix:  50 errors, OURS=0   <- back at baseline

Also flips RQ-59-STARTFN proposed -> implemented: its fix merged as
c960903 (#1053) with a real `Payload::StartSection` decoder arm and a
refusal test. RQ-59-I64SHIFT stays `proposed` — PR #1054 is still open.

Verified with a duplicate-key-STRICT YAML loader as well as rivet:
16 artifacts, no duplicate ids, all three of DATASEG/I64SHIFT/STARTFN
carry their own links and the right issue number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
avrabe added a commit that referenced this pull request Aug 26, 2026
…59-STARTFN (#1046) (#1050)

* plan(v0.59): RQ-59-POPCNT implemented; scope two more shipped miscompiles found by the census lanes

POPCNT: #1039 merged — i32.popcnt no longer clobbers R11. Verified live on main:
the expansion runs on R12 alone and BOTH backends now carry a loud guard
("Popcnt destination must be R0-R10: R11 is the linear-memory base").

TWO NEW FIX-FIRST ARTIFACTS, both shipped miscompiles, both found by lanes that
were scoped to MEASURE rather than to hunt:

RQ-59-I64SHIFT (#1048) — the i64 variable-shift expansions clobber their own
shift-amount operand. Thumb-2 and A32 both mask the amount IN PLACE (& 63) and
write amt-32 into the amount's home HIGH register, so re-reading the amount
after i64.shl/shr_u/shr_s returns a mangled value. Executed, both selectors:
amt=64 returns 1 where 65 is correct; amt=67 returns 11 where 75 is correct.

  Same class as #1021, DIFFERENT REGISTER TIER: #1021 clobbered a reserved
  global (R11); this clobbers the CALLER'S OWN OPERAND. That is why
  TIERCENSUS's reserved-register subset was {Popcnt} and still did not bound
  the risk — the operand tier is outside what that subset measures.

  And the observation that matters most: the wired i64_shr_599 differential
  PASSES on the very binary where this miscompiles. The oracle is structurally
  blind to the class — which is the argument for VCR-TIER-001's canary gate
  (v0.60), a guard that would have caught BOTH.

RQ-59-STARTFN (#1046) — all three backends silently drop the (start) function.
The decoder has NO Payload::StartSection arm. wasmtime returns 42 on the repro;
synth-compiled code returns 0, exit 0, no warning, on ARM, RISC-V and AArch64.

  THIRD silent-drop of the same shape in one session, after #1041 (ARM
  relocatable discarding active data segments) and alongside #1048. Minimum fix
  is a loud refusal matching the aarch64 #851 style; implementing start-function
  invocation is capability work and not this artifact. The DATASEG lane also
  reported two softer same-class observations (non-zero global inits, active
  element-segment table images) that may sit inside the documented R9/R11
  embedder contract — the lane must determine which side each falls on, and a
  fourth silent drop would outrank the fix.

v0.59 scope is now 15 artifacts. claim_check green; rivet validate 50 errors,
identical to main's #1012 baseline (adds none).

Refs #1021, #1048, #1046, #242.

* fix(rivet): repair the RQ-59-I64SHIFT splice — DATASEG had lost its links, and a duplicate `issue:` key broke the typed parse

My conflict resolution spliced RQ-59-I64SHIFT INTO the middle of
RQ-59-DATASEG's mapping — between its `tags:` and its `links:`/`fields:` —
so DATASEG silently lost its trace links to BR-001 and I64SHIFT inherited
them, and appending `issue: "#1048"` produced a DUPLICATE KEY next to
`issue: "#1041"` in one mapping.

WHY THE CHECK MISSED IT: I validated the resolution with `yaml.safe_load`,
which SILENTLY KEEPS THE LAST VALUE on a duplicate key and reported
"YAML OK". rivet's serde-typed loader rejects it, and reports the position
of the FIRST top-level key (`unknown field 'metadata' ... line 56`), which
reads like a header problem 700 lines away from the actual fault.
A permissive parser is not an oracle for a typed schema.

Baseline-compared rather than read absolute (rivet FAILS on main by
construction — the federated half is unresolved, #1012):
  main:            50 errors, OURS=0
  before this fix: 51 errors, OURS=1   <- the parse error
  after this fix:  50 errors, OURS=0   <- back at baseline

Also flips RQ-59-STARTFN proposed -> implemented: its fix merged as
c960903 (#1053) with a real `Payload::StartSection` decoder arm and a
refusal test. RQ-59-I64SHIFT stays `proposed` — PR #1054 is still open.

Verified with a duplicate-key-STRICT YAML loader as well as rivet:
16 artifacts, no duplicate ids, all three of DATASEG/I64SHIFT/STARTFN
carry their own links and the right issue number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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