Skip to content

Support provisional restart upgrades under external process supervisors #14

Description

@ausimian

Summary

Support OTP restart_emulator transitions when the release is supervised by systemd, Docker, Kubernetes, or another external process supervisor rather than heart.

This is part of completing the stock Mix launcher architecture: Castle should preserve OTP's release-state semantics while adapting the restart handshake so an external supervisor remains responsible for starting the new BEAM process.

Verified OTP behaviour

On OTP 28.3, plain restart_emulator does use OTP's provisional restart preparation path. After the relup evaluator returns restart_emulator, release_handler calls prepare_restart_new_emulator/7, which:

  • marks the target release current, then persists it as tmp_current in RELEASES;
  • writes releases/new_start_erl.data naming the target ERTS/release version;
  • calls heart:set_cmd/1 with the classic OTP start command;
  • only after that succeeds does the outer handler call init:reboot().

restart_new_emulator is different because it first creates a hybrid temporary release (new ERTS/kernel/stdlib/SASL + old remaining applications) and continues the rest of the upgrade after reboot. It is not distinguished by exclusive use of new_start_erl.data, tmp_current, or heart; plain restart_emulator uses those mechanisms too.

The actual compatibility gap

A normal Mix release supervised by systemd/Docker/etc. usually does not run with -heart.

heart:set_cmd/1 fails with badarg if the registered heart process does not exist. Therefore a plain restart_emulator transition currently fails during OTP's restart preparation and never reaches the intended init:reboot().

Classic OTP also assumes a start_prg such as $ROOT/bin/start that accepts the generated start-data file; a Mix release instead owns bin/<release> and selects RELEASE_VSN itself.

We should adapt these assumptions without replacing the Mix launcher or reimplementing OTP's RELEASES state machine.

Proposed architecture

1. Preserve OTP's provisional release state

Continue to use plain restart_emulator for full-restart transitions. Let release_handler own:

  • tmp_current state;
  • RELEASES persistence/reconciliation;
  • new_start_erl.data generation;
  • make_permanent/1 / start_erl.data semantics.

Do not introduce Castle-owned edits of RELEASES or replace OTP's transactional state model unless this approach proves infeasible.

2. Bridge the heart:set_cmd/1 handshake — real heart, defanged

Run real heart, configured so it is operationally inert, and let the external supervisor remain the sole restart authority. This keeps us entirely on documented OTP interfaces.

Setting Purpose
-heart, via ELIXIR_ERL_OPTIONS in env.sh the heart process exists, so heart:set_cmd/1 returns ok instead of raising badarg
HEART_NO_KILL=TRUE heart does not SIGKILL a node that misses heartbeats — but see the correction below, it does not keep the node alive
HEART_BEAT_TIMEOUT=65535 heart's documented maximum, which is what actually makes the timeout unreachable
HEART_COMMAND unset nothing happens on an unexpected death when no temporary command is set
$ROOT/bin/start, shipped by Forecastle and inert (exit 0) neutralises the temporary command release_handler installs

release_handler then completes its normal preparation and calls init:reboot(); the BEAM exits; the supervisor starts bin/<release> again; the env hook selects the provisional version.

Correction — HEART_NO_KILL does not mean the node survives a heartbeat timeout. An earlier revision of this section said "heart never kills a node that misses heartbeats", measured by SIGSTOPing a beam and observing it still in state T after heart had run the command. That measurement was wrong, because a stopped process cannot act on its own shutdown. The node dies the moment it resumes:

heart: heart-beat time-out, no activity for 15 seconds
heart: Executed ".../probe.sh t" -> 0. Terminating.
Kernel pid terminated (heart) ({port_terminated,{heart,loop,...}})

HEART_NO_KILL suppresses the SIGKILL, but the port program terminates once it has run the command, the Erlang heart process then exits {port_terminated, …}, and heart is a kernel process linked to init (new_kernelpid/3) — so init halts the node anyway.

So -heart gives a stalled node a way to die that a deployment without heart did not have. That is what HEART_BEAT_TIMEOUT=65535 is for: at the 60-second default this is reachable by an ordinary long stall, and at heart's documented maximum it is not.

The inert bin/start is still mandatory, and for the reason originally givenHEART_NO_KILL suppresses the kill but not the command, so heart runs system(cmd) on a timeout. A bin/start that launched the target would start it while the old node was still up, in the window before init gets round to halting it. Doing nothing in that script is the whole point, and the HEART_NO_KILL documentation assumes as much — "useful if the command executed by heart takes care of this".

Also measured, and the reason bin/start must exist rather than merely being named: heart does execute the temporary command on init:reboot() with HEART_COMMAND unset, receiving the data file as $1.

Keep it at the default start_prg path. The default is {no_check, $ROOT/bin/start} and needs no configuration; setting {sasl, start_prg} explicitly is {do_check, _} and would mean injecting :sasl config, which Forecastle deliberately no longer does.

Rejected alternative — impersonating heart. A Castle process registered as heart answering {Caller, set_cmd, Cmd} with {heart, ok} also works, and was the earlier recommendation here. It depends on an OTP-internal message protocol behind the public API, and heart:wait/0 is a receive with no after clause — so a shim that took the message and died before replying would leave release_handler blocked inside handle_call indefinitely, wedging every subsequent release operation on the node. Real heart avoids both. Keep the shim only as a fallback if running -heart proves impossible in some supported deployment.

Rejected alternative — heart as the restarter. Real heart plus a bin/start that actually starts the target, alongside a supervisor with Restart=, gives two independent authorities starting the service. Sharing one implementation between the two entry points does not help: the problem is duplicated authority, not duplicated code.

3. Use the stock Mix launcher for the provisional boot

After the BEAM exits, the external supervisor starts the standard bin/<release> again.

The Castle/Forecastle release env hook should:

  1. detect a valid one-shot releases/new_start_erl.data;
  2. atomically consume/rename it;
  3. extract the target release version;
  4. set RELEASE_VSN;
  5. re-exec the untouched Mix launcher so it recomputes REL_VSN_DIR from that version.

On subsequent starts, with no provisional marker, the stock launcher uses ordinary releases/start_erl.data and therefore boots the permanent release.

Rollback semantics

Desired transition:

A permanent
  -> install B with restart_emulator
  -> OTP persists B as tmp_current + writes new_start_erl.data
  -> BEAM exits
  -> external supervisor restarts
  -> env hook boots B provisionally
  -> B is current in memory, A remains permanent

If B crashes before commit:

no one-shot provisional marker
  -> external supervisor starts normally
  -> start_erl.data still names A
  -> A boots again

If B is accepted:

Castle.commit(B)
  -> release_handler.make_permanent(B)
  -> start_erl.data now names B

Failure handling

A failed restart preparation may already have written new_start_erl.data before heart:set_cmd/1 fails. release_handler can reconcile tmp_current back to unpacked when SASL restarts on the old script, but the launcher hook must not blindly consume a stale marker from a failed transition.

The implementation therefore needs an explicit validity/one-shot strategy coupling the Castle restart preparation and Forecastle env hook.

Acceptance criteria

  • Plain restart_emulator works without requiring heart to own process restart.
  • OTP remains authoritative for RELEASES, tmp_current, new_start_erl.data, and make_permanent/1 semantics.
  • The standard Mix bin/<release> script is not replaced or patched.
  • External supervisor restart boots the provisional target through the env-hook/re-exec path.
  • A second restart before commit rolls back to the previous permanent release.
  • Castle.commit/1 makes the target permanent and ordinary starts select it afterward.
  • Failed restart preparation cannot leave a stale marker that is later mistaken for a valid provisional boot.
  • heart is operationally inert: a heart-beat timeout neither kills the node nor starts anything, and an unexpected death starts nothing.
  • The heart configuration and the inert bin/start are integration-tested across all supported OTP versions. heart's behaviour lives in a C port program rather than in heart.erl, so it is not verifiable by source review and has to be measured per OTP release.
  • Verified under each supported supervisor — systemd, Docker restart policies, runit — that the supervisor is the only thing that starts the replacement, and that -heart in foreground mode causes no trouble. Mix recommends heart only for daemon* and does not say why.

Related work

  • ausimian/forecastle#4: generates explicit hot vs restart_emulator transitions.
  • ausimian/forecastle#10: launcher-side new_start_erl.data selection/re-exec hook.
  • ausimian/forecastle#3: established the invariant that the Mix-generated launcher remains stock.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions