Unify mix recompile and external resource handling

Imagine the following scenario:

1. the user changes an external resource
2. the user calls mix compile and it fails
3. the user reverts the changes to the external resource

We had already fixed this bug for `__mix_recompile__?`.
Therefore, this pull request unifies how both are handled
by touching the source file after we detect it is stale
(which is also what we do on every subsequent compilation
cycle).

Note that before we stored the result of `__mix_recompile__?`
in the checkpoint file. However, that's not needed. The checkpoint
is useful to track changes to sources that may not define any module.
But since the compilation check and external resources always
require a module, touching the file is enough.

Closes https://github.com/phoenixframework/phoenix/issues/6476.
This commit is contained in:
José Valim
2025-09-13 21:31:33 +02:00
parent 2dc3d2713c
commit 7d521de63a
2 changed files with 85 additions and 73 deletions
+72 -73
View File
@@ -6,7 +6,7 @@ defmodule Mix.Compilers.Elixir do
@moduledoc false
@manifest_vsn 28
@checkpoint_vsn 2
@checkpoint_vsn 3
import Record
@@ -152,7 +152,8 @@ defmodule Mix.Compilers.Elixir do
removed,
Map.merge(stale_modules, removed_modules),
Map.merge(stale_exports, removed_modules),
dest
dest,
timestamp
)
end
@@ -320,8 +321,11 @@ defmodule Mix.Compilers.Elixir do
{%{}, %{}, all_paths, sources_stats}
end
# If any .beam file is missing, the first one will the first to miss,
# so we always check that. If there are no modules, then we can rely
# If the user does a change, compilation fails, and then they revert
# the change, the mtime will have changed but the .beam files will
# be missing and the digest is the same, so we need to check if .beam
# files are available. Checking the first .beam file is enough, as
# they would be all removed. If there are no modules, then we can rely
# purely on digests.
defp missing_beam_file?(dest, [mod | _]), do: not File.exists?(beam_path(dest, mod))
defp missing_beam_file?(_dest, []), do: false
@@ -334,56 +338,44 @@ defmodule Mix.Compilers.Elixir do
removed,
stale_modules,
stale_exports,
dest
dest,
timestamp
) do
{modules_to_recompile, modules_to_mix_check} =
for {module, module(recompile?: recompile?)} <- all_modules, reduce: {[], []} do
{modules_to_recompile, modules_to_mix_check} ->
cond do
Map.has_key?(stale_modules, module) ->
{[module | modules_to_recompile], modules_to_mix_check}
recompile? ->
{modules_to_recompile, [module | modules_to_mix_check]}
true ->
{modules_to_recompile, modules_to_mix_check}
end
end
_ = Code.ensure_all_loaded(modules_to_mix_check)
modules_to_recompile =
modules_to_recompile ++
for {:ok, {module, true}} <-
Task.async_stream(
modules_to_mix_check,
fn module ->
{module,
function_exported?(module, :__mix_recompile__?, 0) and
module.__mix_recompile__?()}
end,
ordered: false,
timeout: :infinity
) do
module
end
{checkpoint_stale_modules, checkpoint_stale_exports, checkpoint_modules} =
parse_checkpoint(manifest)
modules_to_recompile =
Map.merge(checkpoint_modules, Map.from_keys(modules_to_recompile, true))
{checkpoint_stale_modules, checkpoint_stale_exports} = parse_checkpoint(manifest)
stale_modules = Map.merge(checkpoint_stale_modules, stale_modules)
stale_exports = Map.merge(checkpoint_stale_exports, stale_exports)
if map_size(stale_modules) != map_size(checkpoint_stale_modules) or
map_size(stale_exports) != map_size(checkpoint_stale_exports) or
map_size(modules_to_recompile) != map_size(checkpoint_modules) do
write_checkpoint(manifest, stale_modules, stale_exports, modules_to_recompile)
map_size(stale_exports) != map_size(checkpoint_stale_exports) do
write_checkpoint(manifest, stale_modules, stale_exports)
end
# We don't need to store those in the checkpoint because
# these changes come from modules and, when they are stale,
# we remove the .beam files and touch sources.
modules_to_mix_check =
for {module, module(recompile?: true)} <- all_modules,
not Map.has_key?(stale_modules, module),
do: module
_ = Code.ensure_all_loaded(modules_to_mix_check)
modules_to_recompile =
for {:ok, {module, true}} <-
Task.async_stream(
modules_to_mix_check,
fn module ->
{module,
function_exported?(module, :__mix_recompile__?, 0) and
module.__mix_recompile__?()}
end,
ordered: false,
timeout: :infinity
),
into: %{} do
{module, true}
end
sources_stats =
for path <- new_paths,
into: mtimes_and_sizes(all_sources),
@@ -392,20 +384,30 @@ defmodule Mix.Compilers.Elixir do
# Sources that have changed on disk or
# any modules associated with them need to be recompiled
changed =
for {source,
source(external: external, size: size, mtime: mtime, digest: digest, modules: modules)} <-
all_sources,
{last_mtime, last_size} = Map.fetch!(sources_stats, source),
# If the user does a change, compilation fails, and then they revert
# the change, the mtime will have changed but the .beam files will
# be missing and the digest is the same, so we need to check if .beam
# files are available.
size != last_size or
Enum.any?(modules, &Map.has_key?(modules_to_recompile, &1)) or
Enum.flat_map(all_sources, fn
{source,
source(external: external, size: size, mtime: mtime, digest: digest, modules: modules)} ->
{last_mtime, last_size} = Map.fetch!(sources_stats, source)
cond do
Enum.any?(external, &stale_external?(&1, sources_stats)) or
(last_mtime > mtime and
(missing_beam_file?(dest, modules) or digest_changed?(source, digest))),
do: source
has_any_key?(modules_to_recompile, modules) ->
# Mark the source as changed so the combination of a timestamp
# plus removed beam files (which are removed by update_stale_entries)
# causes it to be recompiled
File.touch!(source, timestamp + 1)
[source]
size != last_size or
has_any_key?(stale_modules, modules) or
(last_mtime > mtime and
(missing_beam_file?(dest, modules) or digest_changed?(source, digest))) ->
[source]
true ->
[]
end
end)
changed = new_paths ++ changed
@@ -576,7 +578,7 @@ defmodule Mix.Compilers.Elixir do
end
defp has_any_key?(map, enumerable) do
Enum.any?(enumerable, &Map.has_key?(map, &1))
map != %{} and Enum.any?(enumerable, &Map.has_key?(map, &1))
end
defp stale_local_deps(local_deps, manifest, stale_modules, modified, deps_exports) do
@@ -940,25 +942,20 @@ defmodule Mix.Compilers.Elixir do
#
# Therefore, it is important for us to checkpoint any state that may
# have lead to a compilation and which can now be reverted.
defp parse_checkpoint(manifest) do
try do
(manifest <> ".checkpoint") |> File.read!() |> :erlang.binary_to_term()
rescue
_ ->
{%{}, %{}, %{}}
_ -> {%{}, %{}}
else
{@checkpoint_vsn, stale_modules, stale_exports, recompile_modules} ->
{stale_modules, stale_exports, recompile_modules}
_ ->
{%{}, %{}, %{}}
{@checkpoint_vsn, stale_modules, stale_exports} -> {stale_modules, stale_exports}
_ -> {%{}, %{}}
end
end
defp write_checkpoint(manifest, stale_modules, stale_exports, recompile_modules) do
defp write_checkpoint(manifest, stale_modules, stale_exports) do
File.mkdir_p!(Path.dirname(manifest))
term = {@checkpoint_vsn, stale_modules, stale_exports, recompile_modules}
term = {@checkpoint_vsn, stale_modules, stale_exports}
checkpoint_data = :erlang.term_to_binary(term, [:compressed])
File.write!(manifest <> ".checkpoint", checkpoint_data)
end
@@ -1117,8 +1114,10 @@ defmodule Mix.Compilers.Elixir do
{pending_modules, exports, changed} =
update_stale_entries(pending_modules, sources, changed, %{}, stale_exports, compile_path)
# For each changed file, mark it as changed.
# If compilation fails mid-cycle, they will be picked next time around.
# Those files have been changed transitively, so we mark them as changed
# in case compilation fails mid-cycle. The combination of the outdated
# timestamp plus the missing BEAM files (which were removed in
# update_stale_entries above) will cause them to be recompiled next time.
for file <- changed do
File.touch!(file, timestamp)
end
@@ -965,6 +965,10 @@ defmodule Mix.Tasks.Compile.ElixirTest do
@external_resource "lib/a.eex"
@external_resource #{inspect(tmp)}
def a, do: :ok
if System.get_env("RAISE_EXTERNAL_RESOURCE") do
raise "oops"
end
end
""")
@@ -978,7 +982,16 @@ defmodule Mix.Tasks.Compile.ElixirTest do
File.touch!("lib/a.eex", {{2038, 1, 1}, {0, 0, 0}})
assert Mix.Tasks.Compile.Elixir.run(["--verbose"]) == {:noop, []}
# Force recompilation but fail compilation
force_recompilation("lib/a.eex")
System.put_env("RAISE_EXTERNAL_RESOURCE", "1")
assert capture_io(:stderr, fn ->
assert {:error, [_]} = Mix.Tasks.Compile.Elixir.run(["--verbose"])
end) =~ "oops"
# Delete failure reason, it will now compile for good
System.delete_env("RAISE_EXTERNAL_RESOURCE")
assert Mix.Tasks.Compile.Elixir.run(["--verbose"]) == {:ok, []}
assert_received {:mix_shell, :info, ["Compiled lib/a.ex"]}
refute_received {:mix_shell, :info, ["Compiled lib/b.ex"]}