Skip to content

Commit 7e90906

Browse files
Add API for listening to concurrent mix compilations (#13896)
1 parent 251badd commit 7e90906

File tree

19 files changed

+965
-76
lines changed

19 files changed

+965
-76
lines changed

lib/iex/lib/iex/helpers.ex

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,14 @@ defmodule IEx.Helpers do
136136

137137
if is_nil(project) or
138138
project.__info__(:compile)[:source] == String.to_charlist(Path.absname("mix.exs")) do
139-
do_recompile(options)
139+
Mix.Project.with_build_lock(fn ->
140+
purge_result = IEx.MixListener.purge()
141+
142+
case do_recompile(options) do
143+
:noop -> purge_result
144+
compile_result -> compile_result
145+
end
146+
end)
140147
else
141148
message = "Cannot recompile because the current working directory changed"
142149
IO.puts(IEx.color(:eval_error, message))

lib/iex/lib/iex/mix_listener.ex

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
defmodule IEx.MixListener do
2+
@moduledoc false
3+
4+
use GenServer
5+
6+
@name __MODULE__
7+
8+
@spec start_link(keyword) :: GenServer.on_start()
9+
def start_link(_opts) do
10+
GenServer.start_link(__MODULE__, {}, name: @name)
11+
end
12+
13+
@doc """
14+
Unloads all modules invalidated by external compilations.
15+
"""
16+
@spec purge :: :ok | :noop
17+
def purge do
18+
GenServer.call(@name, :purge, :infinity)
19+
end
20+
21+
@impl true
22+
def init({}) do
23+
{:ok, %{to_purge: MapSet.new()}}
24+
end
25+
26+
@impl true
27+
def handle_call(:purge, _from, state) do
28+
for module <- state.to_purge do
29+
:code.purge(module)
30+
:code.delete(module)
31+
end
32+
33+
status = if Enum.empty?(state.to_purge), do: :noop, else: :ok
34+
35+
{:reply, status, %{state | to_purge: MapSet.new()}}
36+
end
37+
38+
@impl true
39+
def handle_info({:modules_compiled, info}, state) do
40+
if info.os_pid == System.pid() do
41+
# Ignore compilations from ourselves, because the modules are
42+
# already updated in memory
43+
{:noreply, state}
44+
else
45+
%{changed: changed, removed: removed} = info.modules_diff
46+
state = update_in(state.to_purge, &Enum.into(changed, &1))
47+
state = update_in(state.to_purge, &Enum.into(removed, &1))
48+
{:noreply, state}
49+
end
50+
end
51+
52+
def handle_info(_message, state) do
53+
{:noreply, state}
54+
end
55+
end

lib/mix/lib/mix.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -402,7 +402,7 @@ defmodule Mix do
402402
def start(_type, []) do
403403
Mix.Local.append_archives()
404404
Mix.Local.append_paths()
405-
children = [Mix.State, Mix.TasksServer, Mix.ProjectStack]
405+
children = [Mix.Sync.PubSub, Mix.State, Mix.TasksServer, Mix.ProjectStack]
406406
opts = [strategy: :one_for_one, name: Mix.Supervisor, max_restarts: 0]
407407
Supervisor.start_link(children, opts)
408408
end

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

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -202,15 +202,24 @@ defmodule Mix.Compilers.Elixir do
202202

203203
put_compile_env(sources)
204204
all_warnings = previous_warnings ++ runtime_warnings ++ compile_warnings
205-
unless_previous_warnings_as_errors(previous_warnings, opts, {:ok, all_warnings})
205+
206+
lazy_modules_diff = fn ->
207+
modules_diff(modules, removed_modules, all_modules, timestamp)
208+
end
209+
210+
unless_previous_warnings_as_errors(
211+
previous_warnings,
212+
opts,
213+
{:ok, all_warnings, lazy_modules_diff}
214+
)
206215

207216
{:error, errors, %{runtime_warnings: r_warnings, compile_warnings: c_warnings}, state} ->
208217
# In case of errors, we show all previous warnings and all new ones.
209218
{_, _, sources, _, _, _} = state
210219
errors = Enum.map(errors, &diagnostic/1)
211220
warnings = Enum.map(r_warnings ++ c_warnings, &diagnostic/1)
212221
all_warnings = Keyword.get(opts, :all_warnings, errors == [])
213-
{:error, previous_warnings(sources, all_warnings) ++ warnings ++ errors}
222+
{:error, previous_warnings(sources, all_warnings) ++ warnings ++ errors, nil}
214223
after
215224
Code.compiler_options(previous_opts)
216225
end
@@ -247,7 +256,12 @@ defmodule Mix.Compilers.Elixir do
247256

248257
all_warnings = Keyword.get(opts, :all_warnings, true)
249258
previous_warnings = previous_warnings(sources, all_warnings)
250-
unless_previous_warnings_as_errors(previous_warnings, opts, {status, previous_warnings})
259+
260+
unless_previous_warnings_as_errors(
261+
previous_warnings,
262+
opts,
263+
{status, previous_warnings, nil}
264+
)
251265
end
252266
end
253267

@@ -989,16 +1003,38 @@ defmodule Mix.Compilers.Elixir do
9891003
File.rm(manifest <> ".checkpoint")
9901004
end
9911005

992-
defp unless_previous_warnings_as_errors(previous_warnings, opts, {status, all_warnings}) do
1006+
defp unless_previous_warnings_as_errors(
1007+
previous_warnings,
1008+
opts,
1009+
{status, all_warnings, modules_diff}
1010+
) do
9931011
if previous_warnings != [] and opts[:warnings_as_errors] do
9941012
message = "Compilation failed due to warnings while using the --warnings-as-errors option"
9951013
IO.puts(:stderr, message)
996-
{:error, all_warnings}
1014+
{:error, all_warnings, modules_diff}
9971015
else
998-
{status, all_warnings}
1016+
{status, all_warnings, modules_diff}
9991017
end
10001018
end
10011019

1020+
defp modules_diff(compiled_modules, removed_modules, all_modules, timestamp) do
1021+
{changed, added} =
1022+
compiled_modules
1023+
|> Map.keys()
1024+
|> Enum.split_with(&Map.has_key?(all_modules, &1))
1025+
1026+
# Note that removed_modules may also include changed modules
1027+
removed =
1028+
for {module, _} <- removed_modules, not Map.has_key?(compiled_modules, module), do: module
1029+
1030+
%{
1031+
added: added,
1032+
changed: changed,
1033+
removed: removed,
1034+
timestamp: timestamp
1035+
}
1036+
end
1037+
10021038
## Compiler loop
10031039
# The compiler is invoked in a separate process so we avoid blocking its main loop.
10041040

lib/mix/lib/mix/project.ex

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -896,7 +896,7 @@ defmodule Mix.Project do
896896
Mix.shell().info("Waiting for lock on the build directory (held by process #{os_pid})")
897897
end
898898

899-
Mix.Lock.with_lock(build_path, fun, on_taken: on_taken)
899+
Mix.Sync.Lock.with_lock(build_path, fun, on_taken: on_taken)
900900
end
901901

902902
@doc false
@@ -910,7 +910,7 @@ defmodule Mix.Project do
910910
Mix.shell().info("Waiting for lock on the deps directory (held by process #{os_pid})")
911911
end
912912

913-
Mix.Lock.with_lock(deps_path, fun, on_taken: on_taken)
913+
Mix.Sync.Lock.with_lock(deps_path, fun, on_taken: on_taken)
914914
end
915915

916916
# Loads mix.exs in the current directory or loads the project from the

lib/mix/lib/mix/pubsub.ex

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
defmodule Mix.PubSub do
2+
@moduledoc false
3+
4+
# The Mix pub/sub is responsible for notifying other OS processes
5+
# about relevant concurrent events.
6+
#
7+
# The pub/sub consists of a local subscriber process that receives
8+
# events from other OS processes, and a listener supervisor, which
9+
# holds all listener processes that have been configured. Whenever
10+
# the subscriber receives an event, it sends a message to each of
11+
# the listeners.
12+
#
13+
# Inherently, a compilation may be required before the listener
14+
# modules are available, so we start the local subscriber process
15+
# separately with `start/0`, and then start the listeners later
16+
# with `start_listeners/0`. The subscriber is going to accumulate
17+
# events and reply them once the listenres are started.
18+
19+
@spec start :: :ok
20+
def start do
21+
# Avoid calling the supervisor, if already started
22+
if Process.whereis(Mix.PubSub) do
23+
:ok
24+
else
25+
case Supervisor.start_child(Mix.Supervisor, Mix.PubSub) do
26+
{:ok, _pid} ->
27+
:ok
28+
29+
{:error, {:already_started, _pid}} ->
30+
:ok
31+
32+
{:error, reason} ->
33+
raise RuntimeError, "failed to start Mix.PubSub, reason: #{inspect(reason)}"
34+
end
35+
end
36+
end
37+
38+
@spec child_spec(term) :: Supervisor.child_spec()
39+
def child_spec(_opts) do
40+
children = [
41+
Mix.PubSub.Subscriber
42+
]
43+
44+
opts = [strategy: :one_for_one, name: Mix.PubSub]
45+
46+
%{
47+
id: Mix.PubSub,
48+
start: {Supervisor, :start_link, [children, opts]},
49+
type: :supervisor
50+
}
51+
end
52+
53+
@spec start_listeners :: :ok
54+
def start_listeners do
55+
# Avoid calling the supervisor, if already started
56+
if Process.whereis(Mix.PubSub.ListenerSupervisor) do
57+
:ok
58+
else
59+
case Supervisor.start_child(Mix.PubSub, listener_supervisor()) do
60+
{:ok, _pid} ->
61+
Mix.PubSub.Subscriber.flush()
62+
:ok
63+
64+
{:error, {:already_started, _pid}} ->
65+
:ok
66+
67+
{:error, reason} ->
68+
raise RuntimeError,
69+
"failed to start Mix.PubSub.ListenerSupervisor, reason: #{inspect(reason)}"
70+
end
71+
end
72+
end
73+
74+
defp listener_supervisor do
75+
children = configured_listeners() ++ built_in_listeners()
76+
77+
children = Enum.map(children, &Supervisor.child_spec(&1, []))
78+
79+
opts = [strategy: :one_for_one, name: Mix.PubSub.ListenerSupervisor]
80+
81+
%{
82+
id: Mix.PubSub.ListenerSupervisor,
83+
start: {Supervisor, :start_link, [children, opts]},
84+
type: :supervisor
85+
}
86+
end
87+
88+
defp configured_listeners do
89+
config = Mix.Project.config()
90+
91+
listeners =
92+
Application.get_env(:mix, :listeners, []) ++
93+
Keyword.get(config, :listeners, [])
94+
95+
Enum.uniq(listeners)
96+
end
97+
98+
defp built_in_listeners do
99+
iex_started? = Process.whereis(IEx.Supervisor) != nil
100+
101+
if iex_started? do
102+
[IEx.MixListener]
103+
else
104+
[]
105+
end
106+
end
107+
end

lib/mix/lib/mix/pubsub/subscriber.ex

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
defmodule Mix.PubSub.Subscriber do
2+
@moduledoc false
3+
4+
use GenServer
5+
6+
@name __MODULE__
7+
8+
@spec start_link(keyword) :: GenServer.on_start()
9+
def start_link(_opts) do
10+
GenServer.start_link(__MODULE__, {}, name: @name)
11+
end
12+
13+
@spec flush :: :ok
14+
def flush do
15+
GenServer.cast(@name, :flush)
16+
end
17+
18+
@impl true
19+
def init({}) do
20+
build_path = Mix.Project.build_path()
21+
Mix.Sync.PubSub.subscribe(build_path)
22+
{:ok, %{acc: []}}
23+
end
24+
25+
@impl true
26+
def handle_info(message, %{acc: nil} = state) do
27+
notify_listeners([message])
28+
{:noreply, state}
29+
end
30+
31+
def handle_info(message, state) do
32+
# Accumulate messages until the flush
33+
{:noreply, update_in(state.acc, &[message | &1])}
34+
end
35+
36+
@impl true
37+
def handle_cast(:flush, state) do
38+
notify_listeners(Enum.reverse(state.acc))
39+
{:noreply, %{state | acc: nil}}
40+
end
41+
42+
defp notify_listeners(messages) do
43+
children = Supervisor.which_children(Mix.PubSub.ListenerSupervisor)
44+
45+
for message <- messages do
46+
for {_, pid, _, _} <- children, is_pid(pid) do
47+
send(pid, message)
48+
end
49+
end
50+
end
51+
end

0 commit comments

Comments
 (0)