Skip to content

feat: install restart transitions under an external supervisor - #26

Merged
ausimian merged 6 commits into
release/1.0.0from
issue/14-restart
Aug 23, 2026
Merged

feat: install restart transitions under an external supervisor#26
ausimian merged 6 commits into
release/1.0.0from
issue/14-restart

Conversation

@ausimian

Copy link
Copy Markdown
Owner

Refs #14. Targets release/1.0.0. Atomic with forecastle#19 — neither half works alone. Merge this one first.

Restart transitions now install under an external supervisor, and the restart path runs end to end for the first time.

The blocker this removes

release_handler calls heart:set_cmd/1 while preparing any transition that restarts the emulator — prepare_restart_new_emulator/7, which both restart instructions go through, at two call sites. With no heart process that raises badarg, so the install fails before init:reboot(). A Mix release under systemd normally runs without -heart.

The fix is Forecastle's: run real OTP heart, deliberately defanged, with the supervisor as the only restart authority. Castle's half is the marker protocol and the serialization around it.

The marker is evidence about one install attempt

releases/castle-restart-pending holds the target version and an attempt id. arm/4 is ordered, and the order is load-bearing:

  1. refuse if a marker is already there — one pending restart install at a time
  2. clear any stale new_start_erl.data
  3. publish the marker, staged and hard-linked exactly as sys.config.pristine is

Refusing after clearing would silently take a concurrent install's reboot away. And after the clear, new_start_erl.data can only exist because this attempt wrote it — which is what lets "marker + OTP's file + versions agree" mean this attempt asked for the reboot. Version agreement alone could not carry that: a failed attempt to X leaves OTP's file behind, a retry to X arms a fresh marker, and a restart in between would have booted X though nothing installed it.

disarm/3 removes the marker only while the attempt id still matches, and answers four ways — ours (removed, or a failed removal reported), theirs (left, a reboot still owed), gone (quiet), unverifiable (reported). A stranded marker is an explicit failure, not a silent one, because an operator who is not told meets it as a surprise boot.

The whole install is serialized, not just the arming

:global.trans restricted to [node()], around the which_releases read, classification, materialisation, arming, install_release/1 and disarm. global_name_server is a kernel process, so this works on a non-distributed node — measured on nonode@nohost with is_alive() == false: two callers, the second blocks until the first completes.

Two races closed:

  • Serializing only install_release/1 — OTP's own lock — is too late. Two callers both pass the pending-marker check, the loser's clear destroys the winner's live evidence, and the winner's reboot falls back to the permanent release with a false "Nothing has been changed".
  • Materialisation had to move inside the lock and behind the refusals. Inside but ahead of the refusal, the second caller still renames its sys.config over the first's on the way to being told no — and providers are not obliged to be idempotent, which is why sys.config.pristine exists. Moving it behind the refusals also means a refused install no longer materialises at all, retiring a cost AGENTS.md had accepted.

It also collapsed a bug that had nothing to do with races: the running release was being read twice, so classification and arming could reason from two different moments.

Exception-safe via an implicit try/catch/elsenot after, which cannot see which way the block went and would disarm a successful restart install, destroying the marker whose whole purpose is to outlive the call. There is a test forbidding that.

commit/1 and running/1 needed nothing, and that is measured

transform_release/3 writes the tmp_current record back as unpacked on disk while set_current/2 makes it current in memory, because init:script_id() names it — exactly the status do_make_permanent/2 accepts. Verified against OTP 27–29 source during review rather than assumed.

The invariant that makes the residuals tolerable

Every remaining edge fails in the same direction — a lost or duplicated reboot, never a wrong release made permanent — because make_permanent/1 is the only writer of start_erl.data and nothing in these paths touches it. Adversarial review tried to break that across four rounds and could not.

Declared limitations

  • The lock is node-local. A second VM running Castle.install/1 against the same releases directory gets only the filesystem half. A filesystem lock would close it at the price of a stale lock wedging every later install after a hard kill.
  • commit/1 materialises outside the install lock, so committing X while installing the same X leaves two renames onto one sys.config unordered. Review confirmed this is not reachable by a routine install → crash → restart → install → commit sequence.
  • disarm/3 is check-then-unlink across two syscalls, and no POSIX call unlinks by identity. Fails toward a lost reboot.
  • A hard kill between arming and installing can strand a marker pair. Bounded: start_erl.data still names the old version, so the wrong boot is one boot.

Tests

112 → 140. Written to fail against what they name, and mutation-checked: reinstating the pre-lock materialisation fails the boundary case on the second caller's peer having been called; the exception and stranded-marker cases were each confirmed to fail against the pre-fix behaviour.

The concurrency tests barrier at which_releases/0 — the first thing inside the region and the last before arming. The destructive interleaving cannot be pinned by an end state even without the lock, because it needs a caller suspended between two adjacent statements with no seam, so the discriminator is that the second caller's lookup never happens: refute_receive, which can pass spuriously but never fail spuriously. Written down so the next reviewer does not hunt for an assertion that cannot exist.

Castle.install/2..5 mirrors Commands.install/5 so two concurrent callers can be driven through the public boundary; arity-1 is unchanged and nothing in a release passes the extras.

Verification

mix precommit green across all six CI cells — Elixir 1.18×OTP27, 1.19×OTP27/28, 1.20×OTP27/28/29 — with compile --warnings-as-errors. Four adversarial review rounds; fourteen findings across the pair, none declined.

The restart path itself is exercised by forecastle#19's :e2e suite, which drives unpack → install → reboot → provisional boot → rollback → commit against a real release. It also closed a documented gap here: the provisional boot is a cold boot of the materialised sys.config, so a value changed between boots proves the target's own providers re-ran over it.

ausimian and others added 5 commits August 23, 2026 14:00
release_handler calls heart:set_cmd/1 while preparing any transition that
reboots the emulator, and it writes the installed version to
releases/new_start_erl.data while deliberately leaving
releases/start_erl.data - what the stock launcher reads - naming the
version that is still permanent. So on a Mix release the install failed
before rebooting, and past that the reboot would have come back on the
version being upgraded away from.

install/4 now classifies the transition from the relup before
release_handler is asked for anything, and arms
releases/castle-restart-pending with the target version when a reboot is
coming. Forecastle's env.sh fragment consumes that marker together with
OTP's own, requires the two to name one version, and re-execs the stock
launcher on it. The two halves are useless apart; forecastle#10 lands
with this.

Two markers rather than one because new_start_erl.data is written before
the reboot and nothing ever removes it, so a preparation that failed
after writing it leaves a file naming a version that was never
installed - transform_release/3 reconciles the release record and not the
file. The marker is cleared on every failing path, and an install that
cannot arm one is refused rather than performed: the alternative is a
reboot that loses the upgrade with nothing saying so.

It has to be a prediction rather than a reaction. A one-stage
restart_emulator is replied to with {ok, Vsn, Descr}, exactly as a
completed hot upgrade is, and init:reboot() has already been called by
the time the reply arrives - so a marker cleared on {ok, ...} would race
the shutdown. which_releases/0 is still asked once: the record check and
the classification are both about the release the system is running.

The two-stage restart_new_emulator is deliberately not armed for. The
marker OTP writes for it names the temporary hybrid release, whose
version directory holds a start.boot and a sys.config and none of the
launcher's own files, so there is nothing there for a launcher to boot.

What the install reports changes with it: that the version was installed
and the emulator is restarting, and that it stays provisional until it is
committed, rather than "Now running" - which is false for as long as the
reboot takes, and which automation reads.

commit/1 needed nothing, and that is measured rather than assumed:
transform_release/3 writes the tmp_current record back as unpacked on
disk while set_current/2 makes it current in memory, which is exactly
the status do_make_permanent/2 accepts.

Refs: ausimian/forecastle#10
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C4oaMvbR1cbxrZBj8qwkqN
Two files that agree on a version do not establish that one install
produced them. prepare_restart_new_emulator/7 writes new_start_erl.data
before the reboot and nothing removes it, so a failed attempt to X left
OTP's half of the pair behind; a retry to X armed a fresh marker beside
it, and a manual or hard restart before the retry reached
install_release/1 then presented a matching pair for an install that
never happened - the node booted X while which_releases/0 reported it
unpacked. Back-to-back or concurrent installs broke it the other way,
by overwriting and disarming each other's marker.

arm_restart/4 is three steps now, and the order is the protocol. A
marker already at the path refuses the install, so there is one pending
restart install at a time and the look comes before anything
destructive. Any new_start_erl.data is then cleared - safe, because
write_new_start_erl/3 goes through file:write_file/2, which creates the
file when it is absent - so past that point the file existing means
this attempt's own preparation wrote it. The marker is staged in an
owner-only working directory and hard-linked into place, the way
Castle.Peer publishes sys.config.pristine: a link publishes a file that
is already complete, and refuses rather than replaces. An exclusive
create in place has neither property - it makes creation atomic and
leaves the file empty until the write, and a death there leaves an
empty marker that blocks every later attempt.

The marker carries the attempt on a second line - operating system pid,
wall clock in nanoseconds, serial - and disarm/2 removes it only while
it still says so. The name is shared and the marker is short-lived,
because any start or daemon of the deployment consumes it whether or
not it goes on to boot, so removing it by name would take a later
attempt's reboot away. The hook reads only the first line, which is the
version, so nothing on the shell side parses anything it did not
before.

Castle.ReleaseHandlerStub takes a function as a reply now, because the
states this rests on exist only while install_release/1 is in flight: a
preparation that writes new_start_erl.data and then fails, so a
same-version retry can be shown to clear it; the filesystem as a hard
restart before the reboot would find it, which is the marker alone; and
a marker replaced between the arming and the disarming.

The claim that both markers are consumed atomically was false, and is
corrected here and in the release note. What is atomic is the claim of
Castle's marker; OTP's file is read and removed separately, and no
POSIX operation moves two files together. The order is what makes that
safe - the marker goes first, so an interruption loses the selection
rather than misapplying it.

Refs: ausimian/forecastle#10
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C4oaMvbR1cbxrZBj8qwkqN
arm_restart/4's three steps are one caller's sequence, and the argument
that release_handler serialises install_release/1 anyway does not carry
them: that serialisation is downstream of the entire protocol. Two
callers read the running release, classify the transition and pass the
marker check before either of them publishes anything, so refusing
before clearing buys nothing across processes. The loser reaches
clear_provisional/3 after the winner's install_release/1 has written
new_start_erl.data, deletes the winner's live evidence, and refuses -
the winner's reboot then comes back on the permanent release, install
waits for a version that never becomes the running one, and the operator
is told that nothing has been changed by the process that changed it.

The protocol is not reordered to avoid that, because publishing before
clearing would leave the marker pairable with a stale new_start_erl.data
and boot a version nothing installed, which is worse. What is added is
that there is only ever one caller in it: the running-release read, the
classification, the arming, install_release/1 and the disarming all run
inside :global.trans/3 over [node()]. The region has to reach that far
for a second reason - restart_planned?/3 is a prediction about the
running release, so a concurrent hot install moves the from-version and
do_get_rh_script/4 then selects a different relup entry from the one
that was classified.

global_name_server is a kernel process and runs whether or not
distribution does, and a lock restricted to [node()] talks to the local
one only, so this works on a node with is_alive() == false - which is
the ordinary case here, and the case it was measured on. trans/3
releases the lock in an after and global monitors the holder, so a
caller that dies does not wedge every later install; retries are
infinity, so there is no aborted to mean anything by. It needs no
process of Castle's own, which a supervised lock server would have added
to the managed system's supervision tree for a command that runs a
handful of times in a deployment's life. [node()] rather than
[node() | nodes()] because every caller arrives by rpc on the running
node: a cluster-wide lock would wait on nodes that share nothing with
the deployment and would still not cover a caller in another VM, which
is the boundary and is documented as one.

It waits rather than refusing, and the waiter then meets the marker
check and is told a restart install is pending - the same message as
before, said about a pair that is complete instead of said while taking
half of it away. Only install takes the lock: unpack, commit and remove
arm nothing, and the materialisation Castle.install/1 does first stays
outside it.

The tests drive two real callers with a barrier at which_releases/0,
which is the first thing inside the region and the last before the
arming - so a caller held there is one that has taken the region and
armed nothing, which is the state two of them could previously occupy at
once. The discriminator is that the second caller's lookup never
happens; the end state is deliberately not one, because the interleaving
that destroys evidence needs a caller suspended between the marker check
and the clearing, and those are adjacent. A third test installs one
relup concurrently from two different running versions, where the same
target is hot from one and a restart from the other, so the
classification is shown to belong to the caller that made it.

Refs: ausimian/forecastle#10
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C4oaMvbR1cbxrZBj8qwkqN
Three round-three review findings, two of them in this repo.

Materialisation raced the serialised install. `Castle.install/1` composed
`Commands.materialise/3` and then `Commands.install/4`, so two callers
both configured the target before either reached the lock. The argument
for keeping it outside was that its primitives refuse rather than
replace; that is true of the staging and of `sys.config.pristine` and
false of the step that matters, because materialising *ends* in a rename
onto `sys.config` - a replace by design, since that is the file
`:release_handler` reads. So the loser's providers could overwrite the
configuration the winner's provisional release was about to boot, after
which the loser was refused for the winner's marker: a refused install
decided what a successful one booted. Providers are not obliged to agree
across evaluations, which is why `sys.config.pristine` exists at all.

Materialising is now the third step inside `Commands.install/5`, after
the record check and after the pending-marker refusal, so a caller that
is going to be refused configures nothing. Inside the lock alone would
not have been enough: ahead of `unclaimed/3` it would still have replaced
the configuration on its way to being told no, and there is a test whose
only job is to fail against that arrangement. The boundary moved out with
it - `Castle.install/1` is one call, so "an install is serialised" is
true of the public entry point rather than of a part of it. Only the ERTS
guard stays outside, which changes what an ERTS-less deployment is told
by `install`: "Cannot install", not "Cannot configure".

An exceptional failure left an actionable marker pair. Cleanup ran only
when `install_release/1` returned an error or an unrecognised value, so
an exit, a throw or a raise skipped it - and where
`prepare_restart_new_emulator/7` had already written
`new_start_erl.data`, that left the complete pair the launcher acts on.
The region is now an implicit `try` with `catch` and `else` clauses, not
`after`: an `after` cannot see which way the block went and would disarm
the successful restart install too, taking away the marker whose whole
purpose is to outlive the call.

Failing to settle the marker is now reported rather than swallowed.
Ignoring `File.rm/1` rested on a directory the marker cannot be removed
from being one it could not have been linked into, which holds only if
nothing changed in between - and `install_release/1` runs in between.
An unreadable marker was worse than ignored, it was classified as another
attempt's and left alone. `disarm/3` now answers four ways: ours is
removed and a failed removal reported, theirs is left, gone is success,
and unverifiable is reported. What the operator is told names the file,
says OTP's own file may be beside it, says an ordinary restart will
therefore boot the version the install did not finish, and asks for the
marker to be removed first.

The read and the removal go through `Castle.Deployment`, for the reason
`stat/1` is there: the answers that decide what Castle says are the
failing ones, and every fixture that makes them fail uses a mode, which
root and some filesystems ignore.

Claude-Session: https://claude.ai/code/session_01C4oaMvbR1cbxrZBj8qwkqN
The case that pins two callers not deciding each other's configuration
drove `Commands.install/5`, and the defect it names was a composition in
`Castle.install/1` - `materialise/3` and then the install, so both callers
configured the target before either reached the lock. Putting that
composition back therefore left the case green: it was asserting about a
function the defect was never in.

`Castle.install/1` now takes the releases directory, the handler, the peer
and the deployment as defaulted arguments, the way `Commands.install/5`
already did, and that one case runs two concurrent callers through the
boundary `bin/castle` actually reaches over `rpc`. `installer/3` takes
`through: :boundary` for it; the boundary prints what succeeded and raises
what failed, so `invoke/2` puts both back into the shape
`Commands.install/5` returns - `with_io/1` inside the task, because that is
whose group leader has to be swapped.

Verified by reintroducing the composition: the case fails on the second
caller's peer having been called and on the target holding its
configuration, which is exactly what it is for.

Every other concurrency case stays on `Commands.install/5`, which is the
right level for a claim about the serialised region itself. The arity-1
form is unchanged and nothing in a release passes the extra arguments, so
no caller chooses anything about the deployment.

Claude-Session: https://claude.ai/code/session_01C4oaMvbR1cbxrZBj8qwkqN
@ausimian

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6b299b64de

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread RELEASE.md
Comment on lines +120 to +123
`:release_handler` for anything, and leaves a marker beside the release records
naming the version being installed. The launcher's `env.sh` fragment, which
Forecastle 1.0.0 contributes, consumes that marker on the next start and boots
the version it names.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Pin the paired Forecastle marker consumer

A fresh checkout still resolves Forecastle from mix.lock:7 at 3f32f70c..., the earlier pin that only picked up forecastle#4, so it does not contain the paired env.sh marker consumer/heart setup described here. Because mix deps.get honors that lock, releases built from this commit arm castle-restart-pending but retain the old launcher; under an external supervisor the restart install therefore still fails at heart:set_cmd/1 or cannot consume the marker on restart. Update the Forecastle lock to the paired change before advertising this path, since the repository explicitly states that the two halves are useless apart.

AGENTS.md reference: AGENTS.md:L600-L606

Useful? React with 👍 / 👎.

Two findings from a Castle review of the restart-marker work.

commit/1 composed materialise-then-commit at the boundary with no lock, so a
duplicate install of the version being committed could configure it between
the two steps: the commit succeeds, that install then fails as already
installed, and its configuration is what the newly permanent release boots on
the next restart. A failed caller deciding what a successful one boots, which
is the failure this protocol exists to prevent, through the one operation
still outside the serialised region.

The reason it was left outside does not hold. AGENTS.md called putting commit
behind the install lock a deadlock dressed as caution, because an install
waiting on a reboot is when a commit is wanted - but an install never holds
the lock across a reboot. install_release/1 replies before init:reboot(), the
reboot runs in release_handler's process, and after a restart transition the
VM that held the lock is gone. The only thing commit can wait for is a hot
install still inside install_release/1, where waiting is correct.

So Commands.commit/5 takes rel_dir and materialises inside serialised/2, and
the refusal an ERTS-less deployment used to hear from commit changes from
naming the configuration step to naming commit itself. That asymmetry was the
visible symptom of the composition, and erts_guard_test now pins the new
direction, so a reappearance means a composition came back at the boundary.

Separately, arm/4's occupied and stale refusals both claimed nothing had been
changed. Materialisation runs before arming, so the target's configuration has
already been replaced by then and the claim was false. They now say what did
and did not happen. unclaimed/3's refusal keeps the phrase, because it fires
before materialising and there it is true.

Co-Authored-By: Claude <noreply@anthropic.com>
@ausimian

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Delightful!

Reviewed commit: c0acc72e88

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@ausimian
ausimian merged commit 517b7ae into release/1.0.0 Aug 23, 2026
7 checks passed
@ausimian
ausimian deleted the issue/14-restart branch August 23, 2026 12:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant