Skip to content

fix(beam): namespace module-level mutable state keys by module#4683

Merged
dbrattli merged 1 commit into
mainfrom
fix/beam-namespace-module-mutable-state
Jun 24, 2026
Merged

fix(beam): namespace module-level mutable state keys by module#4683
dbrattli merged 1 commit into
mainfrom
fix/beam-namespace-module-mutable-state

Conversation

@dbrattli

Copy link
Copy Markdown
Collaborator

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 emitting get(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:

  • Two files each with let mutable counter = 0 both produce key counter.
  • The Erlang process dictionary is process-local, and the test runner (erl_test_runner.erl) runs every module's main/0 and 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 helper mutableStateKey moduleName name (in Fable2Beam.Util.fs) builds the key and is used at every site so reads, writes and the initializer always agree:

  • the IdentExpr read branch (mutable ident → get),
  • the Set(IdentExpr, …) write branch (mutable ident → put),
  • the MemberDeclaration mutable-init and snapshot-init puts,
  • the snapshot accessor (name() -> get(key); the accessor function name stays bare).

The module name is threaded through Context (set once in transformFile) 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.md is updated to reflect the now module-qualified keys.

Generated output (after fix)

% module_mutable_one_tests.erl
erlang:put(module_mutable_one_tests_shared, V)
% module_mutable_two_tests.erl
erlang:put(module_mutable_two_tests_shared, V)

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 broken main/0 is diagnosable rather than surfacing later as silent undefined reads. The run still does not abort on a broken initializer.

Tests

  • Added two BEAM test modules (ModuleMutableOneTests.fs / ModuleMutableTwoTests.fs) that each define a module-level mutable named shared, write distinct values through each, and assert each module sees its own value (in both write orders). This fails before the fix (both keys are shared, so the second write clobbers the first) and passes after.
  • Existing multi-statement-initializer regression tests in MiscTests.fs stay green.
  • ./build.sh test beam: 2462 passed, 0 failed.
  • dotnet fantomas run on changed F# files (CI Fantomas check).

🤖 Generated with Claude Code

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>
@dbrattli dbrattli merged commit 1ec28ab into main Jun 24, 2026
31 checks passed
@dbrattli dbrattli deleted the fix/beam-namespace-module-mutable-state branch June 24, 2026 17:53
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