Skip to content

fix(beam): entry point argv and exit code, bigint from float, option reflection#4772

Merged
dbrattli merged 3 commits into
mainfrom
fix/beam-hedgehog-fixes
Jul 13, 2026
Merged

fix(beam): entry point argv and exit code, bigint from float, option reflection#4772
dbrattli merged 3 commits into
mainfrom
fix/beam-hedgehog-fixes

Conversation

@dbrattli

Copy link
Copy Markdown
Collaborator

Three Beam bugs found by compiling Scriptorium's Scriptorium.Hedgehog suite to Erlang. Each is fixed here with the test that catches it.

The suite was 2560/2560 green with all three bugs live — every one sat in a blind spot. Hedgehog made an effective canary because it is reflection-heavy, built on a splitmix64 PRNG that depends on integer wraparound, and pulled in as a package compiled from source. Nothing in tests/Beam did any of those.

1. The generated main shim was never executed

Since module names became app-qualified (#4770), a project's [<EntryPoint>] lives in <app>_<file>:main/1 and Fable emits a main.erl shim so runners have a stable entry module. The shim called main/0 and passed no argv — an F# [<EntryPoint>] is string[] -> int, so it always lowers to main/1. Every Beam program with an entry point died instantly with undef.

Forwarding the argv turned out not to be enough on its own. An F# array is a reference read with erlang:get/1, and an F# string is a UTF-8 binary, whereas a runner hands us a plain list of char lists. Passed straight through, argv reaches erlang:get/1 as a list, yields undefined, and fails with badarg — visible only in a program that actually reads its argv, which Scriptorium's does not.

The exit code has to cross back out too: an [<EntryPoint>] returning non-zero must exit non-zero, or a failing test suite reports success. (halt/1 flushes stdout, so output is not lost.)

Why nothing caught it: tests/Beam runs test functions directly; it never builds a project with an entry point and runs it end to end. The shim was dead code in the suite. So this adds two fixture programs — tests/Beam/Program (an entry point that echoes its argv and returns an exit code) and tests/Beam/ProgramNoEntry (top-level effects only, exercising the main/0 fallback) — which Build.Test.Beam compiles, runs on the BEAM, and checks the output and exit status of.

2. BigInteger conversion from a float was the identity

bigint x was emitted as x — right for an integral x, wrong for a float, which must truncate toward zero the way BigInteger(double) does. The float was left where an integer was required and flowed into integer arithmetic, surfacing as a bare badarith, or — for narrower ranges — as silently wrong data (all zeros) rather than an error. It broke Hedgehog's Range.exponential*, and since the default int generator is Gen.int32 (Range.exponentialBounded ()), every auto-derived value containing an int.

Why nothing caught it: the existing bigint tests only convert out of bigint, and only assert values — and 3.0 == 3 is true in Erlang, so a value-only assertion cannot see a float left where an integer belongs. The new tests assert integrality as well (div/rem reject a float), and cover float32, round-trips, and the scaleExponential shape. They also add splitmix64 as a bit-exactness test: a PRNG built on uint64 wraparound is the sharpest detector of fixed-width integer bugs there is.

3. option was not a union in reflection

Beam's type info for option carried only fullname + generics and no cases, so FSharpType.IsUnion typeof<int option> was false, GetUnionCases errored, and MakeUnion built the wrong shape. JS and Python both attach None/Some cases; Beam now does too — with an erased_option marker, because Beam's option is erased (None = undefined, Some v = v), so the generic bare-atom / tagged-tuple constructor would build none / {some, V}, which is not an option at runtime.

The cases are emitted as a plain list, not a thunk. Two Erlang funs from different literals never compare equal, so type info containing an inline thunk is not comparable across construction sites and typeof<'a option> = typeof<'a option> would be false. Records and unions get away with deferring because both sides call the same generated reflection function; option's cases are emitted inline at each use site. Option's only case field is the generic arg, which resolves to a call and cannot self-recurse, so there is nothing to defer. That trap is now pinned by an explicit type-equality test.

Why nothing caught it: the reflection tests cover records, unions, tuples, enums and recursive types, but never ask a built-in generic whether it is a union. option is the one type that is both a union and erased, so it falls between "user union" and "builtin type" in the matrix. The new tests round-trip through MakeUnion against natively constructed values — inspecting case metadata alone would pass with the wrong shape.

Verification

2574 passed, 0 failed (main is at 2560; 14 new unit tests), plus the entry-point program tests.

Each fix was confirmed to be load-bearing by reverting it and re-running:

  • reverting all three source fixes → 11 of the 14 new unit tests fail (6 bigint, 5 reflection) with badarith, not_union_type, badkey and value mismatches;
  • reverting the shim alone → the unit suite is still 2574/2574 green, and the new end-to-end test fails — which is precisely the blind spot this PR is about.

The three new tests that pass without the fixes are deliberate: splitmix64 is coverage rather than a regression test (fixed-width integers were fixed in #4769), and the type-equality and record-with-option tests are guards against reintroducing the thunk trap.

Downstream: with all three fixed, Scriptorium.Hedgehog is 21/21 green on Beam with no source changes on the Scriptorium side, and the other suites are unchanged (Ink 51/51, Nib 68/68, Parchment 27/27, Quill 29 + 1 legitimate skip).

Not in this PR

Two gaps this exercise exposed that are worth their own work: nothing compiles a project referencing another project/package (the multi-assembly path that hid the module-name collisions in #4770), and the end-to-end program harness could cover stdout/exit-code for more than the entry point.

🤖 Generated with Claude Code

dbrattli and others added 3 commits July 13, 2026 15:36
Erlang integers are already arbitrary-precision, so `bigint x` was emitted as
`x` — right for an integral x, wrong for a float, which must truncate toward
zero the way BigInteger(double) does. The float was left where an integer was
required and then flowed into integer arithmetic, surfacing downstream as a
bare `badarith` — or, for narrower ranges, as silently wrong data rather than
an error. It broke Hedgehog's Range.exponential, whose scaleExponential
round-trips through bigint and float, and so every auto-derived value
containing an int.

`op_Explicit` is declared on BigInteger in both directions, so a conversion
whose *target* is the bigint has to truncate rather than narrow.

The existing bigint tests only convert out of bigint, and only assert values —
and `3.0 == 3` is true in Erlang, so a value-only assertion cannot see a float
left where an integer belongs. The new tests assert integrality too (div/rem
reject a float), and cover float32, round-trips, and the scaleExponential shape.
Also adds splitmix64 as a bit-exactness test: a PRNG built on uint64 wraparound
is the sharpest detector there is of fixed-width integer bugs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Beam's type info for option carried only fullname + generics and no cases, so
FSharpType.IsUnion typeof<int option> was false, GetUnionCases errored, and
MakeUnion built the wrong shape — unlike every other target, where None (tag 0)
and Some (tag 1) are attached to the option type.

Beam's option is erased (None = undefined, Some v = v), so the generic bare-atom
/ tagged-tuple constructor would build `none` / `{some, V}`, which is not an
option at runtime. Each case therefore carries an `erased_option` marker telling
fable_reflection to build and read the native shape instead.

The cases are emitted as a plain list rather than a thunk: two Erlang funs from
different literals never compare equal, so type info containing an inline thunk
is not comparable across construction sites, and `typeof<'a option> =
typeof<'a option>` would be false. Records and unions get away with deferring
because both sides call the same generated reflection function. Option's only
case field is the generic arg, which resolves to a call and cannot self-recurse,
so there is nothing to defer.

Reflection tests covered records, unions, tuples, enums and recursive types, but
never asked a built-in generic whether it is a union — option falls between "user
union" and "builtin type" in that matrix. The new tests round-trip through
MakeUnion against natively constructed values (inspecting case metadata alone
would pass with the wrong shape), and pin type-info equality explicitly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…shim

Since module names became app-qualified (#4770), a project's [<EntryPoint>] lives
in <app>_<file>:main/1 and Fable emits a main.erl shim so runners have a stable
entry module. The shim called main/0 and passed no argv, so every Beam program
with an entry point died instantly with `undef`.

Forwarding the argv is not enough on its own: an F# array is a reference read
with erlang:get/1 and an F# string is a UTF-8 binary, whereas a runner hands us a
plain list of char lists. Passed straight through, argv reaches erlang:get/1 as a
list, yields `undefined`, and fails with `badarg` — which only shows up in a
program that actually reads its argv.

The exit code has to cross back out too: an [<EntryPoint>] returning non-zero must
exit non-zero, or a failing test suite reports success. halt/1 flushes stdout, so
the program's output is not lost.

The Beam suite calls test functions directly and never runs a program end to end,
which is how the shim stayed broken while the suite was green — it is still green
against the old shim. So this adds two fixture programs (one with an entry point,
one with only top-level effects) that Build.Test.Beam compiles, runs on the BEAM,
and checks the output and exit status of.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dbrattli dbrattli changed the title fix(beam): three bugs found compiling Hedgehog — entry point shim, bigint from float, option reflection fix(beam): entry point argv and exit code, bigint from float, option reflection Jul 13, 2026
@dbrattli dbrattli merged commit ea91554 into main Jul 13, 2026
39 checks passed
@dbrattli dbrattli deleted the fix/beam-hedgehog-fixes branch July 13, 2026 13:52
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