Skip to content

Commit 535b9af

Browse files
dbrattliclaude
andauthored
fix(beam): support module-level mutable variables via process dictionary (#4676)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f7daf1d commit 535b9af

4 files changed

Lines changed: 256 additions & 19 deletions

File tree

src/Fable.Transforms/Beam/FABLE-BEAM.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1112,9 +1112,66 @@ alone eliminates the single hardest piece of the Fable.Python runtime.
11121112
`{choice1_of2, V}` / `{choice2_of2, wrap_error(E)}` matching Beam's Choice union representation.
11131113
- **OperationCanceledException**: Added to the exception type pattern in Beam Replacements
11141114
alongside `BuiltinSystemException` and `KeyNotFoundException`.
1115+
- **Module-level mutable variables**: `let mutable x = v` at module level is routed through
1116+
the process dictionary (same mechanism as local mutable `let` bindings). The declaration
1117+
emits a `main/0` fragment that initialises the value (`put(x, v)`); reads of the ident emit
1118+
`get(x)` and writes (`x <- e`) emit `put(x, e)` (see the `IdentExpr`/`Set` branches and the
1119+
`MemberDeclaration` value case in `Fable2Beam.fs`). All `main/0` fragments — mutable inits,
1120+
snapshot inits (below), and `do` actions — are merged in declaration order, so module
1121+
initialisation runs as a single ordered sequence, mirroring F#. A value initialiser that
1122+
lowers to a multi-statement block (e.g. it contains a `let`) is stored as `put(x, <block>)`
1123+
using the *whole* block (its final expression is the value), and is wrapped in an
1124+
immediately-invoked `fun` so its local Erlang variables stay isolated — Erlang `begin...end`
1125+
does not introduce a scope, so two initialisers reusing the same local name would otherwise
1126+
clash in the shared `main/0` clause.
1127+
- **Snapshotting immutable values that read a mutable**: an *immutable* module value whose
1128+
initializer reads a module-level mutable (e.g. `let c = topA`) must capture the value at
1129+
binding time, because F# evaluates module bindings once, in order, before any later
1130+
reassignment. Compiled naively as a lazy 0-arity accessor it would re-read the *live*
1131+
process-dict value and observe later writes. Such values are therefore also eagerly
1132+
initialised in `main/0` (`put(c, <value>)`, emitted in declaration order so it captures
1133+
the mutable's value at that point) plus an accessor that reads the snapshot (`c() ->
1134+
get(c)`). Detection is `readsFreeMutable`, which walks the Fable body tracking locally
1135+
bound names and triggers only on a *free* (module-level) mutable reference —
1136+
self-contained bodies whose only mutables are local stay lazy, so they don't gain a
1137+
spurious dependency on `main/0`.
1138+
- **Module init runs before tests**: the Erlang test runner (`erl_test_runner.erl`) calls
1139+
`test_*/0` functions directly and would never run `main/0`, so module-level
1140+
initialisation (mutable inits and `do` actions) would never execute and reads would
1141+
return `undefined`. The runner now invokes each module's `main/0` (if exported) before
1142+
its tests, mirroring .NET module initialisation (which runs before any module code).
1143+
This is safe because Beam test modules contain no top-level side effects beyond these
1144+
initialisers.
11151145

11161146
## Future Improvements
11171147

1148+
### Module-level mutable state: per-process, requires `main/0`
1149+
1150+
Module-level mutables live in the **process dictionary** and are initialised by `main/0`.
1151+
That makes their semantics correct only when `main/0` actually runs in the process that later
1152+
reads the state:
1153+
1154+
- **Works**: entry-point programs (the `main/0` Fable emits is the program entry — quicktest,
1155+
real apps) and the test harness (now calls `main/0` before each module's tests).
1156+
- **Does not work**: a *library* module whose functions are called by other code without that
1157+
module's `main/0` having run — its module-level mutables/snapshots read `undefined`. Reads
1158+
from a **different process** than the one that ran `main/0` also see `undefined`, since the
1159+
process dictionary is process-local.
1160+
1161+
This matches the broader Beam design (mutation is single-process by design; see "Class instance
1162+
representation" and "Mutable Collections" above), but it means module-level mutable global state
1163+
is not a fully general feature. Follow-up options if true cross-process / load-time module state
1164+
is ever needed:
1165+
1166+
- **`persistent_term`** + `-on_load` for global, load-time initialisation (reads visible from
1167+
any process). Downside: writes are global-GC-heavy and meant for write-rarely data, so
1168+
frequently-mutated module values would be slow.
1169+
- **ETS** table per module for shared mutable state — O(1) writes, but cross-process shared
1170+
state, against the isolation model.
1171+
1172+
Neither is implemented; the current per-process approach is the right default for typical F#
1173+
programs where module-level mutables are entry-point/program state.
1174+
11181175
### Mutable Collections: Process Dict vs ETS
11191176

11201177
Currently, `Dictionary`, `HashSet`, `ResizeArray`, and `Array` use the process dictionary

src/Fable.Transforms/Beam/Fable2Beam.fs

Lines changed: 128 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,31 @@ let resolveImportModuleName (com: IBeamCompiler) (importPath: string) =
183183
else
184184
Some name
185185

186+
/// Detect whether an expression reads a *free* mutable ident — a module-level mutable
187+
/// not bound locally within the expression. Such reads must be snapshotted at module-init
188+
/// time (eager) rather than recomputed lazily on each access, because the module-level
189+
/// mutable may be reassigned (via a `do` action or later binding) after this value is bound.
190+
/// Locally-bound mutables are excluded: their initializers are self-contained, so lazy
191+
/// recomputation yields the same value and avoids creating a spurious dependency on main/0.
192+
let rec readsFreeMutable (bound: Set<string>) (expr: Expr) : bool =
193+
match expr with
194+
| IdentExpr ident -> ident.IsMutable && not (bound.Contains ident.Name)
195+
| Let(ident, value, body) -> readsFreeMutable bound value || readsFreeMutable (Set.add ident.Name bound) body
196+
| LetRec(bindings, body) ->
197+
let bound = bindings |> List.fold (fun s (i, _) -> Set.add i.Name s) bound
198+
199+
(bindings |> List.exists (fun (_, v) -> readsFreeMutable bound v))
200+
|| readsFreeMutable bound body
201+
| Lambda(arg, body, _) -> readsFreeMutable (Set.add arg.Name bound) body
202+
| Delegate(args, body, _, _) ->
203+
let bound = args |> List.fold (fun s a -> Set.add a.Name s) bound
204+
readsFreeMutable bound body
205+
| ForLoop(ident, start, limit, body, _, _) ->
206+
readsFreeMutable bound start
207+
|| readsFreeMutable bound limit
208+
|| readsFreeMutable (Set.add ident.Name bound) body
209+
| _ -> getSubExpressions expr |> List.exists (readsFreeMutable bound)
210+
186211
let rec transformExpr (com: IBeamCompiler) (ctx: Context) (expr: Expr) : Beam.ErlExpr =
187212
match expr with
188213
| Unresolved(_, _, r) ->
@@ -215,6 +240,9 @@ let rec transformExpr (com: IBeamCompiler) (ctx: Context) (expr: Expr) : Beam.Er
215240
| None ->
216241
if ctx.LocalVars.Contains(ident.Name) || ctx.RecursiveBindings.Contains(ident.Name) then
217242
Beam.ErlExpr.Variable(capitalizeFirst ident.Name |> sanitizeErlangVar)
243+
elif ident.IsMutable then
244+
// Module-level mutable: read current value from process dictionary
245+
Beam.ErlExpr.Call(None, "get", [ atomLit (sanitizeErlangName ident.Name) ])
218246
else
219247
// Module-level function reference: call as 0-arity function
220248
Beam.ErlExpr.Call(None, sanitizeErlangName ident.Name, [])
@@ -521,6 +549,9 @@ let rec transformExpr (com: IBeamCompiler) (ctx: Context) (expr: Expr) : Beam.Er
521549
// Array ref (non-byte): put the new value into the process dict ref
522550
let erlExpr = transformExpr com ctx expr
523551
Beam.ErlExpr.Call(None, "put", [ erlExpr; transformExpr com ctx value ])
552+
| IdentExpr ident when ident.IsMutable ->
553+
// Module-level mutable: update via process dictionary using name atom
554+
Beam.ErlExpr.Call(None, "put", [ atomLit (sanitizeErlangName ident.Name); transformExpr com ctx value ])
524555
| IdentExpr ident -> Beam.ErlExpr.Match(Beam.PVar(capitalizeFirst ident.Name), transformExpr com ctx value)
525556
| _ ->
526557
com.WarnOnlyOnce("Set with non-identifier target is not supported for Beam target")
@@ -3475,21 +3506,104 @@ and transformDeclaration (com: IBeamCompiler) (ctx: Context) (decl: Declaration)
34753506
| Beam.ErlExpr.Block exprs -> exprs
34763507
| expr -> [ expr ]
34773508

3478-
let funcDef: Beam.ErlFunctionDef =
3479-
{
3480-
Name = Beam.Atom name
3481-
Arity = arity
3482-
Clauses =
3483-
[
3484-
{
3485-
Patterns = args
3486-
Guard = []
3487-
Body = body
3488-
}
3489-
]
3490-
}
3509+
// The value initializer of a module-level mutable/snapshot is spliced into the
3510+
// shared main/0 clause. A multi-statement body binds local Erlang variables (from
3511+
// F# `let`s); since Erlang `begin...end` does not introduce a new scope, two such
3512+
// initializers reusing the same variable name would clash in main/0. Wrap a
3513+
// multi-statement body in an immediately-invoked `fun` so its locals stay isolated.
3514+
// A single-expression body needs no wrapper.
3515+
let initValue =
3516+
match body with
3517+
| [ single ] -> single
3518+
| _ ->
3519+
Beam.ErlExpr.Apply(
3520+
Beam.ErlExpr.Fun
3521+
[
3522+
{
3523+
Patterns = []
3524+
Guard = []
3525+
Body = body
3526+
}
3527+
],
3528+
[]
3529+
)
34913530

3492-
[ Beam.ErlForm.Function funcDef ]
3531+
// Module-level mutable values (no args, IsMutable) are stored in the process
3532+
// dictionary so they can be updated. Emit a main/0 that initializes the value
3533+
// instead of a constant-returning function — the main/0 merges with the
3534+
// ActionDeclaration main/0 so the initialization runs before use.
3535+
if info.IsValue && info.IsMutable && arity = 0 then
3536+
let initStmt = Beam.ErlExpr.Call(None, "put", [ atomLit name; initValue ])
3537+
3538+
let funcDef: Beam.ErlFunctionDef =
3539+
{
3540+
Name = Beam.Atom "main"
3541+
Arity = 0
3542+
Clauses =
3543+
[
3544+
{
3545+
Patterns = []
3546+
Guard = []
3547+
Body = [ initStmt ]
3548+
}
3549+
]
3550+
}
3551+
3552+
[ Beam.ErlForm.Function funcDef ]
3553+
elif info.IsValue && arity = 0 && readsFreeMutable Set.empty memDecl.Body then
3554+
// Immutable module-level value whose initializer reads a module-level mutable.
3555+
// F# evaluates it once at module-init, before any later reassignment of that
3556+
// mutable, so it must be snapshotted. Emit a main/0 fragment that stores the
3557+
// value in the process dictionary (in declaration order, so it captures the
3558+
// mutable's value at this point) plus an accessor that reads the snapshot.
3559+
let initStmt = Beam.ErlExpr.Call(None, "put", [ atomLit name; initValue ])
3560+
3561+
let initDef: Beam.ErlFunctionDef =
3562+
{
3563+
Name = Beam.Atom "main"
3564+
Arity = 0
3565+
Clauses =
3566+
[
3567+
{
3568+
Patterns = []
3569+
Guard = []
3570+
Body = [ initStmt ]
3571+
}
3572+
]
3573+
}
3574+
3575+
let accessorDef: Beam.ErlFunctionDef =
3576+
{
3577+
Name = Beam.Atom name
3578+
Arity = 0
3579+
Clauses =
3580+
[
3581+
{
3582+
Patterns = []
3583+
Guard = []
3584+
Body = [ Beam.ErlExpr.Call(None, "get", [ atomLit name ]) ]
3585+
}
3586+
]
3587+
}
3588+
3589+
[ Beam.ErlForm.Function initDef; Beam.ErlForm.Function accessorDef ]
3590+
else
3591+
3592+
let funcDef: Beam.ErlFunctionDef =
3593+
{
3594+
Name = Beam.Atom name
3595+
Arity = arity
3596+
Clauses =
3597+
[
3598+
{
3599+
Patterns = args
3600+
Guard = []
3601+
Body = body
3602+
}
3603+
]
3604+
}
3605+
3606+
[ Beam.ErlForm.Function funcDef ]
34933607

34943608
| ActionDeclaration actionDecl ->
34953609
let bodyExpr = transformExpr com ctx actionDecl.Body

tests/Beam/MiscTests.fs

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -947,11 +947,65 @@ let ``test Binding doesn't shadow top-level functions`` () =
947947
equal 4 B.d
948948
equal 0 B.D.e
949949

950-
// TODO: Module-level `do` side effects on mutable values don't execute during Erlang module load
951-
// [<Fact>]
952-
// let ``test Setting a top-level value doesn't alter values at same level`` () =
953-
// equal 15 topA
954-
// equal 25 B.a
950+
[<Fact>]
951+
let ``test Setting a top-level value doesn't alter values at same level`` () =
952+
equal 15 topA
953+
equal 25 B.a
954+
955+
// --- Module-level mutable variables ---
956+
// On BEAM these are backed by the process dictionary; module initialization (main/0)
957+
// runs before the tests, and reads/writes go through get/put on the value's name atom.
958+
959+
let mutable moduleCounter = 0
960+
961+
let private bumpModuleCounter () = moduleCounter <- moduleCounter + 1
962+
let private readModuleCounter () = moduleCounter
963+
964+
[<Fact>]
965+
let ``test Module-level mutable can be read and written`` () =
966+
moduleCounter <- 42
967+
equal 42 moduleCounter
968+
969+
[<Fact>]
970+
let ``test Module-level mutable supports multiple assignments`` () =
971+
moduleCounter <- 1
972+
moduleCounter <- 2
973+
moduleCounter <- moduleCounter + 10
974+
equal 12 moduleCounter
975+
976+
[<Fact>]
977+
let ``test Module-level mutable is shared across functions`` () =
978+
moduleCounter <- 5
979+
bumpModuleCounter ()
980+
bumpModuleCounter ()
981+
equal 7 (readModuleCounter ())
982+
983+
// Regression: initializers that compile to a multi-statement block must store the
984+
// block's final value, not just its first statement. `let t = ... in t + t` survives
985+
// as a Let (t is used twice, so it isn't inlined) and lowers to a two-statement block.
986+
// Both initializers deliberately reuse the local name `t`: their inits are spliced into
987+
// the shared module-init clause, so their locals must not clash.
988+
989+
let mutable mlTopMulti = 10
990+
991+
let mlSnapMulti =
992+
let t = mlTopMulti
993+
t + t
994+
995+
do mlTopMulti <- mlTopMulti + 5
996+
997+
let mutable mlMutMulti =
998+
let t = f8 3 4
999+
t + t
1000+
1001+
[<Fact>]
1002+
let ``test Module-level mutable with multi-statement initializer`` () =
1003+
equal 14 mlMutMulti
1004+
1005+
[<Fact>]
1006+
let ``test Snapshot reading a mutable with multi-statement initializer`` () =
1007+
equal 20 mlSnapMulti
1008+
equal 15 mlTopMulti
9551009

9561010
// TODO: Recursive value bindings use Lazy internally, which is not yet supported by Fable Beam
9571011
// let mutable recMutableValue = 0

tests/Beam/erl_test_runner.erl

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,20 @@ main([Dir]) ->
1212
code:purge(Mod),
1313
code:load_file(Mod),
1414
Exports = Mod:module_info(exports),
15+
%% Run the module initializer (main/0) before the module's tests, if present.
16+
%% F# evaluates module-level bindings (including mutable values and `do` actions)
17+
%% once before any module code runs; main/0 carries that initialization, so it must
18+
%% execute before the test functions that read module-level state.
19+
case lists:member({main, 0}, Exports) of
20+
true ->
21+
try Mod:main()
22+
catch _:_ -> ok
23+
end;
24+
false -> ok
25+
end,
1526
TestFuns = [{Mod, F} || {F, 0} <- Exports,
1627
F =/= module_info,
28+
F =/= main,
1729
lists:prefix("test_", atom_to_list(F))],
1830
lists:foldl(fun({M, F}, {Pass, Fail}) ->
1931
try

0 commit comments

Comments
 (0)