Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions src/Fable.Transforms/Beam/FABLE-BEAM.md
Original file line number Diff line number Diff line change
Expand Up @@ -1112,9 +1112,61 @@ alone eliminates the single hardest piece of the Fable.Python runtime.
`{choice1_of2, V}` / `{choice2_of2, wrap_error(E)}` matching Beam's Choice union representation.
- **OperationCanceledException**: Added to the exception type pattern in Beam Replacements
alongside `BuiltinSystemException` and `KeyNotFoundException`.
- **Module-level mutable variables**: `let mutable x = v` at module level is routed through
the process dictionary (same mechanism as local mutable `let` bindings). The declaration
emits a `main/0` fragment that initialises the value (`put(x, v)`); reads of the ident emit
`get(x)` and writes (`x <- e`) emit `put(x, e)` (see the `IdentExpr`/`Set` branches and the
`MemberDeclaration` value case in `Fable2Beam.fs`). All `main/0` fragments — mutable inits,
snapshot inits (below), and `do` actions — are merged in declaration order, so module
initialisation runs as a single ordered sequence, mirroring F#.
- **Snapshotting immutable values that read a mutable**: an *immutable* module value whose
initializer reads a module-level mutable (e.g. `let c = topA`) must capture the value at
binding time, because F# evaluates module bindings once, in order, before any later
reassignment. Compiled naively as a lazy 0-arity accessor it would re-read the *live*
process-dict value and observe later writes. Such values are therefore also eagerly
initialised in `main/0` (`put(c, <value>)`, emitted in declaration order so it captures
the mutable's value at that point) plus an accessor that reads the snapshot (`c() ->
get(c)`). Detection is `readsFreeMutable`, which walks the Fable body tracking locally
bound names and triggers only on a *free* (module-level) mutable reference —
self-contained bodies whose only mutables are local stay lazy, so they don't gain a
spurious dependency on `main/0`.
- **Module init runs before tests**: the Erlang test runner (`erl_test_runner.erl`) calls
`test_*/0` functions directly and would never run `main/0`, so module-level
initialisation (mutable inits and `do` actions) would never execute and reads would
return `undefined`. The runner now invokes each module's `main/0` (if exported) before
its tests, mirroring .NET module initialisation (which runs before any module code).
This is safe because Beam test modules contain no top-level side effects beyond these
initialisers.

## Future Improvements

### Module-level mutable state: per-process, requires `main/0`

Module-level mutables live in the **process dictionary** and are initialised by `main/0`.
That makes their semantics correct only when `main/0` actually runs in the process that later
reads the state:

- **Works**: entry-point programs (the `main/0` Fable emits is the program entry — quicktest,
real apps) and the test harness (now calls `main/0` before each module's tests).
- **Does not work**: a *library* module whose functions are called by other code without that
module's `main/0` having run — its module-level mutables/snapshots read `undefined`. Reads
from a **different process** than the one that ran `main/0` also see `undefined`, since the
process dictionary is process-local.

This matches the broader Beam design (mutation is single-process by design; see "Class instance
representation" and "Mutable Collections" above), but it means module-level mutable global state
is not a fully general feature. Follow-up options if true cross-process / load-time module state
is ever needed:

- **`persistent_term`** + `-on_load` for global, load-time initialisation (reads visible from
any process). Downside: writes are global-GC-heavy and meant for write-rarely data, so
frequently-mutated module values would be slow.
- **ETS** table per module for shared mutable state — O(1) writes, but cross-process shared
state, against the isolation model.

Neither is implemented; the current per-process approach is the right default for typical F#
programs where module-level mutables are entry-point/program state.

### Mutable Collections: Process Dict vs ETS

Currently, `Dictionary`, `HashSet`, `ResizeArray`, and `Array` use the process dictionary
Expand Down
120 changes: 106 additions & 14 deletions src/Fable.Transforms/Beam/Fable2Beam.fs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,31 @@ let resolveImportModuleName (com: IBeamCompiler) (importPath: string) =
else
Some name

/// Detect whether an expression reads a *free* mutable ident — a module-level mutable
/// not bound locally within the expression. Such reads must be snapshotted at module-init
/// time (eager) rather than recomputed lazily on each access, because the module-level
/// mutable may be reassigned (via a `do` action or later binding) after this value is bound.
/// Locally-bound mutables are excluded: their initializers are self-contained, so lazy
/// recomputation yields the same value and avoids creating a spurious dependency on main/0.
let rec readsFreeMutable (bound: Set<string>) (expr: Expr) : bool =
match expr with
| IdentExpr ident -> ident.IsMutable && not (bound.Contains ident.Name)
| Let(ident, value, body) -> readsFreeMutable bound value || readsFreeMutable (Set.add ident.Name bound) body
| LetRec(bindings, body) ->
let bound = bindings |> List.fold (fun s (i, _) -> Set.add i.Name s) bound

(bindings |> List.exists (fun (_, v) -> readsFreeMutable bound v))
|| readsFreeMutable bound body
| Lambda(arg, body, _) -> readsFreeMutable (Set.add arg.Name bound) body
| Delegate(args, body, _, _) ->
let bound = args |> List.fold (fun s a -> Set.add a.Name s) bound
readsFreeMutable bound body
| ForLoop(ident, start, limit, body, _, _) ->
readsFreeMutable bound start
|| readsFreeMutable bound limit
|| readsFreeMutable (Set.add ident.Name bound) body
| _ -> getSubExpressions expr |> List.exists (readsFreeMutable bound)

let rec transformExpr (com: IBeamCompiler) (ctx: Context) (expr: Expr) : Beam.ErlExpr =
match expr with
| Unresolved(_, _, r) ->
Expand Down Expand Up @@ -215,6 +240,9 @@ let rec transformExpr (com: IBeamCompiler) (ctx: Context) (expr: Expr) : Beam.Er
| None ->
if ctx.LocalVars.Contains(ident.Name) || ctx.RecursiveBindings.Contains(ident.Name) then
Beam.ErlExpr.Variable(capitalizeFirst ident.Name |> sanitizeErlangVar)
elif ident.IsMutable then
// Module-level mutable: read current value from process dictionary
Beam.ErlExpr.Call(None, "get", [ atomLit (sanitizeErlangName ident.Name) ])
else
// Module-level function reference: call as 0-arity function
Beam.ErlExpr.Call(None, sanitizeErlangName ident.Name, [])
Expand Down Expand Up @@ -521,6 +549,9 @@ let rec transformExpr (com: IBeamCompiler) (ctx: Context) (expr: Expr) : Beam.Er
// Array ref (non-byte): put the new value into the process dict ref
let erlExpr = transformExpr com ctx expr
Beam.ErlExpr.Call(None, "put", [ erlExpr; transformExpr com ctx value ])
| IdentExpr ident when ident.IsMutable ->
// Module-level mutable: update via process dictionary using name atom
Beam.ErlExpr.Call(None, "put", [ atomLit (sanitizeErlangName ident.Name); transformExpr com ctx value ])
| IdentExpr ident -> Beam.ErlExpr.Match(Beam.PVar(capitalizeFirst ident.Name), transformExpr com ctx value)
| _ ->
com.WarnOnlyOnce("Set with non-identifier target is not supported for Beam target")
Expand Down Expand Up @@ -3475,21 +3506,82 @@ and transformDeclaration (com: IBeamCompiler) (ctx: Context) (decl: Declaration)
| Beam.ErlExpr.Block exprs -> exprs
| expr -> [ expr ]

let funcDef: Beam.ErlFunctionDef =
{
Name = Beam.Atom name
Arity = arity
Clauses =
[
{
Patterns = args
Guard = []
Body = body
}
]
}
// Module-level mutable values (no args, IsMutable) are stored in the process
// dictionary so they can be updated. Emit a main/0 that initializes the value
// instead of a constant-returning function — the main/0 merges with the
// ActionDeclaration main/0 so the initialization runs before use.
if info.IsValue && info.IsMutable && arity = 0 then
let initStmt = Beam.ErlExpr.Call(None, "put", [ atomLit name; List.head body ])

[ Beam.ErlForm.Function funcDef ]
let funcDef: Beam.ErlFunctionDef =
{
Name = Beam.Atom "main"
Arity = 0
Clauses =
[
{
Patterns = []
Guard = []
Body = [ initStmt ]
}
]
}

[ Beam.ErlForm.Function funcDef ]
elif info.IsValue && arity = 0 && readsFreeMutable Set.empty memDecl.Body then
// Immutable module-level value whose initializer reads a module-level mutable.
// F# evaluates it once at module-init, before any later reassignment of that
// mutable, so it must be snapshotted. Emit a main/0 fragment that stores the
// value in the process dictionary (in declaration order, so it captures the
// mutable's value at this point) plus an accessor that reads the snapshot.
let initStmt = Beam.ErlExpr.Call(None, "put", [ atomLit name; List.head body ])

let initDef: Beam.ErlFunctionDef =
{
Name = Beam.Atom "main"
Arity = 0
Clauses =
[
{
Patterns = []
Guard = []
Body = [ initStmt ]
}
]
}

let accessorDef: Beam.ErlFunctionDef =
{
Name = Beam.Atom name
Arity = 0
Clauses =
[
{
Patterns = []
Guard = []
Body = [ Beam.ErlExpr.Call(None, "get", [ atomLit name ]) ]
}
]
}

[ Beam.ErlForm.Function initDef; Beam.ErlForm.Function accessorDef ]
else

let funcDef: Beam.ErlFunctionDef =
{
Name = Beam.Atom name
Arity = arity
Clauses =
[
{
Patterns = args
Guard = []
Body = body
}
]
}

[ Beam.ErlForm.Function funcDef ]

| ActionDeclaration actionDecl ->
let bodyExpr = transformExpr com ctx actionDecl.Body
Expand Down
37 changes: 32 additions & 5 deletions tests/Beam/MiscTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -935,11 +935,38 @@ let ``test Binding doesn't shadow top-level functions`` () =
equal 4 B.d
equal 0 B.D.e

// TODO: Module-level `do` side effects on mutable values don't execute during Erlang module load
// [<Fact>]
// let ``test Setting a top-level value doesn't alter values at same level`` () =
// equal 15 topA
// equal 25 B.a
[<Fact>]
let ``test Setting a top-level value doesn't alter values at same level`` () =
equal 15 topA
equal 25 B.a

// --- Module-level mutable variables ---
// On BEAM these are backed by the process dictionary; module initialization (main/0)
// runs before the tests, and reads/writes go through get/put on the value's name atom.

let mutable moduleCounter = 0

let private bumpModuleCounter () = moduleCounter <- moduleCounter + 1
let private readModuleCounter () = moduleCounter

[<Fact>]
let ``test Module-level mutable can be read and written`` () =
moduleCounter <- 42
equal 42 moduleCounter

[<Fact>]
let ``test Module-level mutable supports multiple assignments`` () =
moduleCounter <- 1
moduleCounter <- 2
moduleCounter <- moduleCounter + 10
equal 12 moduleCounter

[<Fact>]
let ``test Module-level mutable is shared across functions`` () =
moduleCounter <- 5
bumpModuleCounter ()
bumpModuleCounter ()
equal 7 (readModuleCounter ())

// TODO: Recursive value bindings use Lazy internally, which is not yet supported by Fable Beam
// let mutable recMutableValue = 0
Expand Down
12 changes: 12 additions & 0 deletions tests/Beam/erl_test_runner.erl
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,20 @@ main([Dir]) ->
code:purge(Mod),
code:load_file(Mod),
Exports = Mod:module_info(exports),
%% Run the module initializer (main/0) before the module's tests, if present.
%% F# evaluates module-level bindings (including mutable values and `do` actions)
%% once before any module code runs; main/0 carries that initialization, so it must
%% execute before the test functions that read module-level state.
case lists:member({main, 0}, Exports) of
true ->
try Mod:main()
catch _:_ -> ok
end;
false -> ok
end,
TestFuns = [{Mod, F} || {F, 0} <- Exports,
F =/= module_info,
F =/= main,
lists:prefix("test_", atom_to_list(F))],
lists:foldl(fun({M, F}, {Pass, Fail}) ->
try
Expand Down
Loading