Skip to content

fix(#952): a declined REQUESTED export now exits non-zero on --all-exports - #960

Merged
avrabe merged 2 commits into
mainfrom
fix/skipped-export-exit-952
Aug 13, 2026
Merged

fix(#952): a declined REQUESTED export now exits non-zero on --all-exports#960
avrabe merged 2 commits into
mainfrom
fix/skipped-export-exit-952

Conversation

@avrabe

@avrabe avrabe commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Summary

A function synth DECLINES was dropped from the output object with exit 0: a build gating on $? (every build — that's the point) accepted an object silently missing a public entry point. This closes that gap while preserving the existing asymmetry: skipping a non-exported internal helper (pulled in only for #235 reachability) stays a routine warning-only skip; only a decline of a function the module actually (export ...)s now fails the build.

  • compile_all_exports now tracks, per skipped function, whether func.export_name.is_some(). If any REQUESTED export was skipped, the compile anyhow::bail!s with a nonzero exit — placed after the existing compiled_funcs.is_empty() bail, so a module whose sole export was skipped keeps its pre-existing "nothing to emit" message rather than being relabeled.
  • New opt-in --all-exports-scoped flag --allow-skipped-exports restores the pre-A declined export exits 0 with the symbol simply absent — the decline is honest to stderr but invisible to the build system #952 exit-0 behavior for callers who genuinely want the partial object (the --all-exports corpus-sweep shape, where per-function declines are expected and counted downstream, not treated as a build failure). Inert on the single-function --func-index/--func-name path, which already hard-errors on any decline — documented as such in the flag's doc comment.
  • The gated path writes no object (the bail happens before ELF emission). Every caller updated below inspects the object only under --allow-skipped-exports, so nothing reads a missing file.

Red-first numbers

New test: crates/synth-cli/tests/skipped_export_exit_952.rs.

  • RED (declined_requested_export_exits_nonzero): gale's exact [CRITICAL] thumb-2 silently miscompiles any call with an i64 parameter that is not last — args land in the wrong registers, exit 0, no warning #929 repro (f, the module's sole export, gets declined). Anchors on stderr.contains("skipping function 'f'") before asserting the exit code, so the test can't pass vacuously. Mutation-tested: reverted just the if !skipped_exports.is_empty() { gate to if false && … (anchor grep -c = 1 before mutating) — the RED test fails with the gate off, passes with it on.
  • NEGATIVE CONTROL (skipped_nonexported_helper_still_exits_zero): a fixture where an exported f calls a non-exported helper that itself calls a second non-exported helper with an unsupported f64 op — both helpers get skipped (reachable_from_exports pulls them in only via the call chain), f compiles fine via an external relocation to the missing symbol. Passes both before and after the fix (verified against a separately-built pre-fix baseline binary). Anchors on the skip actually having occurred ("skipping function" + "were skipped" present) so the control isn't vacuous — a fixture where the "helper" is simply never compiled (unreached) produces no skip at all and would prove nothing.
  • --allow-skipped-exports (allow_skipped_exports_restores_exit_zero): same RED fixture, flag added, exits 0, decline still warns.

Blast radius

grep -rn "all-exports" .github/workflows/ci.yml → 30 lines. Replayed every one directly against the fixed binary (28 distinct invocations, including the multi-line steps sed doesn't catch on a single-line grep) — all pass unchanged, because none of those specific fixtures currently trigger a skip of a requested export.

The real hits were not in ci.yml's inline commands but in test/oracle files that sweep corpora expecting some functions to decline while asserting overall compile success — six files needed --allow-skipped-exports added:

  • crates/synth-cli/tests/call_indirect_275_selfcontained.rstest_275_selfcontained_a32_still_declines_loudly expects the sole export entry to be loud-skipped (residual A32 arm backend compiles only exports, not the reachable call graph - blocks self-contained firmware #275 decline) with exit 0, to inspect the partial object's symtab.
  • crates/synth-cli/tests/i64_call_arg_decline_929.rs — both [CRITICAL] thumb-2 silently miscompiles any call with an i64 parameter that is not last — args land in the wrong registers, exit 0, no warning #929 decline tests compile a module whose sole export is the declined function, asserting compile success to read the diagnostic text.
  • crates/synth-cli/tests/rv32_local_promo_flip_472.rs — found by the workspace test run, not the ci.yml grep: its RV32 corpus includes gust_kernel.wasm, whose exported gust_poll already declines on RV32 for an unrelated pre-existing reason (GlobalGet unsupported in the RV32 skeleton) in both arms of the sweep. The gate compares per-function byte sizes across arms and is unaffected by a function absent from both.
  • scripts/repro/i64_param_518_riscv_loudskip.py — literally titled "loudskip"; every I64_PARAM_FNS entry is a named export this oracle expects declined, with sys.exit(f"riscv compile failed (expected success with skips): …") on nonzero exit.
  • scripts/repro/call_indirect_275_selfcontained_differential.py — python twin of the Rust A32-residual-decline case above.
  • scripts/repro/wast_conformance_928_differential.py — the RQ-56-CONF corpus sweep over tests/wast/ (381 assert_return/2 assert_trap assertions), explicitly named in A declined export exits 0 with the symbol simply absent — the decline is honest to stderr but invisible to the build system #952 itself as the shape --allow-skipped-exports exists for. Verified byte-for-byte: built a separate baseline binary from origin/main's main.rs + the original (unpatched) script, ran it, then ran the fixed binary + patched script — both print exactly #928 CHECKS=240/240 executed assertions over 25 wast files; 141 declined / declined by reason: emu-error:...=18, emu-error:...=5, i64-pair=118, identical down to the reason breakdown.

Cleared without changes (each independently re-run against the fixed binary):

  • Eight oracle scripts referencing "skipping function"/"were skipped" text whose healthy state has zero skips (framebacking_i64param_837, i64_spill_pool_587, rv32_label_882, aarch64_float_completion_851, vfp_spill_881, i64_param_518_differential, i64_stack_param_503, call_indirect_275_selfcontained_execution_differential) — a regression there already fails today via a different, more specific message; this PR doesn't change what they exercise.
  • multi_memory_406.rs's three loud-skip tests (cross_memory_copy_loud_skips, non_default_memory_fill_loud_skips, wide_access_on_non_default_memory_loud_skips) — each fixture's sole export is the declined function, so they already fail today via the pre-existing compiled_funcs.is_empty() bail (unchanged message, unaffected by this PR's new gate since that check runs first).
  • scripts/repro/vcr_ra_003_rv32_alloc_validator.py — also sweeps gust_kernel.wasm on RV32, but its own logic already treats a nonzero exit as "compile failed for a non-validator reason, skip" rather than a gate failure; re-run against the fixed binary, still 0 false-positives, gust_kernel.wasm now shows as a skip line where it previously silently attempted validation on a partial object.
  • scripts/repro/fact_spec_mask_494_differential.py — fails today with identical output (28 B -> 28 B, non-shrink) against both the pre-fix baseline and the fixed binary; a pre-existing, unrelated environment gap, not a regression from this change.
  • scripts/repro/{frame_slot_dce,gust_spill_fwd_390,vcr_dec_001_graph_alloc}_differential.py — also touch gust_kernel.wasm, but on the ARM backend, where the RV32-only GlobalGet gap doesn't apply; all re-run PASS unchanged (vcr_dec_001_graph_alloc_differential.py explicitly shows gust_kernel.wasm applied=2 … diverged OK).

Gates

  • cargo fmt --all --check — clean
  • cargo clippy --workspace --all-targets -- -D warnings — exit 0
  • cargo test --workspace --no-fail-fast — 136/136 test-result blocks ok, 0 failed (this is where rv32_local_promo_flip_472 surfaced and was fixed)
  • cargo test -p synth-cli --test frozen_codegen_bytes — 10/10 (no bytes changed)
  • cargo build -p synth-cli --features verify / --features exports_only_275_probe / --features riscv --test async_intrinsics_gate — all clean

Closes #952

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

…ports

A function synth DECLINES was dropped from the output object with exit 0: a
build gating on `$?` (every build) accepted an object silently missing a
public entry point. Declining a non-exported internal helper (#235
reachability) stays a routine warning-only skip — only a decline of a
function the module actually `(export ...)`s now fails the build.

- `compile_all_exports` tracks, per skipped function, whether
  `func.export_name.is_some()`; if any REQUESTED export was skipped the
  compile now `anyhow::bail!`s (placed after the existing
  `compiled_funcs.is_empty()` bail, so a module whose sole export was
  skipped keeps its current "nothing to emit" message).
- New opt-in `--allow-skipped-exports` restores the pre-#952 exit-0 behavior
  for callers who genuinely want the partial object (an --all-exports
  corpus sweep, where per-function declines are expected and counted
  downstream). Inert on the single-function --func-index/--func-name path,
  which already hard-errors on any decline.
- Red-first integration test crates/synth-cli/tests/skipped_export_exit_952.rs:
  a declined export exits non-zero (RED before the fix), a negative control
  (skipping only a non-exported helper) stays exit 0 both before and after,
  and --allow-skipped-exports restores exit 0.

Blast radius: six existing tests/oracles compile corpora expecting some
functions to decline while asserting overall success, and needed
--allow-skipped-exports added — crates/synth-cli/tests/{call_indirect_275_
selfcontained,i64_call_arg_decline_929,rv32_local_promo_flip_472}.rs and
scripts/repro/{i64_param_518_riscv_loudskip,call_indirect_275_selfcontained_
differential,wast_conformance_928_differential}.py. The wast conformance
sweep (scripts/repro/wast_conformance_928_differential.py) was verified to
produce byte-identical CHECKS=240/240 / declined-by-reason output against a
separately-built pre-fix baseline binary.

Closes #952

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

codecov Bot commented Aug 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.23529% with 4 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
crates/synth-cli/src/main.rs 88.23% 4 Missing ⚠️

📢 Thoughts on this report? Let us know!

…eclines

Making a declined REQUESTED export fatal is the point of this PR, but it also
breaks every downstream consumer that had been silently relying on exit 0.
CI surfaced them one at a time because the ledger step is `set -e`: the first
run died on unreachable_665, the second on i64_globals_643, with 17 of >=32
oracles never reaching the ledger.

Rather than iterate one CI round per oracle, ran all 64 oracles from the two
failing jobs locally against this branch AND against main. The difference is
the blast radius, and it is exactly five:

  unreachable_665           rv32 skips `guarded`  (if/else arity, #343)
  i64_globals_643           rv32 skips 6 i64-global exports
  i64_float_conv_869        arm  skips the i64<->float family off m7dp
  trunc_sat_782             arm  skips the trunc_sat family off m7dp
  float_select_return_782   trips its own "compile must succeed" control

The main baseline matters: 9 of the 14 failures on this branch also fail on
main (local env, not this change). Without that control I would have reported
14 and "fixed" 9 things that were never broken.

Each of the five gets `--allow-skipped-exports` — the corpus-sweep shape the
flag was added for. That flag SILENCES the gate this PR exists to add, so it
is only safe where something else still notices a NEW decline. Here that is
the non-vacuity floor, and every one of the five has one with a tight margin:

  unreachable_665          floor 8       (measured 0 when it broke)
  i64_globals_643          floor 28      normal 28  — exact
  float_select_return_782  floor 702     normal 702 — exact
  i64_float_conv_869       floor 96276   normal 96296
  trunc_sat_782            floor 192672

A new decline removes executed emulations and drops the count under the floor,
so the oracle still goes red — the flag tolerates the declines we know about
without tolerating the ones we don't. A blanket sweep of the flag across all
131 `--all-exports` oracles would NOT have that property, which is why this
touches five files and not 131.

Verified: all five pass with the flag, and the four with reported counts meet
their floors (i64_float_conv_869 at 96296 >= 96276).

Refs #952

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
@avrabe
avrabe merged commit c2f9d72 into main Aug 13, 2026
58 checks passed
@avrabe
avrabe deleted the fix/skipped-export-exit-952 branch August 13, 2026 17:37
avrabe added a commit that referenced this pull request Aug 13, 2026
#966)

#960 merged AFTER the v0.56.2 tag (`git merge-base --is-ancestor` confirms),
so `main` carries a user-visible behavior change — a declined REQUESTED export
now exits non-zero — that no released version contains. The issue was
auto-closed on merge, so without this entry the next release cut would depend
on someone re-reading an issue comment to notice it.

Also records why five oracles took `--allow-skipped-exports`: the flag
silences the gate #952 adds, so it is only sound where a tight non-vacuity
floor still catches a NEW decline. All five margins measured, not assumed —
unreachable_665 is 8 executed / 8 floor (2 rows loud-decline), i64_globals_643
28/28, float_select_return_782 702/702, i64_float_conv_869 96296/96276.

Refs #952


Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
avrabe added a commit that referenced this pull request Aug 14, 2026
…MS (#970) (#972)

Six artifacts still read `proposed` while their work is on main. Re-graded to
`implemented`, so the release-readiness query means something:

  RQ-57-SKIPEXIT   #952  (#960)   declined REQUESTED export exits non-zero
  RQ-57-PROVGAP    #944  (#967)   verified origins for introduced branches
  RQ-57-DOCSWEEP   #946  (#968)   Tiers 2+3 honesty sweep
  RQ-57-SPWILD     #946  (#969)   writes_sp 175-of-222 wildcard expanded
  RQ-57-A64PARAM   #851  (#971)   aarch64 written-param homing
  RQ-561-ZEROMEM   #953  (v0.56.1) shipped two releases ago, never re-graded

NEW: RQ-57-COUNTPARAMS (#970). It has no artifact because nobody planned it —
the #851 lane found it while fixing the aarch64 instance of the same shape.
An unplanned finding with no artifact is invisible to the release query, which
is exactly how #933 slipped a release, so it gets one now.

What it records that the issue alone does not:

  * The severity differs by BACKEND and both halves are stated at the
    confidence they were established. RISC-V is EXECUTED-confirmed: with a
    poisoned stack the function returns 0xDEADBEEF — an UNINITIALISED stack
    slot, i.e. previous frame contents. That is information disclosure, not a
    wrong value. ARM's exact wrong value was INFERRED FROM DISASSEMBLY, never
    executed; the artifact says so, because the #851 lane's first draft stated
    it as measured and the advisor caught it.
  * ARM is only INCIDENTALLY correct on the simple shape (a merge-point
    `str r1,[sp]` catches the still-live param) and breaks once a call
    clobbers the param register — so "ARM looks fine" is not evidence.
  * Red-first must be per-backend BY EXECUTION. Assuming one backend's
    evidence transfers is what left this latent after the aarch64 fix.
  * The named residual survives: the `None` branch of
    `current_func_param_count` still uses the unsound heuristic — unreachable
    from the CLI, reachable via the direct `compile_function` API.

Per the user's direction this ships in v0.57 rather than as a v0.56.3 patch.

rivet: errors unchanged (50 before and after); warnings 164 -> 166, which is
the two every artifact in this file carries (trailer-reference naming, and the
`verifies` link that lands with the test) — verified identical for
RQ-57-SPWILD and RQ-57-GPIO, so this is one more artifact of the same shape,
not a new defect. claim_check 43/43.

Refs #970


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.

A declined export exits 0 with the symbol simply absent — the decline is honest to stderr but invisible to the build system

1 participant