fix(beam): entry point argv and exit code, bigint from float, option reflection#4772
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Three Beam bugs found by compiling Scriptorium's
Scriptorium.Hedgehogsuite 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/Beamdid any of those.1. The generated
mainshim was never executedSince module names became app-qualified (#4770), a project's
[<EntryPoint>]lives in<app>_<file>:main/1and Fable emits amain.erlshim so runners have a stable entry module. The shim calledmain/0and passed no argv — an F#[<EntryPoint>]isstring[] -> int, so it always lowers tomain/1. Every Beam program with an entry point died instantly withundef.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 reacheserlang:get/1as a list, yieldsundefined, and fails withbadarg— 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/1flushes stdout, so output is not lost.)Why nothing caught it:
tests/Beamruns 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) andtests/Beam/ProgramNoEntry(top-level effects only, exercising themain/0fallback) — whichBuild.Test.Beamcompiles, runs on the BEAM, and checks the output and exit status of.2.
BigIntegerconversion from a float was the identitybigint xwas emitted asx— right for an integralx, wrong for a float, which must truncate toward zero the wayBigInteger(double)does. The float was left where an integer was required and flowed into integer arithmetic, surfacing as a barebadarith, or — for narrower ranges — as silently wrong data (all zeros) rather than an error. It broke Hedgehog'sRange.exponential*, and since the default int generator isGen.int32 (Range.exponentialBounded ()), every auto-derived value containing anint.Why nothing caught it: the existing bigint tests only convert out of bigint, and only assert values — and
3.0 == 3is 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/remreject a float), and coverfloat32, round-trips, and thescaleExponentialshape. 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.
optionwas not a union in reflectionBeam's type info for
optioncarried onlyfullname+genericsand no cases, soFSharpType.IsUnion typeof<int option>wasfalse,GetUnionCaseserrored, andMakeUnionbuilt the wrong shape. JS and Python both attachNone/Somecases; Beam now does too — with anerased_optionmarker, because Beam's option is erased (None=undefined,Some v=v), so the generic bare-atom / tagged-tuple constructor would buildnone/{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.
optionis 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 throughMakeUnionagainst natively constructed values — inspecting case metadata alone would pass with the wrong shape.Verification
2574 passed, 0 failed (
mainis 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:
badarith,not_union_type,badkeyand value mismatches;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.Hedgehogis 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