Skip to content

Commit 2e31949

Browse files
committed
Checkpoint mix compile state from semantic recompilations
This address the following case: 1. The user changes config/mix.exs/__mix_recompile__? 2. We detect the change, remove .beam files, and start recompilation 3. Recompilation fails for some reason 4. The user reverts the change 5. The compiler no longer recompiles (as it sees no change) but the .beam files are now missing To address this, we checkpoint all stale modules coming from config and mix.exs changes, as well as all modules where __mix_recompile__? returned true.
1 parent 588a99a commit 2e31949

File tree

2 files changed

+184
-37
lines changed

2 files changed

+184
-37
lines changed

lib/mix/lib/mix/compilers/elixir.ex

Lines changed: 87 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,15 @@ defmodule Mix.Compilers.Elixir do
2323
Compiles stale Elixir files.
2424
2525
It expects a `manifest` file, the source directories, the destination
26-
directory, an option to know if compilation is being forced or not, and a
27-
list of any additional compiler options.
26+
directory, the cache key based on compiler configuration, external
27+
manifests, and external modules, followed by opts.
2828
2929
The `manifest` is written down with information including dependencies
3030
between modules, which helps it recompile only the modules that
3131
have changed at runtime.
3232
"""
3333
def compile(manifest, srcs, dest, new_cache_key, new_parent_manifests, new_parents, opts) do
34-
manifest_last_modified = Mix.Utils.last_modified(manifest)
34+
modified = Mix.Utils.last_modified(manifest)
3535
new_parents = :ordsets.from_list(new_parents)
3636

3737
# We fetch the time from before we read files so any future
@@ -47,22 +47,22 @@ defmodule Mix.Compilers.Elixir do
4747
# we need to recompile all references to old and new modules.
4848
stale =
4949
if old_parents != new_parents or
50-
Mix.Utils.stale?(new_parent_manifests, [manifest_last_modified]) do
50+
Mix.Utils.stale?(new_parent_manifests, [modified]) do
5151
:ordsets.union(old_parents, new_parents)
5252
else
5353
[]
5454
end
5555

5656
# If mix.exs has changed, recompile anything that calls Mix.Project.
5757
stale =
58-
if Mix.Utils.stale?([Mix.Project.project_file()], [manifest_last_modified]),
58+
if Mix.Utils.stale?([Mix.Project.project_file()], [modified]),
5959
do: [Mix.Project | stale],
6060
else: stale
6161

6262
# If the dependencies have changed, we need to traverse lock/config files.
63-
deps_changed? = Mix.Utils.stale?([Mix.Project.config_mtime()], [manifest_last_modified])
63+
deps_changed? = Mix.Utils.stale?([Mix.Project.config_mtime()], [modified])
6464

65-
# The app tracker will return information about apps before this compilation.
65+
# The app tracer will return information about apps before this compilation.
6666
app_tracer = Mix.Compilers.ApplicationTracer.init()
6767

6868
{force?, stale, new_lock, new_config} =
@@ -102,8 +102,6 @@ defmodule Mix.Compilers.Elixir do
102102
{false, stale, old_lock, old_config}
103103
end
104104

105-
modified = Mix.Utils.last_modified(manifest)
106-
107105
{stale_local_mods, stale_local_exports, all_local_exports} =
108106
stale_local_deps(manifest, stale, modified, all_local_exports)
109107

@@ -116,11 +114,11 @@ defmodule Mix.Compilers.Elixir do
116114
compiler_info_from_force(manifest, all_paths, all_modules, dest)
117115
else
118116
compiler_info_from_updated(
117+
manifest,
119118
modified,
120-
all_paths,
119+
all_paths -- prev_paths,
121120
all_modules,
122121
all_sources,
123-
prev_paths,
124122
removed,
125123
stale_local_mods,
126124
Map.merge(stale_local_exports, removed_modules),
@@ -182,9 +180,9 @@ defmodule Mix.Compilers.Elixir do
182180
delete_compiler_info()
183181
end
184182
else
185-
# We need to return ok if deps_changed? or stale_local_mods changed
186-
# because we want to propagate the changed status to compile.protocols.
187-
# This will be the case whenever:
183+
# We need to return ok if deps_changed? or stale_local_mods changed,
184+
# even if no code was compiled, because we need to propagate the changed
185+
# status to compile.protocols. This will be the case whenever:
188186
#
189187
# * the lock file or a config changes
190188
# * any module in a path dependency changes
@@ -272,47 +270,59 @@ defmodule Mix.Compilers.Elixir do
272270
{[], %{}, all_paths, sources_stats}
273271
end
274272

275-
# Assume that either all .beam files are missing, or none of them are
273+
# If any .beam file is missing, the first one will the first to miss,
274+
# so we always check that. If there are no modules, then we can rely
275+
# purely on digests.
276276
defp missing_beam_file?(dest, [mod | _]), do: not File.exists?(beam_path(dest, mod))
277277
defp missing_beam_file?(_dest, []), do: false
278278

279279
defp compiler_info_from_updated(
280+
manifest,
280281
modified,
281-
all_paths,
282+
new_paths,
282283
all_modules,
283284
all_sources,
284-
prev_paths,
285285
removed,
286286
stale_local_mods,
287287
stale_local_exports,
288288
dest
289289
) do
290-
# Otherwise let's start with the new sources
291-
new_paths = all_paths -- prev_paths
290+
# TODO: Use :maps.from_keys/2 on Erlang/OTP 24+
291+
modules_to_recompile =
292+
for module(module: module, recompile?: true) <- all_modules,
293+
recompile_module?(module),
294+
into: %{},
295+
do: {module, []}
296+
297+
{checkpoint_stale, checkpoint_modules} = parse_checkpoint(manifest)
298+
modules_to_recompile = Map.merge(checkpoint_modules, modules_to_recompile)
299+
stale_local_mods = Map.merge(checkpoint_stale, stale_local_mods)
300+
301+
if map_size(stale_local_mods) != map_size(checkpoint_stale) or
302+
map_size(modules_to_recompile) != map_size(checkpoint_modules) do
303+
write_checkpoint(manifest, stale_local_mods, modules_to_recompile)
304+
end
292305

293306
sources_stats =
294307
for path <- new_paths,
295308
into: mtimes_and_sizes(all_sources),
296309
do: {path, Mix.Utils.last_modified_and_size(path)}
297310

298-
modules_to_recompile =
299-
for module(module: module, recompile?: true) <- all_modules,
300-
recompile_module?(module),
301-
into: %{},
302-
do: {module, true}
303-
304311
# Sources that have changed on disk or
305312
# any modules associated with them need to be recompiled
306313
changed =
307314
for source(source: source, external: external, size: size, digest: digest, modules: modules) <-
308315
all_sources,
309316
{last_mtime, last_size} = Map.fetch!(sources_stats, source),
310-
Enum.any?(modules, &Map.has_key?(modules_to_recompile, &1)) or
317+
# If the user does a change, compilation fails, and then they revert
318+
# the change, the mtime will have changed but the .beam files will
319+
# be missing and the digest is the same, so we need to check if .beam
320+
# files are available.
321+
size != last_size or
322+
Enum.any?(modules, &Map.has_key?(modules_to_recompile, &1)) or
311323
Enum.any?(external, &stale_external?(&1, modified, sources_stats)) or
312-
(size != last_size or
313-
(last_mtime > modified and
314-
(missing_beam_file?(dest, modules) or
315-
digest != digest(source)))),
324+
(last_mtime > modified and
325+
(missing_beam_file?(dest, modules) or digest != digest(source))),
316326
do: source
317327

318328
changed = new_paths ++ changed
@@ -436,11 +446,12 @@ defmodule Mix.Compilers.Elixir do
436446
end
437447

438448
defp dependent_runtime_modules(sources, all_modules, pending_modules) do
449+
# TODO: Use :maps.from_keys/2 on Erlang/OTP 24+
439450
changed_modules =
440451
for module(module: module) = entry <- all_modules,
441452
entry not in pending_modules,
442453
into: %{},
443-
do: {module, true}
454+
do: {module, []}
444455

445456
fixpoint_runtime_modules(sources, changed_modules, %{}, pending_modules)
446457
end
@@ -649,7 +660,8 @@ defmodule Mix.Compilers.Elixir do
649660
end
650661

651662
defp update_stale_entries(modules, sources, changed, stale_mods, stale_exports, compile_path) do
652-
changed = Enum.into(changed, %{}, &{&1, true})
663+
# TODO: Use :maps.from_keys/2 on Erlang/OTP 24+
664+
changed = Enum.into(changed, %{}, &{&1, []})
653665
reducer = &remove_stale_entry(&1, &2, sources, stale_exports, compile_path)
654666
remove_stale_entries(modules, %{}, changed, stale_mods, reducer)
655667
end
@@ -709,7 +721,7 @@ defmodule Mix.Compilers.Elixir do
709721
base = Path.basename(manifest)
710722

711723
# TODO: Use :maps.from_keys/2 on Erlang/OTP 24+
712-
stale_modules = for module <- stale_modules, do: {module, true}, into: %{}
724+
stale_modules = for module <- stale_modules, do: {module, []}, into: %{}
713725

714726
for %{scm: scm, opts: opts} = dep <- Mix.Dep.cached(),
715727
not scm.fetchable?,
@@ -721,7 +733,7 @@ defmodule Mix.Compilers.Elixir do
721733
{modules, exports, new_exports} ->
722734
module = beam |> Path.basename() |> Path.rootname() |> String.to_atom()
723735
export = exports_md5(module, false)
724-
modules = Map.put(modules, module, true)
736+
modules = Map.put(modules, module, [])
725737

726738
# If the exports are the same, then the API did not change,
727739
# so we do not mark the export as stale. Note this has to
@@ -731,7 +743,7 @@ defmodule Mix.Compilers.Elixir do
731743
exports =
732744
if export && old_exports[module] == export,
733745
do: exports,
734-
else: Map.put(exports, module, true)
746+
else: Map.put(exports, module, [])
735747

736748
# In any case, we always store it as the most update export
737749
# that we have, otherwise we delete it.
@@ -834,7 +846,7 @@ defmodule Mix.Compilers.Elixir do
834846

835847
defp deps_on(apps) do
836848
# TODO: Use :maps.from_keys/2 on Erlang/OTP 24+
837-
apps = for app <- apps, do: {app, true}, into: %{}
849+
apps = for app <- apps, do: {app, []}, into: %{}
838850
deps_on(Mix.Dep.cached(), apps, [], false)
839851
end
840852

@@ -846,7 +858,7 @@ defmodule Mix.Compilers.Elixir do
846858

847859
# It depends on one of the apps, store it
848860
Enum.any?(deps, &Map.has_key?(apps, &1.app)) ->
849-
deps_on(cached_deps, Map.put(apps, app, true), acc, true)
861+
deps_on(cached_deps, Map.put(apps, app, []), acc, true)
850862

851863
# Otherwise we will check it later
852864
true ->
@@ -936,6 +948,7 @@ defmodule Mix.Compilers.Elixir do
936948
manifest_data = :erlang.term_to_binary(term, [:compressed])
937949
File.write!(manifest, manifest_data)
938950
File.touch!(manifest, timestamp)
951+
delete_checkpoint(manifest)
939952

940953
# Since Elixir is a dependency itself, we need to touch the lock
941954
# so the current Elixir version, used to compile the files above,
@@ -946,4 +959,41 @@ defmodule Mix.Compilers.Elixir do
946959
defp beam_path(compile_path, module) do
947960
Path.join(compile_path, Atom.to_string(module) <> ".beam")
948961
end
962+
963+
# Once we added semantic recompilation, the following can happen:
964+
#
965+
# 1. The user changes config/mix.exs/__mix_recompile__?
966+
# 2. We detect the change, remove .beam files and start recompilation
967+
# 3. Recompilation fails
968+
# 4. The user reverts the change
969+
# 5. The compiler no longer recompiles and the .beam files are missing
970+
#
971+
# Therefore, it is important for us to checkpoint any state that may
972+
# have lead to a compilation and which can now be reverted.
973+
974+
defp parse_checkpoint(manifest) do
975+
try do
976+
(manifest <> ".checkpoint") |> File.read!() |> :erlang.binary_to_term()
977+
rescue
978+
_ ->
979+
{%{}, %{}}
980+
else
981+
{@manifest_vsn, stale, recompile_modules} ->
982+
{stale, recompile_modules}
983+
984+
_ ->
985+
{%{}, %{}}
986+
end
987+
end
988+
989+
defp write_checkpoint(manifest, stale, recompile_modules) do
990+
File.mkdir_p!(Path.dirname(manifest))
991+
term = {@manifest_vsn, stale, recompile_modules}
992+
checkpoint_data = :erlang.term_to_binary(term, [:compressed])
993+
File.write!(manifest <> ".checkpoint", checkpoint_data)
994+
end
995+
996+
defp delete_checkpoint(manifest) do
997+
File.rm(manifest <> ".checkpoint")
998+
end
949999
end

lib/mix/test/mix/tasks/compile.elixir_test.exs

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,67 @@ defmodule Mix.Tasks.Compile.ElixirTest do
263263
Application.delete_env(:sample, :foo, persistent: true)
264264
end
265265

266+
test "recompiles files when config changes with crashes" do
267+
in_fixture("no_mixfile", fn ->
268+
Mix.Project.push(MixTest.Case.Sample)
269+
Process.put({MixTest.Case.Sample, :application}, extra_applications: [:logger])
270+
File.mkdir_p!("config")
271+
272+
File.write!("lib/a.ex", """
273+
defmodule A do
274+
require Logger
275+
def fun, do: Logger.debug("hello")
276+
end
277+
""")
278+
279+
assert Mix.Tasks.Compile.Elixir.run(["--verbose"]) == {:ok, []}
280+
assert_received {:mix_shell, :info, ["Compiled lib/a.ex"]}
281+
assert_received {:mix_shell, :info, ["Compiled lib/b.ex"]}
282+
283+
recompile = fn ->
284+
Mix.ProjectStack.pop()
285+
Mix.Project.push(MixTest.Case.Sample)
286+
Mix.Tasks.Loadconfig.load_compile("config/config.exs")
287+
Mix.Tasks.Compile.Elixir.run(["--verbose"])
288+
end
289+
290+
# Adding config recompiles due to macros
291+
File.write!("config/config.exs", """
292+
import Config
293+
config :logger, :compile_time_purge_matching, []
294+
""")
295+
296+
File.touch!("_build/dev/lib/sample/.mix/compile.elixir", @old_time)
297+
assert recompile.() == {:ok, []}
298+
assert_received {:mix_shell, :info, ["Compiled lib/a.ex"]}
299+
refute_received {:mix_shell, :info, ["Compiled lib/b.ex"]}
300+
assert File.stat!("_build/dev/lib/sample/.mix/compile.elixir").mtime > @old_time
301+
302+
# Inserting a bogus config should crash
303+
File.write!("config/config.exs", """
304+
import Config
305+
config :logger, :compile_time_purge_matching, [level_lower_than: :debug]
306+
""")
307+
308+
File.touch!("_build/dev/lib/sample/.mix/compile.elixir", @old_time)
309+
ExUnit.CaptureIO.capture_io(fn -> assert {:error, _} = recompile.() end)
310+
311+
# Revering the original config should recompile
312+
File.write!("config/config.exs", """
313+
import Config
314+
config :logger, :compile_time_purge_matching, []
315+
""")
316+
317+
File.touch!("_build/dev/lib/sample/.mix/compile.elixir", @old_time)
318+
assert recompile.() == {:ok, []}
319+
assert_received {:mix_shell, :info, ["Compiled lib/a.ex"]}
320+
refute_received {:mix_shell, :info, ["Compiled lib/b.ex"]}
321+
assert File.stat!("_build/dev/lib/sample/.mix/compile.elixir").mtime > @old_time
322+
end)
323+
after
324+
Application.put_env(:logger, :compile_time_purge_matching, [])
325+
end
326+
266327
test "recompiles files when lock changes" do
267328
in_fixture("no_mixfile", fn ->
268329
Mix.Project.push(MixTest.Case.Sample)
@@ -971,6 +1032,42 @@ defmodule Mix.Tasks.Compile.ElixirTest do
9711032
end)
9721033
end
9731034

1035+
test "recompiles modules with __mix_recompile__ check with crashes" do
1036+
Agent.start_link(fn -> false end, name: :mix_recompile_raise)
1037+
1038+
in_fixture("no_mixfile", fn ->
1039+
Mix.Project.push(MixTest.Case.Sample)
1040+
1041+
File.write!("lib/a.ex", """
1042+
defmodule A do
1043+
def __mix_recompile__?(), do: Agent.get(:mix_recompile_raise, & &1)
1044+
1045+
if Agent.get(:mix_recompile_raise, & &1) do
1046+
raise "oops"
1047+
end
1048+
end
1049+
""")
1050+
1051+
File.touch!("lib/a.ex", @old_time)
1052+
assert Mix.Tasks.Compile.Elixir.run(["--verbose"]) == {:ok, []}
1053+
assert_received {:mix_shell, :info, ["Compiled lib/a.ex"]}
1054+
1055+
assert Mix.Tasks.Compile.Elixir.run(["--verbose"]) == {:noop, []}
1056+
assert File.stat!("lib/a.ex").mtime == @old_time
1057+
1058+
Agent.update(:mix_recompile_raise, fn _ -> true end)
1059+
1060+
ExUnit.CaptureIO.capture_io(fn ->
1061+
assert {:error, _} = Mix.Tasks.Compile.Elixir.run(["--verbose"])
1062+
end)
1063+
1064+
# After failing to compile and reverting the status, it should still recompile
1065+
Agent.update(:mix_recompile_raise, fn _ -> false end)
1066+
assert Mix.Tasks.Compile.Elixir.run(["--verbose"]) == {:ok, []}
1067+
assert_received {:mix_shell, :info, ["Compiled lib/a.ex"]}
1068+
end)
1069+
end
1070+
9741071
test "prints warnings from non-stale files with --all-warnings" do
9751072
in_fixture("no_mixfile", fn ->
9761073
Mix.Project.push(MixTest.Case.Sample)

0 commit comments

Comments
 (0)