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 given — HEART_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:
- detect a valid one-shot
releases/new_start_erl.data;
- atomically consume/rename it;
- extract the target release version;
- set
RELEASE_VSN;
- 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.
Summary
Support OTP
restart_emulatortransitions when the release is supervised by systemd, Docker, Kubernetes, or another external process supervisor rather thanheart.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_emulatordoes use OTP's provisional restart preparation path. After the relup evaluator returnsrestart_emulator,release_handlercallsprepare_restart_new_emulator/7, which:current, then persists it astmp_currentinRELEASES;releases/new_start_erl.datanaming the target ERTS/release version;heart:set_cmd/1with the classic OTP start command;init:reboot().restart_new_emulatoris 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 ofnew_start_erl.data,tmp_current, orheart; plainrestart_emulatoruses 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/1fails withbadargif the registeredheartprocess does not exist. Therefore a plainrestart_emulatortransition currently fails during OTP's restart preparation and never reaches the intendedinit:reboot().Classic OTP also assumes a
start_prgsuch as$ROOT/bin/startthat accepts the generated start-data file; a Mix release instead ownsbin/<release>and selectsRELEASE_VSNitself.We should adapt these assumptions without replacing the Mix launcher or reimplementing OTP's
RELEASESstate machine.Proposed architecture
1. Preserve OTP's provisional release state
Continue to use plain
restart_emulatorfor full-restart transitions. Letrelease_handlerown:tmp_currentstate;RELEASESpersistence/reconciliation;new_start_erl.datageneration;make_permanent/1/start_erl.datasemantics.Do not introduce Castle-owned edits of
RELEASESor replace OTP's transactional state model unless this approach proves infeasible.2. Bridge the
heart:set_cmd/1handshake — real heart, defangedRun 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.-heart, viaELIXIR_ERL_OPTIONSinenv.shheartprocess exists, soheart:set_cmd/1returnsokinstead of raisingbadargHEART_NO_KILL=TRUESIGKILLa node that misses heartbeats — but see the correction below, it does not keep the node aliveHEART_BEAT_TIMEOUT=65535HEART_COMMANDunset$ROOT/bin/start, shipped by Forecastle and inert (exit 0)release_handlerinstallsrelease_handlerthen completes its normal preparation and callsinit:reboot(); the BEAM exits; the supervisor startsbin/<release>again; the env hook selects the provisional version.Correction —
HEART_NO_KILLdoes not mean the node survives a heartbeat timeout. An earlier revision of this section said "heart never kills a node that misses heartbeats", measured bySIGSTOPing a beam and observing it still in stateTafter 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_NO_KILLsuppresses theSIGKILL, but the port program terminates once it has run the command, the Erlangheartprocess then exits{port_terminated, …}, andheartis a kernel process linked toinit(new_kernelpid/3) — soinithalts the node anyway.So
-heartgives a stalled node a way to die that a deployment without heart did not have. That is whatHEART_BEAT_TIMEOUT=65535is 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/startis still mandatory, and for the reason originally given —HEART_NO_KILLsuppresses the kill but not the command, so heart runssystem(cmd)on a timeout. Abin/startthat launched the target would start it while the old node was still up, in the window beforeinitgets round to halting it. Doing nothing in that script is the whole point, and theHEART_NO_KILLdocumentation assumes as much — "useful if the command executed by heart takes care of this".Also measured, and the reason
bin/startmust exist rather than merely being named: heart does execute the temporary command oninit:reboot()withHEART_COMMANDunset, receiving the data file as$1.Keep it at the default
start_prgpath. The default is{no_check, $ROOT/bin/start}and needs no configuration; setting{sasl, start_prg}explicitly is{do_check, _}and would mean injecting:saslconfig, which Forecastle deliberately no longer does.Rejected alternative — impersonating heart. A Castle process registered as
heartanswering{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, andheart:wait/0is areceivewith noafterclause — so a shim that took the message and died before replying would leaverelease_handlerblocked insidehandle_callindefinitely, wedging every subsequent release operation on the node. Real heart avoids both. Keep the shim only as a fallback if running-heartproves impossible in some supported deployment.Rejected alternative — heart as the restarter. Real heart plus a
bin/startthat actually starts the target, alongside a supervisor withRestart=, 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:
releases/new_start_erl.data;RELEASE_VSN;REL_VSN_DIRfrom that version.On subsequent starts, with no provisional marker, the stock launcher uses ordinary
releases/start_erl.dataand therefore boots the permanent release.Rollback semantics
Desired transition:
If B crashes before commit:
If B is accepted:
Failure handling
A failed restart preparation may already have written
new_start_erl.databeforeheart:set_cmd/1fails.release_handlercan reconciletmp_currentback tounpackedwhen 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
restart_emulatorworks without requiringheartto own process restart.RELEASES,tmp_current,new_start_erl.data, andmake_permanent/1semantics.bin/<release>script is not replaced or patched.Castle.commit/1makes the target permanent and ordinary starts select it afterward.heartis operationally inert: a heart-beat timeout neither kills the node nor starts anything, and an unexpected death starts nothing.bin/startare integration-tested across all supported OTP versions. heart's behaviour lives in a C port program rather than inheart.erl, so it is not verifiable by source review and has to be measured per OTP release.-heartin foreground mode causes no trouble. Mix recommends heart only fordaemon*and does not say why.Related work
ausimian/forecastle#4: generates explicit hot vsrestart_emulatortransitions.ausimian/forecastle#10: launcher-sidenew_start_erl.dataselection/re-exec hook.ausimian/forecastle#3: established the invariant that the Mix-generated launcher remains stock.