Running current Erlang/OTP BEAM files on JErlang¶
JErlang's interpreter was written against OTP 20-era BEAM files. This document records what a module compiled by a current compiler needs, what had to change to run one, and what still does not work.
Compiling¶
JErlang ships a compiler, and it still loads .beam files produced by erlc
from an Erlang/OTP installation. Either way the result is loaded from the
working directory or from another directory on the code path:
src/test/resources/bridge_demo.beam is the result of compiling
src/test/erl/bridge_demo.erl with
Erlang/OTP 29.1.2, and
ErlangSourceBridgeTest
runs it.
Conformance pass rate¶
The metric is the fraction of real BEAM files the runtime can load and, of those, the fraction whose every instruction has a handler. Both are needed before a module's code can run, so a module counts as a pass only if it satisfies both. Opcode counts are not the metric.
ConformanceReport
loads every .beam below a directory and classifies each file:
- load failure — the loader rejected the file: an unknown opcode number, a chunk it can not read, a missing code chunk, or a file that is not BEAM.
- opcode gap — the file loaded, but at least one instruction has no handler. The gap is named per opcode. This is the allowed failure form for a language-level gap; a module should not stop with "Unsupported opcode".
- pass — loaded, and every instruction has a handler.
Loading is read-only. Nothing is registered, so a run over an OTP tree does
not install it, and execution is not part of the metric: calling arbitrary
exported functions needs the standard library, which is 0.6.
Against the modules that ship with Erlang/OTP 29.1.2, in the conformance
CI job:
| Corpus | Modules | Loaded | Passed | Opcode gaps | Load failures |
|---|---|---|---|---|---|
OTP 29.1.2 lib |
1366 | 100% | 100% | 0 | 0 |
The number is reproducible with an OTP installation and any JDK:
The directory may be a path list and is searched recursively. The run prints
the rate; the conformance CI job passes -Djerlang.conformance.min=0.99, so
a drop below that floor fails the build. The checked-in corpus is gated at 100%
by the test job.
What a modern BEAM file contains that the loader did not expect¶
Atoms are UTF-8 and may use compact lengths¶
OTP 20 introduced the AtU8 chunk. OTP 28 changed its format: the atom count is
written as a negative number, and each atom length is encoded with the compact
instruction encoding rather than a single byte. The reader now handles Atom
(latin-1) and AtU8 (UTF-8), including the negative count.
Literals may be uncompressed¶
OTP 28 stopped compressing the LitT chunk. A zero word after the chunk header
now means "the literals follow uncompressed"; the older format still starts with
the uncompressed size and a zlib stream. Both are read.
New chunks carry no code¶
Meta, Dbgi and Type are written by current compilers. They are not needed
to execute a module, so they are skipped. A chunk that fails to load now only
loses its own data instead of failing the whole module.
The line table stores filename indexes¶
Line items are encoded with the a tag for the filename and the i tag for the
line number, but a carries an index into the chunk's own filename table,
not an index into the atom chunk. Decoding it with the instruction argument
reader dereferenced a null atom chunk and aborted the load. The reader now
decodes the tags directly and resolves filenames after reading the table.
Opcode numbers are not enum positions¶
Opcode numbers come from lib/compiler/src/genop.tab, and new opcodes are
inserted between existing ones. Opcode.decode used the enum position, which
happens to work only while no opcode has been inserted. It now looks up the
declared code, and an opcode this interpreter does not know fails with its
number. The maxOpcode header in the Code chunk is reported, but no longer
rejects the module before decoding.
Instructions that had to be added¶
| Opcode | Number | Emitted for | Notes |
|---|---|---|---|
put_tuple2 |
164 | Tuple construction (OTP 22+) | Replaces put_tuple/put pairs |
swap |
169 | Register swaps (OTP 22+) | |
make_fun3 |
171 | Anonymous functions (OTP 24+) | 3 operands in the file; the disassembler expands them |
init_yregs |
172 | Frame initialization (OTP 22+) | Sets Y registers to nil |
call_fun2 |
178 | Calling anonymous functions (OTP 24+) | Tag and type operands are ignored |
build_stacktrace |
160 | try ... catch Class:Reason:Stack (OTP 22+) |
Normalises the recorded frame list |
raw_raise |
161 | erlang:raise/3 in a catch clause (OTP 22+) |
Class, reason and stacktrace are re-raised unchanged |
is_tagged_tuple |
159 | Tuple patterns and record tests (OTP 22+) | Checks arity and the first element |
recv_marker_bind |
173 | A second receive in one function (OTP 28+) |
Records nothing: Process.receivePosition tracks the scan position |
recv_marker_clear |
174 | Leaving a marked receive |
Nothing to clear, for the same reason |
recv_marker_reserve |
175 | A second receive in one function (OTP 28+) |
Allocates the marker reference |
recv_marker_use |
176 | A second receive in one function (OTP 28+) |
Falls through to the loop_rec that follows it |
bs_start_match3 |
166 | Starting a binary match (OTP 22+) | The source is stored as a one element list; a match context is copied |
bs_start_match4 |
170 | Starting a match on a known matchable value (OTP 23+) | no_fail and resume modes; a label fails like bs_start_match3 |
bs_match |
182 | Binary matching (OTP 26+) | Carries the get, skip and literal compare operations as a flat command list |
bs_get_position |
167 | Saving a match position (OTP 22+) | The position is the bit offset |
bs_set_position |
168 | Restoring a match position (OTP 22+) | |
bs_get_tail |
165 | Capturing the rest of a match (OTP 22+) | A binary when byte aligned, otherwise a bitstring |
get_hd |
162 | List patterns and hd optimizations (OTP 22+) |
Raises badarg for a non-list or empty list |
get_tl |
163 | List patterns and tl optimizations (OTP 22+) |
Raises badarg for a non-list or empty list |
bs_create_bin |
177 | Binary construction (OTP 25+) | Reads the flat segment list, including the string table offsets |
nif_start |
179 | Start of a -nifs() function |
No-op: this runtime has no NIFs |
badrecord |
180 | A record pattern that does not match | Raises error:{badrecord,Value} |
update_record |
181 | Record#name{field = Value} (OTP 26+) |
Applies the flat [Index, Value] update list |
executable_line |
183 | Line information for coverage (OTP 27+) | Sets the current frame's location |
debug_line |
184 | Breakpoint locations (OTP 28+) | Sets the current frame's location |
bif3 |
185 | Three-argument BIF calls that can fail (OTP 29+) | Arguments and destination, like bif2 |
is_any_native_record |
186 | Native-record tests (OTP 29) | Always fails: no native record values exist in this runtime |
is_native_record |
187 | Native-record tests (OTP 29) | Always fails |
get_record_elements |
188 | Native-record field access (OTP 29) | Always fails |
put_record |
189 | Native-record construction (OTP 29) | Raises a named native records are not supported error |
is_record_accessible |
190 | Native-record access checks (OTP 29) | Always fails |
get_record_field |
191 | Native-record field access (OTP 29) | Jumps to the fail label, or raises badarg when the label is 0 |
put_list also had to accept a literal tail, which is how an empty list is
encoded when building a list from a literal and a register.
A literal tuple is an operand too. move used to treat any two element tuple
source as a register, so a function returning {ok, Value} or {[], []}
failed with "Cannot convert to integer". Operands now go through toArg,
which treats only {x,N}, {y,N} and {fr,N} as registers and returns any
other tuple unchanged.
Instruction arguments also gained the extended tag 5, a typed register
{tr,Register,Type}. The type is only used by the JIT, but it has to be
decoded to stay in sync with the instruction stream.
OTP 28 also made the heap need of allocate_heap (and test_heap) a list of
heap fragments such as {alloc, [{words, 0}, {floats, 0}, {funs, 1}]}
instead of a word count. The JVM manages the heap, so the interpreter reads
only the stack need and the live-register count and ignores the fragment
description.
Binary matching¶
OTP 26 stopped emitting the individual bs_get_integer2 and
bs_get_binary2 instructions for a match and folded them into one bs_match
instruction with a command list. The interpreter reads that list in order:
ensure_at_least, ensure_exactly, skip, get_tail, =:=, integer,
binary and float. A command whose field does not fit jumps to the fail
label of the instruction.
Fields can be signed, little endian or big endian, and a match can start at a
bit offset: <<A:3, B:5, Rest/bitstring>> works because get_tail returns a
bitstring when the rest is not a whole number of bytes. The position
instructions save the bit offset, so a failed match restores the start and
bs_get_tail can capture the original value for the failure reason.
Limitations: little endian fields must be a whole number of bytes, native is
treated as little endian, and UTF fields still go through their own
bs_get_utf*/bs_skip_utf* instructions rather than through bs_match
commands.
Binary construction¶
bs_create_bin carries a destination and a flat list of six-word segments:
[Type, Seg, Unit, Flags, Value, Size]. Type is integer, float, binary,
append, string, utf8, utf16 or utf32; flags are little, signed
and unsigned where they apply. The interpreter writes each segment into a
BitStringOutputStream, so an unaligned bitstring segment stays unaligned.
A string segment is not inline data: beam_asm encodes it as an offset into
the module's StrT chunk, and Size is the byte count. The interpreter reads
that chunk, which is why binary construction needed the string table that
bs_match_string already used.
Function clauses¶
A clause that fails its pattern does not jump to a normal label: it jumps back
to the label in front of the function's func_info. The interpreter used to
re-enter the function and loop forever. It now recognises those entry labels
and raises error:function_clause, so a single-clause function called with the
wrong argument terminates the process instead of hanging a scheduler.
Comparisons¶
A comparison in a function body compiles to a bif instruction that calls
erlang:'<'/2 and friends, while the same test in a guard compiles to
is_lt, is_ge, is_eq, is_ne, is_eq_exact or is_ne_exact. The
operands may be literals, X registers or Y registers, on either side: X < 12,
12 < X and A < B all appear in compiled code.
Both paths share TermOrder, which implements the Erlang term order
(number < atom < reference < fun < port < pid < tuple < map < nil < list <
bitstring). == and /= compare numbers by value, so 1 == 1.0 is true,
while =:= and =/= stay exact. The guard instructions used to handle only
one literal/register shape and raised Can not compare for every other order.
Receive scan position¶
loop_rec_end used to remove the head of the mailbox. When the first message
did not match a receive clause, it was therefore lost before a later receive
could see it. Process now keeps receivePosition while a receive scans the
mailbox: loop_rec_end advances it, remove_message removes the message at
that position and resets the scan, and a timeout resets it as well. This is
what lets spawn_opt(..., [link, monitor]) receive the DOWN that follows an
EXIT in the same mailbox.
Built-in modules¶
JErlang ships a few modules in Java so that programs run without an OTP
installation. For lists, a lists.beam in the working directory now wins:
the module is loaded from disk, and the built-in implementation is only used
for functions the BEAM file declares but does not implement.
OTP ships its BIFs and NIFs that way. lists:reverse/2 and lists:member/2,
for example, are exported by lists.beam, but their body only calls
erlang:nif_error/1; the runtime has to provide them. The loader recognises
that placeholder and adopts the built-in function, so the real Erlang code in
lists.beam (sort, usort, sum, last and the rest) runs while the primitives
stay native. lists:reverse/2 and lists:member/2 were added to the built-in
module for this. A function adopted that way has no instruction stream, so a
call from Java is dispatched straight to it instead of spawning a process.
Other built-in modules still take precedence over a same-named BEAM file,
because some of them (for example erlang) only declare BIFs in their BEAM
form and would remove the runtime's implementations of them.
Fun calls¶
make_fun3 carries a lambda index, a destination and an explicit list of free
variables, rather than reading the free variables from X0 to X(NumFree-1) the
way make_fun2 does. The lambda table entry still starts with the function's
atom, arity, label, index, free-variable count and unique value, so the label
and the free-variable count come from the same table the older instruction uses;
only the instruction encoding changed. The disassembler prints five operands
because it resolves the atom and the unique value into the instruction.
call_fun and call_fun2 were also setting the continuation pointer to
index + 1, while call sets it to the index of the call instruction and the
interpreter loop advances past it. Every call through a fun therefore skipped
the instruction after the call: in funs_demo, run/0 skipped the swap that
moved the fun into the argument register and passed a number instead.
Fun identity on the wire¶
term_to_binary/1 writes an internal fun as NEW_FUN_EXT, which names the
module version the fun belongs to by a 16 byte digest and the index of the
lambda in the module's function table. The digest is the value
beam_lib:md5/1 returns, so it has to be computed the way beam_lib
computes it: over the significant chunks in the order md5_chunks/0 lists
them, not in file order, with the OldUniq of every lambda table record
replaced by zero. OldIndex and OldUniq themselves are ordinary integer
terms in the term stream. A receiver checks the digest against the module
it has loaded and rejects a mismatch with badfun, so a node that holds
an older compile of a module can not run a fun that was built from a
newer one.
Cross-module calls and tail calls¶
A cross-module non-tail call runs the callee's instruction stream as a nested
activation: Module.apply pushes the callee's frame, clears the continuation
pointer, and puts the caller's pointer back when the callee returns. Clearing
it is what makes a return end the callee's own stream instead of jumping to
an index that belongs to another module.
call_ext_last and call_ext_only replace their activation. When the target
is BEAM code and no catch frame is active, the interpreter does not nest a
Java call: it restores the continuation pointer, releases the activation's
stack words, records where the replaced activation has to return inside its
caller, replaces the stack frame, and continues at the target's first
instruction in the same loop. The recorded continuations belong to their
interpreter frame and are carried across a wait or a yield, so a tail chain
that parks is resumed at the right place. This trampoline is what lets an
alternating cross-module tail loop yield instead of growing the JVM stack.
When the target is a built-in, or an exception handler is active, the old
nested path runs instead. There the callee runs first and the frame and stack
cleanup happens afterwards, so a raise inside it still reports the tail-calling
frame; doing the cleanup first is what broke the crash report and monitor
reason tests. call_last is a local tail call and stays a label jump.
apply_last with no continuation pointer can use the same trampoline. With a
non-zero continuation pointer it still ends the invocation: it tears down its
own frame, and returning to the pointer double-deallocates.
A fun whose lambda lives in another module cannot be reached by jumping to a
label, because a label is resolved in the stream that runs it. call_fun and
call_fun2 run such a fun as its own activation; a fun from the current module
remains a label jump, which is what funs_demo exercises.
Suspension¶
A parked process loses the Java frames of the activations it was running in,
so a wait records the module and the index of the wait instruction, and every
activation that makes a non-tail cross-module call pushes a resume point: the
module and the index after the call. On wakeup execute() resumes the parked
stream first, then re-enters the recorded activations innermost first, with the
callee's result in X0, until one of them parks again or the process ends. This
is what lets proc_lib:start/3 wait in its own module while its caller sits
above it, and what keeps OTP's proc_lib running once erlang:apply/3 has
entered the behaviour's init function.
Exceptions¶
An OTP 29 try is compiled into try (install a handler), try_end (leave the
block normally), try_case (enter the catch clauses) and, when the class does
not match, raise, which re-raises the class left in X0, taking the stacktrace
and then the reason as operands. catch Class:Reason:Stack additionally emits
build_stacktrace (turn the raw stacktrace in X0 into a list) and raw_raise
(re-raise X0/X1/X2 unchanged).
JErlang's Error now carries the exception class (error, throw or exit),
the reason and the stacktrace, and handlers put them in X0, X1 and X2, which is
what try_case reads. Handlers form a chain, so a nested try restores the
enclosing one when it is entered or left.
An exception raised in a called function unwinds to the handler that installed
the try: the interpreter drops the callee's stack frame and restores the
continuation pointer saved at the try, so the catch clause returns to the
right caller. A catch expression has no handler code of its own and continues
after the caught call, so it leaves the frame alone.
An exception that reaches the top of a process terminates that process with
its reason instead of stopping the virtual machine. The scheduler then sends
the reason to the process's links as exit signals and to its monitors as
DOWN messages.
The scheduler also prints an Erlang-style error report, on standard output like OTP's default logger handler, with the stacktrace captured at the raise:
=ERROR REPORT==== 2026-10-28::17:00:00.000000 ===
Error in process <0.33.0> with exit value:
{{badmatch,2},[{crash_demo,crash_now,1,[{file,"crash_demo.erl"},{line,19}]}]}
The reason carried to links and monitors is the same value printed here. An
uncaught error terminates the process with {Reason, Stacktrace}, and an
uncaught throw with {{nocatch, Value}, Stacktrace}; erlang:throw/1
raises the throw class that makes the nocatch wrapper possible. exit/1
and a process killed by an exit signal keep their plain reason, and because
they are normal ways to stop, print nothing.
Stack frames¶
Process keeps one activation per Erlang function call. A frame is recorded
when a function is entered through the export table (Module.apply) or through
a local, fun or dynamic call, and is dropped by return, by a tail call that
replaces the activation, or by an exception that unwinds to a handler. The
stacktrace at a raise is therefore a real list, youngest call first, with each
frame shaped like the ones the BEAM emulator produces:
{try_demo,caught,1,[{file,"try_demo.erl"},{line,14}]}
{try_demo,run,0,[{file,"try_demo.erl"},{line,67}]}
The location comes from the module's line table. Line references are 1-based
and 0 means "no source location"; when the line table has no filename table,
the base name of the compile info chunk's source entry is used. BIFs do not
get a frame, matching the BEAM, and raw_raise re-raises the frames captured at
the original raise.
try_demo.erl covers all of this, and
TryCatchTest runs it.
Timers¶
Every VM timer is scheduled on one hashed timer wheel: 512 one-millisecond
slots, a single daemon thread and one lock. receive ... after,
erlang:send_after, erlang:start_timer, timer:send_after,
erlang:cancel_timer and erlang:read_timer all use it. A
TimerReference wraps the wheel's handle, so a cancelled or already-fired
timer still answers cancel and read_timer the way OTP does. The action runs
outside the wheel lock, which lets a timer callback schedule or cancel another
timer.
A slot can contain a long timer and a short one whose deadlines share the same
slot index. The wheel only fires a task whose deadline has arrived; the long
task stays in its slot and is looked at again one wrap later. The wheel wakes
at the earliest deadline, with a 100 ms cap so a cancellation or a newly added
timer is noticed promptly. A 10 ms timer is checked against a 250 ms bound by
Milestone7Test, through all four entry points.
The timer module loads OTP's timer.beam when it is on the code path, so
its full API wins over the runtime's fallback. The fallback exports
send_after/2,3 and cancel/1; send_after returns OTP's
{ok, {send_local, TRef}} shape, and cancel/1 accepts that wrapper.
timer:apply_after, the interval timers and the {abs, ...} timer options are
not implemented by the fallback.
Process information and flags¶
process_info/1,2 reports registered_name, dictionary,
message_queue_len, messages, links, trap_exit, status,
current_function, reductions, memory, heap_size, stack_size,
max_heap_size and message_queue_data. memory is an estimate over the
runtime's arrays and mailbox, not a measured JVM object graph; heap_size and
stack_size stay zero for the same reason. current_function is the youngest
stack frame, or undefined outside a function.
process_flag/2 accepts trap_exit, monitor_nodes, priority,
message_queue_data (on_heap or off_heap), and max_heap_size (a map with
size, kill and error_logger). The heap limit and the queue mode are
stored and reported but not enforced yet. The OTP flags async_dist,
sensitive, save_calls, min_heap_size, min_bin_vheap_size and
error_handler are accepted as no-ops so a real module that sets one does not
crash; an unknown flag is a badarg.
Scheduling and priorities¶
Each scheduler has four run queues, in strict order: max, high, normal and low.
spawn_opt(Fun, [{priority, Level}]) sets the priority before the process is
queued, and process_flag(priority, Level) moves a process that is still
queued to the new queue. A process that is already running finishes its slice
and is queued at the new priority. There is no load balancing between
schedulers.
Tracing¶
erlang:trace/3 gives a process the call, send, receive and
return_to flags and a tracer process; the tracer is the caller unless a
{tracer, Pid} option says otherwise. erlang:trace_pattern/3 installs a
match spec for an MFA, a module, or all. true and a non-empty spec list
enable tracing; false and the empty list disable it. The body is evaluated
as an action list. {message, Term} supplies the event payload,
{return_trace} and {exception_trace} select return_from and
exception_from events, {silent, Bool} controls the process trace mode, and
display, enable_trace, disable_trace and trace actions are supported.
A call event is {trace, Pid, call, {Module, Function, Args}}, a return event
is {trace, Pid, return_from, {Module, Function, Arity}, Value}, an exception
event is {trace, Pid, exception_from, {Module, Function, Arity}, {Class, Reason}},
a send event is {trace, Pid, send, Message, To}, and a receive event is
{trace, Pid, receive, Message}. Local tail calls (call_only, call_last),
cross-module tail calls that use the trampoline, and calls that run through
Module.apply all report a call event. Local returns report their own
return_from and return_to events. Trace messages are never traced
themselves, so a tracer that reads them cannot feed itself.
What still blocks other programs¶
Every opcode a current erlc emits has a handler: 191 opcodes are known, 30
of them are the old deprecated arithmetic and bit-syntax instructions, and the
remaining 161 all execute. There is no "unsupported opcode" list for OTP 29
programs any more; the conformance corpus in
src/test/erl/corpus runs in CI and is the exit test
for this.
The one language feature still missing is native records, the experimental
OTP 29 data type declared as -record #name{...}. A native record is a
distinct runtime type that captures its field definition; implementing it needs
a new term type, not another opcode. The type-test opcodes therefore always
fail, field extraction jumps to its fail label, and construction raises
error:"native records are not supported by this runtime". Modules that do not
use native records are unaffected.
Elixir and Gleam, measured¶
The earlier text on this page called Elixir's standard library "the remaining
barrier". That was a prediction. The measurement below replaces it with what
the runtime actually did on 2026-11-04, using Elixir 1.20.5 (built for OTP 29),
Gleam 1.19.2, gleam_stdlib 1.0.5 and Erlang/OTP 29.1.2.
ConformanceReport over the compilers' own standard libraries:
| Corpus | Modules | Loaded | Every instruction handled |
|---|---|---|---|
Elixir lib/elixir/ebin |
271 | 271 | 271 |
Gleam gleam_stdlib |
20 | 20 | 20 |
Loading is not running, so a small module was compiled by each compiler and
called through Node.apply/3. The Elixir GenServer and supervisor calls also
had OTP's stdlib ebin on the code path:
| Call | Elixir 1.20.5 | Gleam 1.19.2 |
|---|---|---|
| integer arithmetic | 5 |
5 |
| binary/string concatenation | <<"hello world">> |
<<"hello world">> |
| tuple construction | {1,2} |
{1,2} |
| recursive list sum | 6 |
6 |
spawn/send/receive |
3 |
not tested |
| standard-library map | Enum.map -> [2,4,6] |
list.map -> [2,4,6] |
GenServer start/call/stop |
{true, 5} |
not tested |
| supervised crash and restart | {2, 0} |
not tested |
The Elixir GenServer row starts a registered server, calls it, and stops it.
The supervisor row starts a GenServer under a one_for_one supervisor, adds
2 to its state, kills it with Process.exit/2, waits, and reads the restarted
server; {2, 0} is the pre-crash state and the reset state after restart. That
is the failure model the project exists to test, running from Elixir source.
The measurement found one instruction gap on the path to a working supervisor.
OTP's compiler stores a map in a Y register when the map survives a later call,
and both put_map_assoc and put_map_exact only wrote X destinations. An
Elixir Supervisor start failed with Not implemented: {put_map_assoc, ...}
until both handlers accepted Y. They now do, and put_map_y.beam, compiled by
OTP 29.1.2, is the regression fixture. This was a runtime gap, not an Elixir or
Gleam language gap. A separate crash-report path in the Java supervision
example still exits with a badmatch on its logger metadata; that example
records the remaining gap.
This is an investigation, not a checked-in example and not a CI gate: CI does
not install Elixir or Gleam, and the probe used local toolchains. The useful
next step is a Mix application or a Gleam OTP actor, because single processes,
supervision and both standard libraries now run. A compatibility example should
wait until those toolchain versions are pinned and the remaining OTP surface
(applications, releases, Logger backends) has its own checks.
Not changed¶
- The shell still accepts only zero-argument
m:f().expressions.
receive ... after inside an inbound distribution call¶
The defect described here was fixed after 1.0.0. The measurements below are from 1.0.0 as published, and the ones after them are from the fixed build, both against a real OTP 29 peer.
In 1.0.0, a process created to serve an erpc call that arrived over
distribution did not park in a receive with an after clause: it returned
[] immediately when the message was not already in the mailbox.
| Call on 1.0.0 | From an inbound erpc:call |
From Java, in-process |
|---|---|---|
receive never -> got after 200 -> timed_out end |
[] in 3.6 ms |
timed_out in 214 ms |
a child sends, the parent receives with after 200 |
[] in 0.9 ms |
{got,42} in 0.9 ms |
send to a registered process, receive the reply with after 200 |
[] in 1.1 ms |
[a,b] in 0.6 ms |
A receive whose message is already in the mailbox worked in both cases, so the
defect was the waiting, not delivery. The consequence was concrete: a stateful
module served to a cluster answered [] instead of its state, because every
request/reply pattern is written this way.
The cause: erpc:execute_call/4 is a Java function, and it ran the target with
erlang:apply/3 inside its own Java frame. Suspension unwinds that frame,
because only the interpreter's own activations carry a resume point, so the
process finished without the result and the monitor reported what was left in
x0.
The fix hands the reply to the exit path. execute_call/4 records the result
reference when the call parks instead of throwing a reply it cannot build,
Interpreter leaves a parked process WAITING rather than marking it exited,
and the scheduler builds {Ref, return, x0} when the process finally stops
running. A failure raised after the resume becomes {Ref, error, Reason} by the
same route.
| Call on the fixed build | From an inbound erpc:call |
From Java, in-process |
|---|---|---|
receive never -> got after 200 -> timed_out end |
timed_out in 222 ms |
timed_out in 213 ms |
a child sends, the parent receives with after 200 |
{got,42} in 4.3 ms |
{got,42} in 1.4 ms |
send to a registered process, receive the reply with after 200 |
[a,b] in 2.7 ms |
[a,b] in 1.5 ms |
message already in the mailbox, receive with after 200 |
got_it in 2.8 ms |
got_it in 0.7 ms |
1.0.1 contains this fix, and io.github.slayyyyer:jerlang:1.0.1 resolves from
Maven Central. 1.0.0 is published and cannot change, so anything still pinned
to it keeps the old behaviour.