Skip to content

fix(beam): collapse array-literal process-dict ref round-trips in FFI/Emit calls#4626

Open
dbrattli wants to merge 1 commit into
mainfrom
fix/beam-array-literal-ref-roundtrip
Open

fix(beam): collapse array-literal process-dict ref round-trips in FFI/Emit calls#4626
dbrattli wants to merge 1 commit into
mainfrom
fix/beam-array-literal-ref-roundtrip

Conversation

@dbrattli
Copy link
Copy Markdown
Collaborator

@dbrattli dbrattli commented May 31, 2026

Problem

Fable-BEAM represents F# arrays as process-dictionary refs (fable_utils:new_ref(...)), so any array-typed value handed to an FFI BIF must be read back with erlang:get(...). When an array literal is passed directly to an FFI/Emit binding that derefs its argument — e.g. [<Emit("maps:from_list(erlang:get($0))")>] — the compiler emits a pointless round-trip:

maps:from_list(erlang:get(fable_utils:new_ref([{<<"content-type">>, <<"text/html">>}])))

It allocates a process-dict ref on every call for an immutable literal that never needed mutable-array semantics (and leaks a never-erased process-dict entry). A hand-written handler just writes maps:from_list([{<<"content-type">>, <<"text/html">>}]).

Reported by a consumer migrating Fable.Actor's timeflies-beam example to the typed Cowboy API.

Approach

fable_utils:new_ref(X) stores X under a fresh inline reference and returns it; an immediately-applied erlang:get reads it straight back, so get(new_ref(X))X.

This is recognised on the Beam AST in simplifyArrayRefDerefs (Fable2Beam.fs) when an Emit node is built: when an argument is an inline new_ref(list) and every occurrence of its $N placeholder in the (short, authored) macro template is deref-wrapped (erlang:get($N) / get($N)), the deref is stripped from the template and the underlying list is passed directly.

Gating on the argument's AST shape — rather than scanning rendered Erlang with balanced-paren matching — keeps it robust:

  • A ref-typed variable (a bound/mutable array) is left untouched, so its deref is preserved (verified: maps:from_list(erlang:get(Arr))).
  • The "every occurrence wrapped" check means macros like put($0, get($0) + 1) (where $0 also appears unwrapped) are never miscompiled.
  • Lowering array literals to plain lists at NewArray was rejected: it would break every binding that derefs with erlang:get($0) (you'd get erlang:get([...])undefined).

This covers user FFI bindings and Fable's own deref-based replacements (maps:keys/values/to_list, Array.sum/min/max/contains, Set.ofArray, Map.ofArray, …) when given literals. Generated output now matches a hand-written handler:

maps:from_list([{<<"content-type"/utf8>>, <<"text/html"/utf8>>}])

Changes

  • Fable2Beam.fs — added simplifyArrayRefDerefs, applied to the args/macro when constructing Emit nodes.
  • tests/Beam/InteropTests.fs — regression test feeding a literal array to a deref-style Emit binding.
  • FABLE-BEAM.md — documented the behavior.

Testing

Full BEAM suite passes: 2443 passed, 0 failed (including the new test). Quicktest-verified all paths: lists:sum([1,2,3]), maps:from_list([{...},{...}]), and a bound+mutated array still emitting maps:from_list(erlang:get(Arr)).

🤖 Generated with Claude Code

…/Emit calls

Fable-BEAM represents F# arrays as process-dict refs, so an array literal
passed to an FFI/Emit binding that derefs its argument (e.g.
`[<Emit("maps:from_list(erlang:get($0))")>]`) generated a pointless
round-trip: `maps:from_list(erlang:get(fable_utils:new_ref([...])))`.

`fable_utils:new_ref` stores its argument under a fresh inline reference and
returns it; an immediately-applied `erlang:get` reads it straight back, so
`get(new_ref(X))` is a semantic identity (collapsing it also avoids leaking a
never-erased process-dict entry).

This is now recognised on the Beam AST in `simplifyArrayRefDerefs`
(Fable2Beam.fs) when building an Emit node: when an argument is an inline
`new_ref(list)` and every occurrence of its `$N` placeholder in the macro
template is deref-wrapped (`erlang:get($N)`/`get($N)`), the deref is dropped
from the template and the underlying list is passed directly. Gating on the
argument's AST shape — rather than parsing rendered Erlang — keeps it robust;
a ref-typed variable (bound/mutable array) keeps its deref.

Covers user FFI bindings and Fable's own deref-based replacements
(maps:keys/values/to_list, Array.sum/min/max/contains, Set.ofArray,
Map.ofArray, ...) when given literals.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dbrattli dbrattli force-pushed the fix/beam-array-literal-ref-roundtrip branch from 8fc8f2f to c17c671 Compare May 31, 2026 09:16
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