Skip to content

Commit c8a2ac5

Browse files
ausimianclaude
andcommitted
fix: report a failed commit as possibly partial
`do_make_permanent/2` writes `releases/start_erl.data` through `set_permanent_files/5` before `write_releases/3` updates the release record, and a throw from that write - or from the Windows service update or the `ok = init:make_permanent/2` after it - is caught by `handle_call/3` and returned as `{:error, reason}`. So a returned error can arrive with the file that decides what an ordinary restart boots already naming the target. Saying Castle "did not make it permanent" therefore asserted the absence of an effect that may have happened, and told an operator the rollback still held when it may not - which is the part they act on. Report the commit as possibly partial instead, name the file that may already select the version, and direct them to `bin/castle releases`. The test's refutation is the discriminator: the previous wording keeps every other assertion in that case green. Claude-Session: https://claude.ai/code/session_01RotroiBdbidiCRX3KqE3Dy Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent c3d6143 commit c8a2ac5

4 files changed

Lines changed: 57 additions & 5 deletions

File tree

AGENTS.md

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -978,9 +978,22 @@ is `<Operation> failed for <target>`. That distinction is operational state, not
978978
style. Install failures say the target configuration step completed and direct
979979
the operator to inspect release state; a non-returning install folded into a
980980
marker error says the install state may have changed. A failed
981-
`make_permanent/1` says the target configuration step completed but the version
982-
was not made permanent. "Completed" is deliberate: provider-less releases may
983-
change no configuration file.
981+
`make_permanent/1` says the target configuration step completed and that the
982+
commit **may be partial**, then directs the operator to release state.
983+
"Completed" is deliberate: provider-less releases may change no configuration
984+
file.
985+
986+
**It must not say the version was not made permanent, and it did.**
987+
`do_make_permanent/2` writes `releases/start_erl.data` through
988+
`set_permanent_files/5` *before* `write_releases/3` updates the record, and a
989+
throw from that write — or from the Windows service update or the
990+
`ok = init:make_permanent/2` after it — is caught by `handle_call/3` and
991+
returned as `{:error, reason}`. So the file that decides what an ordinary
992+
restart boots can already name the target on a call that failed. Asserting the
993+
absence of an effect that may have happened is worse than reporting the
994+
uncertainty, because the rollback is the thing an operator acts on: the message
995+
states the partial case and sends them to `bin/castle releases`. Do not
996+
"tighten" it back into a claim about what was not done.
984997

985998
Filesystem reasons in `Castle.Commands` and `Castle.Peer` both go through
986999
`Castle.FileReason`. It formats atoms with `:file.format_error/1`, retaining the

RELEASE.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ Forecastle 1.x and Elixir 1.18 or later.
5050
before restarting, retrying or removing a marker. Also explain the `unpacked`
5151
record left by an unfinished install and fix the former "a other" wording for
5252
named pipes and similar marker-path conflicts.
53+
- Report a failed commit as possibly partial instead of claiming the version was
54+
not made permanent. `:release_handler` writes `releases/start_erl.data` before
55+
it updates the release record, so an error can leave the file that selects the
56+
boot version already naming the target; the message now says so and directs
57+
the operator to inspect release state.
5358
- Report restart installs without raising `CaseClauseError`.
5459
- Return an empty release list without raising `Enum.EmptyError`.
5560
- Report `RELEASES` read and write errors instead of raising `MatchError`.

lib/castle/commands.ex

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1449,6 +1449,13 @@ defmodule Castle.Commands do
14491449
So the file that decides what an ordinary restart boots is written here and
14501450
nowhere else, which is the whole of the rollback property: until this runs, a
14511451
restart returns to the version that was permanent before.
1452+
1453+
**A returned error does not establish that it did not run.**
1454+
`set_permanent_files/5` comes before `write_releases/3`, so a failure in that
1455+
record write - or in the service update or `init:make_permanent/2` after it -
1456+
leaves `releases/start_erl.data` already naming `vsn`. Such a commit is
1457+
partial rather than absent, and the error says so instead of claiming the
1458+
version was not made permanent.
14521459
"""
14531460
@spec commit(String.t(), Path.t(), module(), module(), module()) :: result()
14541461
def commit(
@@ -1488,6 +1495,19 @@ defmodule Castle.Commands do
14881495
# target configuration step has completed, but that need not mean a file was
14891496
# changed: a provider-less release can complete it as a no-op. The message
14901497
# reports the step, not a filesystem effect it cannot prove.
1498+
#
1499+
# **Nor may it claim the commit had no effect, and saying so was wrong.**
1500+
# `do_make_permanent/2` writes `releases/start_erl.data` through
1501+
# `set_permanent_files/5` and only *then* updates the release record through
1502+
# `write_releases/3` - which throws on a failed write, as do the Windows
1503+
# service update and the `ok = init:make_permanent/2` after it, and
1504+
# `handle_call/3` catches all three into the `{:error, reason}` seen here. So
1505+
# an error can arrive with the first write already on disk: the one file that
1506+
# decides what an ordinary restart boots may already name `vsn` even though
1507+
# the call failed. "Did not make it permanent" told an operator the rollback
1508+
# still held when it may not, which is the one thing they would act on. The
1509+
# message therefore reports the commit as possibly partial and sends them to
1510+
# the release state, rather than asserting an outcome this side cannot see.
14911511
defp commit_materialised(vsn, rel_dir, handler, peer, deployment) do
14921512
with {:ok, _} <- materialise(Path.join(rel_dir, vsn), peer, deployment) do
14931513
case handler.make_permanent(to_charlist(vsn)) do
@@ -1497,7 +1517,9 @@ defmodule Castle.Commands do
14971517
{:error, reason} ->
14981518
{:error,
14991519
"Commit failed for #{vsn}: #{inspect(reason)}. Castle completed the target " <>
1500-
"configuration step but did not make it permanent."}
1520+
"configuration step, and the commit may be partial: " <>
1521+
"releases/start_erl.data may already select #{vsn}. Run bin/castle " <>
1522+
"releases to inspect release state before restarting."}
15011523
end
15021524
end
15031525
end

test/castle/commands_test.exs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1477,7 +1477,19 @@ defmodule Castle.CommandsTest do
14771477
assert message =~ "Commit failed for 1.2.3:"
14781478
assert message =~ "bad_status"
14791479
assert message =~ "Castle completed the target configuration step"
1480-
assert message =~ "did not make it permanent"
1480+
1481+
# `set_permanent_files/5` writes releases/start_erl.data before
1482+
# `write_releases/3` updates the record, so a returned error can arrive
1483+
# with the boot already selecting the target. The message has to report
1484+
# that as possibly partial and send the operator to release state; the
1485+
# refutation is the discriminator, since claiming the version was *not*
1486+
# made permanent reads as reassuring and tells them the rollback holds
1487+
# when it may not.
1488+
assert message =~ "the commit may be partial"
1489+
assert message =~ "releases/start_erl.data may already select 1.2.3"
1490+
assert message =~ "Run bin/castle releases"
1491+
refute message =~ "did not make it permanent"
1492+
14811493
assert configuration(dir, "1.2.3") == "[].\n"
14821494
assert PeerStub.calls() == [Path.join(dir, "1.2.3")]
14831495
end

0 commit comments

Comments
 (0)