Skip to content

Commit 4f9538c

Browse files
authored
Merge pull request #11 from ausimian/issue/8-stale-appup
fix: stop the appup compiler leaving a stale artefact in ebin
2 parents c523289 + 7663244 commit 4f9538c

6 files changed

Lines changed: 347 additions & 14 deletions

File tree

README.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,19 @@ for you. The steps are as follows:
160160
]
161161
end
162162
```
163-
163+
164+
The compiler owns `<app>.appup` for the whole of its life. It rewrites it on every
165+
build, and deletes it again if the source goes away or the `:appup` key is dropped,
166+
so that an incremental build cannot ship upgrade instructions belonging to an
167+
earlier version. Naming a file that does not exist is a compilation error: the
168+
project asked for an appup and cannot have one.
169+
170+
That housekeeping only happens while the compiler is registered. To turn an appup
171+
off for some environments, leave `:appup` in `:compilers` and let the `:appup` key
172+
be `nil` - the output is removed and nothing further is reported. Taking the
173+
compiler out of `:compilers` instead stops it running, and an output an earlier
174+
build wrote stays where it is.
175+
164176
## Relup Generation
165177

166178
Forecastle contains a mix task, `forecastle.relup`, that simplifies the generation of

RELEASE.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,22 @@
1515
will not boot. Configuration is expanded at boot by the `env.sh`
1616
integration, which has no `env.bat` counterpart. This has always been the
1717
case; it was previously silent.
18+
- **Breaking:** the `:appup` compiler now fails the build when the `:appup`
19+
project key names a file that does not exist, rather than warning and
20+
carrying on. The project asked for an appup and cannot have one, and the
21+
alternative is a release whose missing upgrade instructions only surface
22+
later — in `:systools.make_relup/4`, or during the upgrade itself. Its
23+
messages also reach the shell now: diagnostics returned by a compiler are
24+
for editors to display inline, and nothing prints them on the command line.
25+
A project that only has an appup in some environments should say so, rather
26+
than name a file that is not there:
27+
28+
```elixir
29+
appup: if(Mix.env() == :prod, do: "appup.exs")
30+
```
31+
32+
A `nil` key is the supported off switch: it removes any output an earlier
33+
build left and reports nothing further.
1834
- **Breaking:** the standard Mix launcher, `bin/<release>`, is no longer
1935
replaced. It keeps everything Mix gives it — cookie handling, distribution,
2036
`eval`/`rpc`/`remote`, daemon mode, version selection — and stays current with
@@ -57,6 +73,32 @@
5773
to manage its own releases.
5874
- The `GitHub` link in the Hex package metadata pointed at the Castle
5975
repository rather than Forecastle's.
76+
- The `:appup` compiler left `<app>.appup` behind in `ebin` once the project
77+
stopped asking for one, whether because the source file was deleted or
78+
because the `:appup` key was removed. It only worked out where the output
79+
went on its way to writing it, so neither of those cases could remove
80+
anything. An incremental build — which is what a CI cache produces —
81+
therefore went on packaging upgrade instructions from an earlier version of
82+
the application, and `release_handler` applied that obsolete plan during a
83+
hot upgrade. The stale output is now deleted instead. Leaving the `:appup`
84+
key unset is a supported way to turn an appup off for an environment: the
85+
earlier output is removed, and beyond saying so once, nothing is reported.
86+
Removal needs the compiler to stay in `:compilers` — dropping it from the
87+
list stops it running at all, as it would any Mix compiler.
88+
- The `:appup` project key is resolved relative to the project file, as the
89+
README has always said it is, rather than to whatever the working directory
90+
happens to be. That is what makes "the source is missing" a trustworthy
91+
verdict, now that it deletes the output and fails the build.
92+
- The `:appup` compiler returned a bare diagnostic where `Mix.Task.Compiler`
93+
expects a list of them, so Mix discarded it and reported that the compiler
94+
had misbehaved instead of saying what was wrong. It also ignored the result
95+
of writing the appup, and so reported success when the write had failed.
96+
- The appup was written as the formatter produced it, which is Unicode
97+
chardata rather than iodata. An appup containing a codepoint above 255 —
98+
a module or term with a non-ASCII name — failed to write at all, and one
99+
between 128 and 255 was written as a lone byte that `:file.consult/1`
100+
cannot read back, so the build reported success and left behind an appup
101+
that `systools` will not parse. It is encoded as UTF-8 now.
60102

61103
### Upgrading an existing deployment
62104

lib/mix/tasks/compile/appup.ex

Lines changed: 136 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,26 @@
11
defmodule Mix.Tasks.Compile.Appup do
22
@moduledoc """
33
Compiles appup files into the application's ebin folder.
4+
5+
The `:appup` project key names a file, relative to the project file, that is
6+
evaluated for its value. It must not introduce top-level bindings. Whatever it
7+
returns is written to `<app>.appup` alongside the application's beams.
8+
9+
The output is removed again whenever the project stops asking for it, either
10+
because the source is gone or because the `:appup` key was dropped. Leaving it
11+
in place would let an incremental build - which is what a CI cache produces -
12+
ship upgrade instructions from an earlier version of the application, and
13+
`release_handler` would then apply that obsolete plan during a hot upgrade.
14+
15+
A configured but missing source is a compilation error: the project asked for
16+
an appup and cannot have one, and the alternative is a release that only fails
17+
later, in `:systools.make_relup/4` or during the upgrade itself.
18+
19+
Removal only happens while the compiler is registered. Taking `:appup` out of
20+
`:compilers` stops it running at all, and whatever an earlier build wrote then
21+
stays where it is - as it would for any Mix compiler dropped from the list. To
22+
turn an appup off per environment, leave the compiler registered and let the
23+
`:appup` key be `nil`: that path removes the output and says nothing further.
424
"""
525
@shortdoc "Compiles appup files"
626
use Mix.Task.Compiler
@@ -9,23 +29,128 @@ defmodule Mix.Tasks.Compile.Appup do
929

1030
@impl true
1131
def run(_args) do
12-
if src = Mix.Project.config()[:appup] do
13-
if File.exists?(src) do
14-
{appup, []} = Code.eval_file(src)
15-
dst = Path.join(Mix.Project.compile_path(), "#{Mix.Project.config()[:app]}.appup")
16-
File.write(dst, :io_lib.format(~c"~tp.~n", [appup]))
17-
else
18-
{:ok, diagnostic(:warning, "Appup file not found: #{src}")}
19-
end
32+
dst = destination()
33+
34+
case source() do
35+
nil -> discard(dst)
36+
src -> build(src, dst)
37+
end
38+
end
39+
40+
# `mix clean` also removes the application's whole build directory, so the
41+
# appup goes with it either way. This is the callback the behaviour asks any
42+
# compiler that writes output to define, and it keeps removing the artefact
43+
# the responsibility of the task that created it.
44+
@impl true
45+
def clean do
46+
_ = File.rm(destination())
47+
:ok
48+
end
49+
50+
# No manifests/0: the appup is regenerated on every build, so its timestamp
51+
# always advances, and advertising it as a manifest would tell everything that
52+
# consults `Mix.Task.Compiler.manifests/1` that the build changed every time.
53+
# Regenerating unconditionally is deliberate - the source is arbitrary code
54+
# whose result need not be a function of the source's own mtime.
55+
56+
defp build(src, dst) do
57+
if File.exists?(src) do
58+
write(src, dst)
2059
else
21-
{:ok, diagnostic(:warning, "No appup specified in project")}
60+
discard(dst, report(:error, "Appup file not found: #{src}"))
61+
end
62+
end
63+
64+
defp write(src, dst) do
65+
{appup, []} = Code.eval_file(src)
66+
67+
case encode(appup) do
68+
bytes when is_binary(bytes) -> put(dst, bytes)
69+
_not_encodable -> {:error, report(:error, "Could not encode the appup in #{src} as UTF-8")}
2270
end
2371
end
2472

25-
defp diagnostic(severity, message, file \\ Mix.Project.project_file()) do
73+
# `:io_lib.format` with `~tp` returns Unicode chardata, not iodata. A codepoint
74+
# above 255 makes `File.write/2` fail outright; one between 128 and 255 is
75+
# written as a lone byte that `:file.consult/1` - which reads UTF-8 - cannot
76+
# read back, so the build reports success and ships an appup that `systools`
77+
# will not parse. An appup that is not what the build thinks it is happens to
78+
# be the whole failure this compiler exists to prevent.
79+
defp encode(appup), do: :unicode.characters_to_binary(:io_lib.format(~c"~tp.~n", [appup]))
80+
81+
defp put(dst, bytes) do
82+
case File.write(dst, bytes) do
83+
:ok ->
84+
:ok
85+
86+
{:error, reason} ->
87+
{:error, report(:error, "Could not write #{dst}: #{:file.format_error(reason)}")}
88+
end
89+
end
90+
91+
# The project has opted out: `:appup` is unset. That is a legitimate state and
92+
# not something to report on every compile, so say nothing unless there is an
93+
# earlier build's output to take away - which happens on exactly one build.
94+
defp discard(dst) do
95+
case remove(dst) do
96+
:removed -> {:ok, report(:information, "Removed #{dst}: no appup is specified")}
97+
:absent -> :noop
98+
{:error, diagnostics} -> {:error, diagnostics}
99+
end
100+
end
101+
102+
# The project asked for an appup and cannot have one. Take the previous one
103+
# away regardless - leaving it behind is the whole bug - and fail with the
104+
# diagnostic that explains why.
105+
defp discard(dst, diagnostics) do
106+
case remove(dst) do
107+
{:error, extra} -> {:error, diagnostics ++ extra}
108+
_removed_or_absent -> {:error, diagnostics}
109+
end
110+
end
111+
112+
# Failing to remove the output is a compilation error in its own right: a
113+
# stale appup that could not be deleted is the bug still present.
114+
defp remove(dst) do
115+
case File.rm(dst) do
116+
:ok ->
117+
:removed
118+
119+
{:error, :enoent} ->
120+
:absent
121+
122+
{:error, reason} ->
123+
{:error, report(:error, "Could not remove #{dst}: #{:file.format_error(reason)}")}
124+
end
125+
end
126+
127+
# Relative to the project file rather than the working directory. The compiler
128+
# is recursive, so Mix runs it from each umbrella child's own directory, but
129+
# nothing guarantees the working directory for a `mix compile` invoked from
130+
# elsewhere - and the answer now decides whether the output is deleted.
131+
defp source do
132+
if src = Mix.Project.config()[:appup] do
133+
Path.expand(src, Path.dirname(Mix.Project.project_file()))
134+
end
135+
end
136+
137+
defp destination do
138+
Path.join(Mix.Project.compile_path(), "#{Mix.Project.config()[:app]}.appup")
139+
end
140+
141+
# Diagnostics returned from a compiler are for editors to display inline;
142+
# nothing on the command line prints them, and a compiler that fails without
143+
# saying why is worse than one that does not fail at all. Say it on stderr as
144+
# well, the way the rest of Forecastle reports build problems.
145+
defp report(severity, message) do
146+
Mix.shell().error("#{severity}: #{message}")
147+
[diagnostic(severity, message)]
148+
end
149+
150+
defp diagnostic(severity, message) do
26151
%Mix.Task.Compiler.Diagnostic{
27152
compiler_name: "Appup",
28-
file: file,
153+
file: Mix.Project.project_file(),
29154
position: nil,
30155
severity: severity,
31156
message: message

test/fixtures/sample/mix.exs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ defmodule Sample.MixProject do
88
app: :sample,
99
version: System.get_env("SAMPLE_VSN", "0.1.0"),
1010
elixir: "~> 1.18",
11-
appup: "appup.exs",
11+
appup: appup(),
1212
compilers: Mix.compilers() ++ [:appup],
1313
deps: deps(),
1414
releases: releases()
@@ -40,6 +40,19 @@ defmodule Sample.MixProject do
4040
]
4141
end
4242

43+
# Switched by the test suite so that the fixture can be built as a project
44+
# that does not ask for an appup at all. "none" rather than an empty value,
45+
# which `System.cmd/3` cannot pass: it unsets the variable instead. `nil` is
46+
# as close as this can get to dropping the key, and it is close enough - the
47+
# compiler reads it through `Mix.Project.config()[:appup]`, which cannot tell
48+
# the two apart.
49+
defp appup do
50+
case System.get_env("SAMPLE_APPUP", "appup.exs") do
51+
"none" -> nil
52+
path -> path
53+
end
54+
end
55+
4356
# Switched by the test suite so that the warning about unsupported Windows
4457
# executables can be provoked.
4558
defp executables do

0 commit comments

Comments
 (0)