Skip to content

Commit b30dd1f

Browse files
ausimianclaude
andcommitted
fix: accept the two release files an unpacked release has
Castle.Peer.release_file/1 listed the version directory, filtered for *.rel and required exactly one, refusing anything else as ambiguous. An unpacked release always holds two, so the peer path refused every release it will ever be asked to configure - found by Forecastle's e2e suite the first time it drove this path. Mix writes one release file into the version directory, <name>.rel, the one belonging to the start script. release_handler adds a second on unpack: it extracts the tarball's <name>-<vsn>.rel, reads that to decide what the version is - check_rel on the way in, and RELEASES written from the record it returns - and then copies it in beside Mix's, "keeping this for backwards compatibility reasons with older systools:make_tar, where there is no copy of the .rel file in the releases/<vsn> dir. See OTP-9746." For a tarball systools built the two names are the same and that copy overwrites Mix's, which is why OTP's comment reads as harmless. They differ here, so both survive. Two shapes are therefore expected and no others: one file, which is a version as Mix assembled it, and that pair, which is an unpacked one. release_handler's copy is the one read, because it is the file the version was admitted on and the applications and emulator version RELEASES records came from those very bytes - so if the two ever disagreed, it is the release as the system understands it. They are byte-identical in practice: Forecastle writes the tarball's copy from Mix's with File.cp!, the tar carries it unchanged, and release_handler's copy is a read and a write of the whole file, all three checked rather than assumed. Nothing compares them, deliberately. This is a choice of which file is authoritative rather than a tie-break between equals, so it holds however the two differ, and a difference in bytes that cannot change the answer is no reason to refuse an install that works. The pair is recognised by the version alone, which is the version directory's own name - release_handler copies into releases/<Vsn> for the Vsn it has just read out of the file, and Mix assembles into releases/<version> - so the release name never has to be found from anywhere. Anything else refuses as before and still names what it found: two names that are not that pair are two release files rather than two copies of one, and so is any third. The comment saying a version directory holds exactly one is what shipped this. It was true of an assembled release and false of every unpacked one, which is the only kind this path meets, and Castle.SyntheticRelease modelled the assembled shape faithfully enough that seven rounds of review had nothing to notice. So the fixture now builds the unpacked shape as well - and builds it by default, since the peer path is reached from install and commit and so meets a version that came out of a tarball. 29 of the 45 tests in peer_test.exs fail against 3ec5979 for that reason alone, every one that materialises anything, which is the gap that mattered here rather than the missing clause. Six tests are new. The unpacked shape resolves, and its two files are asserted to hold the same bytes. The copy unpacking left is the one read, shown by planting a bogus emulator version in Mix's copy: with the two identical, nothing but a difference between them can show which was read. A version Mix assembled still resolves from its one file. And three directories that are genuinely ambiguous are still refused - two unrelated names, a copy belonging to another version, and a third file beside the pair - the second of which is what stops the pair being recognised by suffix rather than by this version. No RELEASE.md entry. The peer path is new in this unreleased version and has never been reachable from a released one, so no consumer could have seen the refusal; what the Added entry already promises is what now works. Verified on 1.19.5 and on 1.20.0 through mise, a recent change here having compiled clean on 1.19 and failed 1.20's type checker: mix compile --warnings-as-errors is clean and the suite passes 90 under both. Refs: #13 Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C4oaMvbR1cbxrZBj8qwkqN
1 parent 3ec5979 commit b30dd1f

4 files changed

Lines changed: 192 additions & 13 deletions

File tree

AGENTS.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,15 @@ puts the other on the peer's code path, and asserts that the answer came from
388388
the peer's. Peer cleanup is asserted from outside: a provider records the
389389
operating system pid of the VM it ran in, and the test waits for it to go.
390390

391+
It builds the **unpacked** shape by default, and the assembled one only when a
392+
test asks for it — because the peer path is reached from `install` and `commit`,
393+
so every version it is asked to configure was unpacked from a tarball. The two
394+
shapes differ in the version directory's release files: Mix assembles one,
395+
`<name>.rel`, while unpacking leaves two, that one plus the `<name>-<vsn>.rel`
396+
`release_handler` copies in beside it. A fixture that only built the assembled
397+
shape is how `Castle.Peer` came to refuse every unpacked release as ambiguous,
398+
having been reviewed seven times against a directory the peer path never meets.
399+
391400
`Castle.Peer.materialise/2` takes `:boot_timeout` and `:resolve_timeout` for the
392401
same reason `Castle.Commands` takes the module to talk to — a deadline nothing
393402
can shorten is a deadline no test can show is enforced. Two tests give it a

lib/castle/peer.ex

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -467,9 +467,43 @@ defmodule Castle.Peer do
467467
end
468468

469469
# Mix names the release file after the release, so it is found rather than
470-
# named. A version directory holds exactly one: Mix renames the one belonging
471-
# to the `start` script and removes the rest. Listed rather than globbed,
472-
# since a release root is a path and not a pattern.
470+
# named. A version directory holds one or two of them, and which it holds says
471+
# how the version got there.
472+
#
473+
# Mix's copy is `<name>.rel` - the release file belonging to the `start`
474+
# script, renamed after the release - and a version directory as Mix
475+
# assembled it holds that one alone. Unpacking a tarball into it adds a
476+
# second: `release_handler` extracts the tarball's `<name>-<vsn>.rel`, reads
477+
# *that* to decide what the version is - `check_rel` on the way in, and the
478+
# `RELEASES` entry is written from the record it returns - and then copies it
479+
# into the version directory, "keeping this for backwards compatibility
480+
# reasons with older systools:make_tar, where there is no copy of the .rel
481+
# file in the releases/<vsn> dir. See OTP-9746." For a tarball `systools`
482+
# built the two names are the same and that copy overwrites Mix's, which is
483+
# why the comment reads as harmless. They differ here, so both survive, and
484+
# every unpacked release has two.
485+
#
486+
# Of the two, `release_handler`'s copy is the authoritative one: it is the
487+
# file the version was admitted on, and the applications and emulator version
488+
# `RELEASES` records came from those very bytes, so it is the release as the
489+
# system understands it. The two are byte-identical in practice - Forecastle
490+
# writes the tarball's copy from Mix's with `File.cp!`, the tar carries it
491+
# unchanged, and `release_handler`'s copy is a read and a write of the whole
492+
# file - and nothing here compares them, deliberately. This is a choice of
493+
# which file is authoritative rather than a tie-break between equals, so it
494+
# holds however the two differ, and a difference in bytes that cannot change
495+
# the answer is no reason to refuse an install that works.
496+
#
497+
# Anything other than those two shapes is refused, naming what it found: two
498+
# names that are not that pair are two release files rather than two copies of
499+
# one, and so is any third. The version is all the pair is recognised by, and
500+
# it is the version directory's own name - `release_handler` copies into
501+
# `releases/<Vsn>` for the `Vsn` it has just read out of the file, and Mix
502+
# assembles into `releases/<version>` - so the directory is the version, and
503+
# the release name never has to be known here.
504+
#
505+
# Listed rather than globbed, since a release root is a path and not a
506+
# pattern.
473507
defp release_file(rel_vsn_dir) do
474508
case File.ls(rel_vsn_dir) do
475509
{:ok, entries} ->
@@ -489,11 +523,34 @@ defmodule Castle.Peer do
489523
end
490524

491525
defp release_file(rel_vsn_dir, names) do
492-
{:error,
493-
"Found more than one release file in #{rel_vsn_dir} - #{Enum.join(names, ", ")} - so " <>
494-
"the emulator to evaluate its configuration with is ambiguous."}
526+
case unpacked_copy(names, Path.basename(rel_vsn_dir)) do
527+
{:ok, name} ->
528+
{:ok, Path.join(rel_vsn_dir, name)}
529+
530+
:none ->
531+
{:error,
532+
"Found more than one release file in #{rel_vsn_dir} - #{Enum.join(names, ", ")}. An " <>
533+
"unpacked version directory holds two, a release file and the copy unpacking " <>
534+
"leaves beside it, and these are not that pair - so the emulator to evaluate its " <>
535+
"configuration with is ambiguous."}
536+
end
537+
end
538+
539+
# The copy `release_handler` made, recognised only while the file it was made
540+
# from is still beside it: the one name of the two that is the other with the
541+
# version in it.
542+
defp unpacked_copy([first, second], vsn) do
543+
cond do
544+
first == copy_of(second, vsn) -> {:ok, first}
545+
second == copy_of(first, vsn) -> {:ok, second}
546+
true -> :none
547+
end
495548
end
496549

550+
defp unpacked_copy(_names, _vsn), do: :none
551+
552+
defp copy_of(name, vsn), do: "#{Path.rootname(name)}-#{vsn}.rel"
553+
497554
## sys.config, and the base it is resolved from
498555

499556
# The base is `sys.config.pristine` once there is one, and `sys.config` itself

test/castle/peer_test.exs

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,12 +247,86 @@ defmodule Castle.PeerTest do
247247

248248
test "reports a version directory with no release file", %{tmp_dir: root} do
249249
vsn_dir = SyntheticRelease.build(root)
250-
File.rm!(Path.join(vsn_dir, "synthetic.rel"))
250+
for path <- Path.wildcard(Path.join(vsn_dir, "*.rel")), do: File.rm!(path)
251251

252252
assert {:error, message} = Castle.Peer.materialise(vsn_dir)
253253
assert message =~ "Cannot find a release file"
254254
end
255255

256+
# Every version this path is asked to configure was unpacked from a tarball,
257+
# and an unpacked version directory holds two release files: the one Mix put
258+
# inside the tarball's version directory, and the copy `release_handler`
259+
# makes of the tarball's own `<name>-<vsn>.rel` beside it. That pair is not
260+
# ambiguous, and refusing it refused every install - which is what the rest
261+
# of this file now builds by default.
262+
test "reads the release file in a version directory that was unpacked", %{tmp_dir: root} do
263+
vsn_dir = SyntheticRelease.build(root)
264+
265+
assert Enum.sort(rel_files(vsn_dir)) == ["synthetic-1.0.0.rel", "synthetic.rel"]
266+
267+
# The two hold the same bytes, which is why nothing but a difference
268+
# planted between them can show which one was read.
269+
assert File.read!(Path.join(vsn_dir, "synthetic-1.0.0.rel")) ==
270+
File.read!(Path.join(vsn_dir, "synthetic.rel"))
271+
272+
assert Castle.Peer.materialise(vsn_dir) == {:ok, []}
273+
end
274+
275+
test "reads the copy unpacking left, and not Mix's", %{tmp_dir: root} do
276+
vsn_dir = SyntheticRelease.build(root)
277+
278+
# Mix's copy, made to name an emulator that is not there. It is the copy
279+
# `release_handler` admitted the version on that says what the version is -
280+
# `RELEASES` was written from those bytes - so that is the one to read, and
281+
# preferring this one would refuse a release that boots.
282+
File.write!(
283+
Path.join(vsn_dir, "synthetic.rel"),
284+
:io_lib.format(~c"~tp.~n", [
285+
{:release, {~c"synthetic", ~c"1.0.0"}, {:erts, ~c"0.0.0"}, []}
286+
])
287+
)
288+
289+
assert Castle.Peer.materialise(vsn_dir) == {:ok, []}
290+
end
291+
292+
test "reads the one release file a version Mix assembled has", %{tmp_dir: root} do
293+
vsn_dir = SyntheticRelease.build(root, shape: :assembled)
294+
295+
assert rel_files(vsn_dir) == ["synthetic.rel"]
296+
assert Castle.Peer.materialise(vsn_dir) == {:ok, []}
297+
end
298+
299+
test "refuses two release files that are not one release's", %{tmp_dir: root} do
300+
vsn_dir = SyntheticRelease.build(root, shape: :assembled)
301+
File.cp!(Path.join(vsn_dir, "synthetic.rel"), Path.join(vsn_dir, "other.rel"))
302+
303+
assert {:error, message} = Castle.Peer.materialise(vsn_dir)
304+
assert message =~ "Found more than one release file"
305+
assert message =~ "synthetic.rel"
306+
assert message =~ "other.rel"
307+
end
308+
309+
test "refuses a copy that belongs to another version", %{tmp_dir: root} do
310+
# What is accepted is a release file and the copy made of it *for this
311+
# version*, since the version directory's own name is the version. A name
312+
# that merely looks like such a copy is two release files.
313+
vsn_dir = SyntheticRelease.build(root, shape: :assembled)
314+
File.cp!(Path.join(vsn_dir, "synthetic.rel"), Path.join(vsn_dir, "synthetic-2.0.0.rel"))
315+
316+
assert {:error, message} = Castle.Peer.materialise(vsn_dir)
317+
assert message =~ "Found more than one release file"
318+
assert message =~ "synthetic-2.0.0.rel"
319+
end
320+
321+
test "refuses a third release file beside the pair", %{tmp_dir: root} do
322+
vsn_dir = SyntheticRelease.build(root)
323+
File.cp!(Path.join(vsn_dir, "synthetic.rel"), Path.join(vsn_dir, "other.rel"))
324+
325+
assert {:error, message} = Castle.Peer.materialise(vsn_dir)
326+
assert message =~ "Found more than one release file"
327+
assert message =~ "other.rel"
328+
end
329+
256330
test "gives up on a peer that never boots, at the deadline", %{tmp_dir: root} do
257331
vsn_dir = SyntheticRelease.build(root, config: with_providers([{PeerProviderStub, []}], []))
258332
File.write!(Path.join(vsn_dir, "preboot.boot"), "not a boot script")
@@ -783,6 +857,7 @@ defmodule Castle.PeerTest do
783857
"preboot.boot",
784858
"preboot.script",
785859
"synthetic.rel",
860+
"synthetic-1.0.0.rel",
786861
"sys.config",
787862
"sys.config.pristine",
788863
Path.join(work, "sys.config"),
@@ -978,6 +1053,8 @@ defmodule Castle.PeerTest do
9781053

9791054
defp mode(path), do: Bitwise.band(File.stat!(path).mode, 0o777)
9801055

1056+
defp rel_files(vsn_dir), do: Enum.filter(File.ls!(vsn_dir), &String.ends_with?(&1, ".rel"))
1057+
9811058
# What the peer saw, as paths relative to the version directory.
9821059
defp snapshot(path, relative_to) do
9831060
assert {:ok, [entries]} = :file.consult(to_charlist(path))

test/support/synthetic_release.ex

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@ defmodule Castle.SyntheticRelease do
1515
# applications - kernel, stdlib, sasl, compiler, elixir and castle
1616
# started, anything else on the code path only, which is the shape
1717
# Forecastle's preboot script has,
18-
# * `releases/<vsn>/<name>.rel`, which is where the emulator version comes
19-
# from, and
18+
# * the release file or files, which is where the emulator version comes
19+
# from - see `write_release_files/4`, since a version directory holds one
20+
# of them or two depending on how the version got there, and
2021
# * `releases/<vsn>/sys.config`.
2122
#
2223
# The boot script is generated by `systools` from the real application
@@ -25,6 +26,9 @@ defmodule Castle.SyntheticRelease do
2526

2627
@started [:kernel, :stdlib, :sasl, :compiler, :elixir, :castle]
2728

29+
# The release name, which is what the release file or files are named after.
30+
@name "synthetic"
31+
2832
@doc """
2933
Builds a release under `root` and returns its version directory.
3034
@@ -36,6 +40,9 @@ defmodule Castle.SyntheticRelease do
3640
* `:apps` - `{app, vsn, dir}` triples to add to the code path without
3741
starting them, which is how a provider module that lives outside the
3842
applications preboot starts is reached
43+
* `:shape` - `:unpacked`, the default, or `:assembled`; which release files
44+
the version directory holds, and so how the version is to have got there.
45+
See `write_release_files/4`
3946
"""
4047
def build(root, opts \\ []) do
4148
vsn = Keyword.get(opts, :vsn, "1.0.0")
@@ -49,7 +56,7 @@ defmodule Castle.SyntheticRelease do
4956
apps = apps ++ for {app, app_vsn, dir} <- extra, do: {app, app_vsn, :none, dir}
5057

5158
make_boot_script(vsn_dir, vsn, apps, link_apps(lib, apps, extra))
52-
write_rel(Path.join(vsn_dir, "synthetic.rel"), vsn, apps)
59+
write_release_files(vsn_dir, vsn, apps, Keyword.get(opts, :shape, :unpacked))
5360
write_sys_config(vsn_dir, opts)
5461

5562
vsn_dir
@@ -159,8 +166,10 @@ defmodule Castle.SyntheticRelease do
159166
end
160167

161168
# `systools` writes the `.rel` it was given back out beside the boot script,
162-
# and a version directory holds exactly one release file, so preboot's goes as
163-
# soon as it has been used - which is what Mix does with its own.
169+
# so preboot's goes as soon as it has been used - which is what Mix does with
170+
# the release file of every boot script but `start`. Left there it would be a
171+
# release file no release has, and one `Castle.Peer` would be right to refuse
172+
# to choose between.
164173
defp make_boot_script(vsn_dir, vsn, apps, paths) do
165174
path = Path.join(vsn_dir, "preboot")
166175
write_rel(path <> ".rel", vsn, apps)
@@ -178,14 +187,41 @@ defmodule Castle.SyntheticRelease do
178187
File.rm!(path <> ".rel")
179188
end
180189

190+
# The release file, under the name or names the shape being built has.
191+
#
192+
# `:assembled` is what `mix release` leaves: one file, `<name>.rel`, the one
193+
# belonging to the `start` script, which Mix renames after the release.
194+
#
195+
# `:unpacked` is that plus `<name>-<vsn>.rel`, and is what
196+
# `release_handler:unpack_release/1` leaves behind - so it is the shape the
197+
# peer path meets, every version it is asked to configure having been unpacked
198+
# from a tarball. The second name is the copy Forecastle puts at the top of
199+
# the tarball's `releases` directory, which `release_handler` reads to admit
200+
# the version and then copies in here, for compatibility with tarballs that
201+
# carried no release file inside the version directory. Made by copying,
202+
# because copying is how the two come to hold the same bytes in a real
203+
# release: `File.cp!` at assembly, and a read and a write of the whole file on
204+
# unpack.
205+
#
206+
# The unpacked shape is the default because it is the one reality presents. A
207+
# test that means the assembled shape asks for it.
208+
defp write_release_files(vsn_dir, vsn, apps, shape) do
209+
assembled = Path.join(vsn_dir, "#{@name}.rel")
210+
write_rel(assembled, vsn, apps)
211+
212+
if shape == :unpacked do
213+
File.cp!(assembled, Path.join(vsn_dir, "#{@name}-#{vsn}.rel"))
214+
end
215+
end
216+
181217
defp write_rel(path, vsn, apps) do
182218
entries =
183219
for app_spec <- apps do
184220
{elem(app_spec, 0), to_charlist(elem(app_spec, 1)), elem(app_spec, 2)}
185221
end
186222

187223
rel =
188-
{:release, {~c"synthetic", to_charlist(vsn)}, {:erts, :erlang.system_info(:version)},
224+
{:release, {to_charlist(@name), to_charlist(vsn)}, {:erts, :erlang.system_info(:version)},
189225
entries}
190226

191227
File.write!(path, :io_lib.format(~c"~tp.~n", [rel]))

0 commit comments

Comments
 (0)