Skip to content

fix(beam): support module-level mutable variables via process dictionary#4676

Open
dbrattli wants to merge 3 commits into
mainfrom
fix/beam-module-level-mutable
Open

fix(beam): support module-level mutable variables via process dictionary#4676
dbrattli wants to merge 3 commits into
mainfrom
fix/beam-module-level-mutable

Conversation

@dbrattli

@dbrattli dbrattli commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator

Module-level let mutable a = 10 was broken: the declaration compiled to a constant-returning function a() -> 10, but assignments (a <- 20) created an unrelated local variable, and reads still called a() (returning the stale initial value).

Route module-level mutables through the process dictionary

The same mechanism already used for local mutable let bindings:

  • MemberDeclaration for a mutable value (no args, IsMutable) now emits a main/0 that initialises via put(name_atom, value). The existing multiple-main/0 merge logic combines it with the ActionDeclaration body, so the initialisation always runs first.
  • IdentExpr for a mutable ident not in ctx.MutableVars/LocalVars now emits get(name_atom) instead of calling the (now-absent) function.
  • Set(IdentExpr, ValueSet, ...) for a mutable ident not in ctx.MutableVars now emits put(name_atom, value) instead of a local match.

With this change let mutable a = 10; a <- 20; printfn "A=%A" a correctly prints A=20.

Snapshot immutable values that read a module-level mutable

An immutable module value that reads a module-level mutable (e.g. let c = topA) was compiled as a lazy 0-arity function that re-read the live process-dictionary value, so it observed later reassignments instead of the value at binding time. F# evaluates such bindings once at module init — before any later do/reassignment — so they must be snapshotted. They now emit a main/0 init (put(name, value), in declaration order) plus an accessor that reads the snapshot. Only values reading a free mutable are affected; self-contained bodies (local mutables) stay lazy to avoid a spurious dependency on main/0.

Run module initialisation before tests

The Erlang test runner called test_*/0 functions directly and never ran main/0, so module-level initialisation (mutable inits and do actions) never executed and reads returned undefined. The runner now invokes a module's main/0 before its tests, mirroring .NET module initialisation (which runs before any module code).

Tests

Enables the previously-skipped Setting a top-level value doesn't alter values at same level test and adds dedicated read/write, multiple-assignment and cross-function module-level mutable tests. Full beam suite: 2457 passed, 0 failed.


This was previously pushed directly to main (488f4cd) and has been moved to a PR.

🤖 Generated with Claude Code

dbrattli and others added 3 commits June 20, 2026 09:00
Module-level `let mutable a = 10` was broken: the declaration compiled
to a constant-returning function `a() -> 10`, but assignments (`a <- 20`)
created an unrelated local variable, and reads still called `a()` (returning
the stale initial value).

Fix: route module-level mutables through the process dictionary, the same
mechanism already used for local mutable `let` bindings:
- `MemberDeclaration` for a mutable value (no args, IsMutable) now emits a
  `main/0` that initialises via `put(name_atom, value)`.  The existing
  multiple-main/0 merge logic combines it with the ActionDeclaration body,
  so the initialisation always runs first.
- `IdentExpr` for a mutable ident not in `ctx.MutableVars`/`LocalVars`
  now emits `get(name_atom)` instead of calling the (now-absent) function.
- `Set(IdentExpr, ValueSet, ...)` for a mutable ident not in `ctx.MutableVars`
  now emits `put(name_atom, value)` instead of a local match.

With this change `let mutable a = 10; a <- 20; printfn "A=%A" a`
correctly prints `A=20`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… in tests

The previous module-level mutable fix routed mutables through the process
dictionary, initialised in main/0. Two problems surfaced:

1. Immutable module values that read a module-level mutable (e.g. `let c =
   topA`) were compiled as lazy 0-arity functions that re-read the live
   process-dictionary value, so they observed later reassignments instead of
   the value at binding time. F# evaluates them once at module init, before
   any later `do`/reassignment, so they must be snapshotted. They now emit a
   main/0 init (`put(name, value)`, in declaration order) plus an accessor
   that reads the snapshot. Only values reading a *free* mutable are affected;
   self-contained bodies (local mutables) stay lazy to avoid a spurious
   dependency on main/0.

2. The Erlang test runner called test_/0 functions directly and never ran
   main/0, so module-level initialisation (mutable inits and `do` actions)
   never executed and reads returned `undefined`. The runner now invokes a
   module's main/0 before its tests, mirroring .NET module initialisation.

Enables the previously-skipped "Setting a top-level value doesn't alter
values at same level" test and adds dedicated read/write, multiple-assignment
and cross-function module-level mutable tests. Full beam suite: 2457 passed.

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

Record how module-level mutables are routed through the process dictionary,
the snapshotting of immutable values that read a module-level mutable, and the
test runner now invoking main/0 before tests. Add a Future Improvements section
covering the per-process / main/0-dependent limitation and the persistent_term
and ETS follow-up options.

Co-Authored-By: Claude Opus 4.8 (1M context) <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.

1 participant