Skip to content

mix release boot scripts preload only the bootstrap module set, exposing release boots (eval/migrations) to an OTP kernel-start race #15596

Description

@bruckner

Elixir and Erlang/OTP versions

Erlang/OTP 28 [erts-16.4] [64-bit] [smp] [jit]
Elixir 1.19.5 (compiled with Erlang/OTP 28)

The generated boot script shape is the same on current main. The
underlying OTP race is erlang/otp#11353 (fix proposed in
erlang/otp#11354) and affects at least OTP 27 and 28.

Operating system

Linux (Debian 13), x86_64 and arm64.

Current behavior

Boot scripts generated by mix release (start.script,
start_clean.script) contain a single primLoad with only the
~29-module bootstrap set (error_handler, code, code_server,
logger*, proc_lib, ...). Everything else — including stdlib
modules the logger needs while handling early kernel-start work
(maps, sys, queue, io_lib) — is lazy-loaded during boot. The
stock start_clean.script shipped inside an OTP install instead
preloads all of kernel+stdlib before any kernel process starts.

That difference exposes every mix-release boot to erlang/otp#11353:
code_server:init registers its name before publishing its mode in
persistent_term, and error_handler switches to
code:ensure_loaded/1 (which reads that term) as soon as the name
resolves — so a lazy load landing in that init window dies with a
persistent_term:get(code_server) badarg. When the loading process is
the logger kernel process, the boot dies:

Kernel pid terminated (logger) ({badarg,[{persistent_term,get,[code_server],...},
  {code_server,get_mode,0,...},{code,ensure_loaded,1,...},
  {error_handler,undefined_function,3,...},{logger_backend,call_handlers,3,...},
  {proc_lib,exit_p,3,...}]})

In production this hit us through bin/<release> eval — the standard
migration/maintenance path — failing fleet-wide on a host while the
running application stayed healthy. A deterministic trigger is any
early-boot logger event; e.g. running the release from a directory the
release user cannot read makes erl_prim_loader emit file_error
reports for the "." code-path entry on every lazy load (an easy state
to be in: cd /root && runuser -u app -- bin/app eval ...). With that
trigger the crash reproduces 5/5; with a readable cwd the same boot
passes 5/5. Full analysis, an Elixir-free reproduction script, and a
crash-dump anatomy are in erlang/otp#11353.

Expected behavior

Release boots should not be able to lazy-load, during the kernel-start
window, modules that OTP's own boot scripts always preload. Two
options, either of which closes the exposure regardless of when/whether
the OTP fix ships:

  1. Generate primLoad phases for kernel+stdlib modules the way the
    stock start_clean.script does (load-before-kernel-start), or

  2. Minimally, extend the bootstrap primLoad with the closure the
    early logger path can touch. The set we deployed and verified
    (crash 5/5 → pass, plus 3/3 on the originally affected host):

    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
    

Workaround we ship today, as a release step, in case it is useful to
anyone landing here (patches both generated scripts and regenerates the
.boot files):

# mix.exs — releases: [myapp: [steps: [:assemble, &preload_boot_modules/1, :tar]]]
def preload_boot_modules(%Mix.Release{version_path: vsn_path} = release) do
  for name <- ["start", "start_clean"],
      script = Path.join(vsn_path, name <> ".script"),
      File.exists?(script) do
    {:ok, [{:script, info, instrs}]} = :file.consult(script)

    {:done, patched} =
      Enum.reduce(instrs, {:pending, []}, fn
        {:primLoad, mods}, {:pending, acc} ->
          {:done, [{:primLoad, Enum.sort(Enum.uniq(mods ++ @boot_preload_modules))} | acc]}

        instr, {state, acc} ->
          {state, [instr | acc]}
      end)

    File.write!(script, :io_lib.format(~c"%% coding: utf-8~n~tp.~n", [
      {:script, info, Enum.reverse(patched)}
    ]))

    :ok = :systools.script2boot(script |> Path.rootname() |> String.to_charlist())
  end

  release
end

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions