Describe the bug
During kernel start, code_server:init/4 registers the code_server name
as its first action but only stores its mode in persistent_term as its
last action, after the full code-path setup:
init(Ref, Parent, [Root,Mode]) ->
register(?MODULE, self()), % name visible immediately
... % path scan — O(code path) work
persistent_term:put(?MODULE, Mode), % term visible only here
Parent ! {Ref,{ok,self()}},
loop(State).
error_handler routes undefined-function resolution by whether the
code_server name resolves. Inside the init window (registered, term not
yet put), a lazy load therefore reaches code:ensure_loaded/1 →
code_server:get_mode() → persistent_term:get(code_server) → badarg.
When the process hitting that window is the logger kernel process
(started earlier by init as a kernelProcess), the badarg is raised inside
logger_backend:call_handlers/3 while logger is handling early kernel-
start work that touches not-yet-loaded stdlib modules (maps:without/2,
then sys:get_log/1, queue in our traces). logger dies, init halts:
Kernel pid terminated (logger) ({badarg,[
{persistent_term,get,[code_server],...},
{code_server,get_mode,0,[{file,"code_server.erl"},{line,84}]},
{code,ensure_loaded,1,[{file,"code.erl"},{line,576}]},
{error_handler,undefined_function,3,...},
{logger_backend,call_handlers,3,[{file,"logger_backend.erl"},{line,53}]},
{proc_lib,exit_p,3,...}]})
Whether the load lands in the window is scheduler timing. On our
1-vCPU cloud host (steal time present) the race flipped and stayed
flipped: the same release binaries that booted correctly for hours
began failing 100% of boots — every session shape, env -i, fresh
$HOME, multiple independent release copies — and the already-running
node was unaffected. -kernel logger_level none boots fine (the event
is filtered before delivery), which we used to bisect. The stable flip
turned out to be environmental: our deploy tooling always ran with an
unreadable cwd (root's home, after runuser to the service user),
which generates the trigger reports on every boot — see the
deterministic reproduction below.
Why vanilla erl doesn't hit it, and who does
The stock start_clean.script in an OTP install has three primLoad
phases and loads all kernel+stdlib modules before any kernelProcess
starts, so nothing lazy-loads during the window. Boot scripts generated
for Elixir (mix release) contain only the ~29-module bootstrap
primLoad (error_handler, code, code_server, logger*, proc_lib, ...)
— no maps, sys, queue, io_lib — and load applications lazily.
Every bin/<release> eval (how Elixir deployments run DB migrations)
boots that shape, so a loaded host can make deploy tooling fail
fleet-wide while the running app stays healthy. That is how we found it.
To Reproduce (deterministic)
The trigger is any early-boot logger event; a reliable, portable source
is an unreadable working directory — the interactive-mode code path
probes "." on every lazy module load, and an EACCES there makes
erl_prim_loader emit a file_error report. The script below builds a
bootstrap-only boot script (stock start_clean with only the first
primLoad kept — the shape mix release generates for every Elixir
release, so kernel/stdlib modules lazy-load during boot) and boots it
twice as an unprivileged user; only the cwd differs:
#!/bin/sh
# Reproduces the code_server-init / logger boot race.
# Run as root on Linux with Erlang/OTP installed. Uses only OTP.
set -e
ROOT=$(erl -noshell -eval 'io:format("~s",[code:root_dir()]),halt().')
cp "$ROOT"/releases/*/start_clean.script /tmp/mini.script
# Keep only the first (bootstrap) primLoad — the shape mix-release
# boot scripts have — so kernel/stdlib modules lazy-load during boot.
erl -noshell -eval '
{ok,[{script,Info,Instrs}]} = file:consult("/tmp/mini.script"),
{Out,_} = lists:foldl(
fun({primLoad,L},{Acc,0}) -> {[{primLoad,L}|Acc],1};
({primLoad,_},{Acc,N}) -> {Acc,N};
(I,{Acc,N}) -> {[I|Acc],N}
end, {[],0}, Instrs),
ok = file:write_file("/tmp/mini.script",
io_lib:format("~p.~n",[{script,Info,lists:reverse(Out)}])),
ok = systools:script2boot("/tmp/mini"),
halt().'
chmod 644 /tmp/mini.boot
echo "--- cwd unreadable by nobody (expect boot crash, exit != 0):"
cd /root
runuser -u nobody -- erl -boot /tmp/mini -noshell -eval 'erlang:halt(0).' \
&& echo "RESULT: booted (no repro)" || echo "RESULT: CRASHED exit=$?"
echo "--- cwd readable (expect clean boot, exit 0):"
cd /tmp
runuser -u nobody -- erl -boot /tmp/mini -noshell -eval 'erlang:halt(0).' \
&& echo "RESULT: booted" || echo "RESULT: CRASHED exit=$?"
Verified: crash/pass flips exactly with cwd readability on
- OTP 28 (erts 16.4), x86_64 Debian 13, kerl-built — 5/5 crash vs 5/5 pass
- OTP 27, arm64 Debian 13 container, Debian
erlang-nox packages —
same signature (line numbers shift with the version:
code_server.erl:80, code.erl:558)
Crash output: exit 1, (no logger present) unexpected logger message ... <0.43.0> ... badarg persistent_term get code_server. <0.43.0>
is the registered logger process (confirmed via process_info in a
logger_level none boot; -kernel logger_level none also makes the
crashing variant boot, since the trigger event is filtered at source).
Without a deterministic trigger the same boot is a scheduler-timing
lottery — how we met it in production: a 1-vCPU cloud host flipped
stably between all-boots-pass (hours) and all-boots-fail on identical
binaries, because our deploy tooling always ran with cwd under /root
after runuser to the service user.
Diagnostic trail (error_handler instrumented with erlang:display of
each undef MFA) immediately before death:
{undef_mfa,os,internal_init_cmd_shell,0}
{undef_mfa,peer,supervision_child_spec,0}
{undef_mfa,beam_lib,module_info,1}
{undef_mfa,binary,module_info,1}
{undef_mfa,gb_sets,module_info,1}
{undef_mfa,gb_trees,module_info,1}
{undef_mfa,unicode,module_info,1}
{undef_mfa,erl_features,enabled,0}
{undef_mfa,maps,without,2}
{undef_mfa,sys,get_log,1}
{undef_mfa,maps,without,2} <- lands in the window, badarg, logger dies
Crash dump available (from a logger_level none-adjacent failing run,
written via ERL_CRASH_DUMP): shows kernel_sup blocked in
code_server:start_link/1 and code_server waiting in
init:request/1 — i.e. code_server mid-init at time of death.
Happy to share privately per the wiki's core-dump guidance.
Expected behavior
A lazy load during code_server's init should block (or fall back to the
init loader) rather than crash the caller; boot should not be able to
kill the logger kernel process through this path.
Affected versions
Confirmed on OTP 28 (erts 16.4, kernel 10.6.3, kerl build, x86_64) and
OTP 27 (Debian arm64 packages). The same init ordering exists on
maint-28 and master (lib/kernel/src/code_server.erl).
Suggested fix
Make the persistent term visible no later than the registered name —
mode is an init argument, so it can simply move first:
init(Ref, Parent, [Root,Mode]) ->
+ persistent_term:put(?MODULE, Mode),
register(?MODULE, self()),
...
- persistent_term:put(?MODULE, Mode),
Parent ! {Ref,{ok,self()}},
Callers that reach code:ensure_loaded/1 in the window then block in
the gen call until loop/1 starts serving, which is the pre-existing
semantics for calls arriving just after init completes. (Belt and
braces: error_handler could also catch this badarg and fall back to
its init-loader path.)
Downstream mitigation we deployed (works, verified 3/3 on the affected
host): extend primLoad in generated boot scripts with the modules the
early logger path can touch — maps sys queue binary gb_sets gb_trees
beam_lib os io io_lib io_lib_format io_lib_pretty unicode string
calendar proplists gen_statem logger_olp logger_proxy logger_h_common
logger_std_h logger_formatter erl_features. The Elixir project may want
this in mix release's script generation regardless.
Describe the bug
During kernel start,
code_server:init/4registers thecode_servernameas its first action but only stores its mode in
persistent_termas itslast action, after the full code-path setup:
error_handlerroutes undefined-function resolution by whether thecode_servername resolves. Inside the init window (registered, term notyet put), a lazy load therefore reaches
code:ensure_loaded/1→code_server:get_mode()→persistent_term:get(code_server)→ badarg.When the process hitting that window is the
loggerkernel process(started earlier by init as a kernelProcess), the badarg is raised inside
logger_backend:call_handlers/3while logger is handling early kernel-start work that touches not-yet-loaded stdlib modules (
maps:without/2,then
sys:get_log/1,queuein our traces). logger dies, init halts:Whether the load lands in the window is scheduler timing. On our
1-vCPU cloud host (steal time present) the race flipped and stayed
flipped: the same release binaries that booted correctly for hours
began failing 100% of boots — every session shape,
env -i, fresh$HOME, multiple independent release copies — and the already-running
node was unaffected.
-kernel logger_level noneboots fine (the eventis filtered before delivery), which we used to bisect. The stable flip
turned out to be environmental: our deploy tooling always ran with an
unreadable cwd (root's home, after
runuserto the service user),which generates the trigger reports on every boot — see the
deterministic reproduction below.
Why vanilla
erldoesn't hit it, and who doesThe stock
start_clean.scriptin an OTP install has threeprimLoadphases and loads all kernel+stdlib modules before any kernelProcess
starts, so nothing lazy-loads during the window. Boot scripts generated
for Elixir (
mix release) contain only the ~29-module bootstrapprimLoad(error_handler, code, code_server, logger*, proc_lib, ...)— no
maps,sys,queue,io_lib— and load applications lazily.Every
bin/<release> eval(how Elixir deployments run DB migrations)boots that shape, so a loaded host can make deploy tooling fail
fleet-wide while the running app stays healthy. That is how we found it.
To Reproduce (deterministic)
The trigger is any early-boot logger event; a reliable, portable source
is an unreadable working directory — the interactive-mode code path
probes
"."on every lazy module load, and an EACCES there makeserl_prim_loaderemit afile_errorreport. The script below builds abootstrap-only boot script (stock
start_cleanwith only the firstprimLoadkept — the shapemix releasegenerates for every Elixirrelease, so kernel/stdlib modules lazy-load during boot) and boots it
twice as an unprivileged user; only the cwd differs:
Verified: crash/pass flips exactly with cwd readability on
erlang-noxpackages —same signature (line numbers shift with the version:
code_server.erl:80,code.erl:558)Crash output: exit 1,
(no logger present) unexpected logger message ... <0.43.0> ... badarg persistent_term get code_server.<0.43.0>is the registered
loggerprocess (confirmed via process_info in alogger_level noneboot;-kernel logger_level nonealso makes thecrashing variant boot, since the trigger event is filtered at source).
Without a deterministic trigger the same boot is a scheduler-timing
lottery — how we met it in production: a 1-vCPU cloud host flipped
stably between all-boots-pass (hours) and all-boots-fail on identical
binaries, because our deploy tooling always ran with cwd under /root
after
runuserto the service user.Diagnostic trail (error_handler instrumented with
erlang:displayofeach undef MFA) immediately before death:
Crash dump available (from a
logger_level none-adjacent failing run,written via ERL_CRASH_DUMP): shows
kernel_supblocked incode_server:start_link/1andcode_serverwaiting ininit:request/1— i.e. code_server mid-init at time of death.Happy to share privately per the wiki's core-dump guidance.
Expected behavior
A lazy load during code_server's init should block (or fall back to the
init loader) rather than crash the caller; boot should not be able to
kill the logger kernel process through this path.
Affected versions
Confirmed on OTP 28 (erts 16.4, kernel 10.6.3, kerl build, x86_64) and
OTP 27 (Debian arm64 packages). The same init ordering exists on
maint-28andmaster(lib/kernel/src/code_server.erl).Suggested fix
Make the persistent term visible no later than the registered name —
mode is an init argument, so it can simply move first:
Callers that reach
code:ensure_loaded/1in the window then block inthe gen call until
loop/1starts serving, which is the pre-existingsemantics for calls arriving just after init completes. (Belt and
braces:
error_handlercould also catch this badarg and fall back toits init-loader path.)
Downstream mitigation we deployed (works, verified 3/3 on the affected
host): extend
primLoadin generated boot scripts with the modules theearly logger path can touch — maps sys queue binary gb_sets gb_trees
beam_lib os io io_lib io_lib_format io_lib_pretty unicode string
calendar proplists gen_statem logger_olp logger_proxy logger_h_common
logger_std_h logger_formatter erl_features. The Elixir project may want
this in
mix release's script generation regardless.