fix(beam): namespace module-level mutable state keys by module#4683
Merged
Conversation
Module-level mutable variables and snapshotted values on the BEAM target are backed by the Erlang process dictionary, keyed by the bare sanitized value name. Within one file that's safe (nested modules are already disambiguated), but across separate F# files the bare key collides: two modules each with `let mutable counter = 0` both produce key `counter`. The process dictionary is process-local and the test runner (and any real program) runs multiple modules' init in one process, so the values overwrite each other. Namespace the process-dict key by the emitting Erlang module name (`<module>_<name>`, matching the existing nested-module mangling style) via a single `mutableStateKey` helper used at every site: the IdentExpr read, the Set write, the mutable-init and snapshot-init puts, and the snapshot accessor. The module name is threaded through `Context` rather than recomputed. Cross-process reads still return `undefined` (process dict is process-local) — that's a separate, documented limitation untouched here. Also: the test runner swallowed init failures with `catch _:_ -> ok`, which masked a real bug during #4676. It now logs the caught module and reason so a broken `main/0` is diagnosable instead of surfacing later as silent `undefined` reads. 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.
Problem
PR #4676 added support for module-level mutable variables on the Erlang/BEAM target, backed by the Erlang process dictionary. A value's state is keyed by an atom derived from its name via
sanitizeErlangName, with reads/writes/initializers emittingget(key)/put(key, v).The process-dict key was the bare sanitized value name with no module qualifier. Within a single F# file this is safe — nested modules are already disambiguated. But across different Erlang modules (separate F# files) the bare name collides:
let mutable counter = 0both produce keycounter.erl_test_runner.erl) runs every module'smain/0and all tests in a single process — so the two counters overwrite each other. The same hazard exists for any real program where multiple modules' init runs in one process.This was documented as a known limitation in
FABLE-BEAM.md.Fix
Namespace the process-dict key by the emitting Erlang module name —
<module>_<name>, matching the existing nested-module mangling style — so state from different modules can't collide. A single helpermutableStateKey moduleName name(inFable2Beam.Util.fs) builds the key and is used at every site so reads, writes and the initializer always agree:IdentExprread branch (mutable ident →get),Set(IdentExpr, …)write branch (mutable ident →put),MemberDeclarationmutable-init and snapshot-initputs,name() -> get(key); the accessor function name stays bare).The module name is threaded through
Context(set once intransformFile) rather than recomputed.Other targets (Python/Rust/Dart/JS) get per-module isolation for free because each file is a real module namespace; BEAM is unique in using a flat process-dict namespace, so this aligns BEAM with their behavior.
Cross-process reads still legitimately return
undefined(the process dict is process-local) — that's a separate, documented limitation and is untouched here. This PR only fixes same-process cross-module collisions.FABLE-BEAM.mdis updated to reflect the now module-qualified keys.Generated output (after fix)
Secondary: diagnosable init failures
The test runner swallowed init failures with
catch _:_ -> ok, which masked a real bug during #4676. It now logs the caught module and reason (WARN init failed <mod>:main/0 - <class>:<reason>) so a brokenmain/0is diagnosable rather than surfacing later as silentundefinedreads. The run still does not abort on a broken initializer.Tests
ModuleMutableOneTests.fs/ModuleMutableTwoTests.fs) that each define a module-level mutable namedshared, write distinct values through each, and assert each module sees its own value (in both write orders). This fails before the fix (both keys areshared, so the second write clobbers the first) and passes after.MiscTests.fsstay green../build.sh test beam: 2462 passed, 0 failed.dotnet fantomasrun on changed F# files (CI Fantomas check).🤖 Generated with Claude Code