Current section
Files
Jump to
Current section
Files
lib/mix/tasks/compile/hologram.ex
defmodule Mix.Tasks.Compile.Hologram do
@moduledoc """
Builds Hologram project JavaScript bundles, the call graph of the code,
PLTs needed by the runtime and PLTs needed to speed up future compilation.
## Telemetry
Emits the following events around each compilation run, i.e. the critical
section guarded by the compiler lock:
* `[:hologram, :compiler, :start]` - dispatched when a compilation begins,
with measurement `%{system_time: System.system_time()}`.
* `[:hologram, :compiler, :stop]` - dispatched when a compilation ends,
whether it succeeds or raises, with measurement `%{duration: native_time}`
in `System.monotonic_time/0` native units.
"""
use Mix.Task.Compiler
require Logger
alias Hologram.Commons.PLT
alias Hologram.Commons.SystemUtils
alias Hologram.Compiler
alias Hologram.Compiler.CallGraph
alias Hologram.Reflection
@ls_build_dirs [".elixir_ls", ".elixir-tools", ".expert", ".lexical"]
@impl Mix.Task.Compiler
# If the options are strings, it means that the task was executed directly by the Elixir compiler.
def run([hd | _tail]) when is_binary(hd) do
run(build_default_opts())
end
@doc """
Benchmarks: https://github.com/bartblast/hologram/blob/master/benchmarks/mix/tasks/compile.hologram/README.md
"""
@impl Mix.Task.Compiler
def run(opts) do
opts = Keyword.merge(build_default_opts(), opts)
cond do
opts[:force?] -> compile_with_lock(opts)
language_server_build?(opts) -> :noop
!compiler_enabled?() -> :noop
true -> compile_with_lock(opts)
end
end
defp build_default_opts do
root_dir = Reflection.root_dir()
assets_dir = Path.join([root_dir, "deps", "hologram", "assets"])
build_dir = Reflection.build_dir()
node_modules_path = Path.join(assets_dir, "node_modules")
[
assets_dir: assets_dir,
build_dir: build_dir,
esbuild_bin_path: Path.join([node_modules_path, ".bin", "esbuild"]),
js_dir: Path.join(assets_dir, "js"),
node_modules_path: node_modules_path,
static_dir: Path.join([root_dir, "priv", "static", "hologram"]),
tmp_dir: Path.join(build_dir, "tmp")
]
end
defp compile(opts) do
{:ok, sup} = DynamicSupervisor.start_link(strategy: :one_for_one)
start_time = System.monotonic_time()
:telemetry.execute([:hologram, :compiler, :start], %{system_time: System.system_time()}, %{})
try do
Logger.info("Hologram: compiler started")
assets_dir = opts[:assets_dir]
build_dir = opts[:build_dir]
File.mkdir_p!(build_dir)
File.mkdir_p!(opts[:static_dir])
File.mkdir_p!(opts[:tmp_dir])
Compiler.maybe_install_js_deps(assets_dir, build_dir)
{old_module_digest_plt, module_digest_plt_dump_path} =
Compiler.maybe_load_module_digest_plt(build_dir, supervisor: sup)
new_module_digest_plt = Compiler.build_module_digest_plt!(supervisor: sup)
module_digests_diff =
Compiler.diff_module_digest_plts(old_module_digest_plt, new_module_digest_plt)
# Building IR PLT from scratch is faster that dumping it to a file,
# and then loading and patching it (benchmarked on an app with 1628 modules):
# build: ~310 ms
# dump: ~350 ms
# load: ~465 ms
# patch: not benchmarked
ir_plt = Compiler.build_ir_plt(supervisor: sup)
{call_graph, call_graph_dump_path} =
Compiler.maybe_load_call_graph(build_dir, supervisor: sup)
call_graph
|> CallGraph.patch(ir_plt, module_digests_diff)
|> CallGraph.add_non_discoverable_edges()
# Must be computed before remove_manually_ported_mfas/1 strips the Task.await/1 vertex.
async_mfas = CallGraph.list_async_mfas(call_graph)
call_graph_for_runtime =
call_graph
|> CallGraph.clone(supervisor: sup)
# DEFER: In case the list of manually ported MFAs grows to ~32 vertices,
# consider using similar strategy to CallGraph.remove_runtime_mfas!/2
# or implement opts param for Digraph.remove_vertices/2 to allow rebuilding the graph.
|> CallGraph.remove_manually_ported_mfas()
page_modules = Reflection.list_pages()
Compiler.validate_page_modules(page_modules)
runtime_mfas = CallGraph.list_runtime_mfas(call_graph_for_runtime, page_modules)
runtime_entry_file_path =
Compiler.create_runtime_entry_file(runtime_mfas, ir_plt, async_mfas, opts)
call_graph_for_pages = CallGraph.remove_runtime_mfas!(call_graph_for_runtime, runtime_mfas)
page_entry_files_info =
page_modules
|> Compiler.create_page_entry_files(call_graph_for_pages, ir_plt, async_mfas, opts)
|> Enum.map(fn {entry_name, entry_file_path} ->
{entry_name, entry_file_path, "page"}
end)
entry_files_info = [{"runtime", runtime_entry_file_path, "runtime"} | page_entry_files_info]
old_build_static_artifacts =
opts[:static_dir]
|> File.ls!()
|> Enum.map(fn file_name -> Path.join(opts[:static_dir], file_name) end)
bundles_info = Compiler.bundle(entry_files_info, opts)
new_build_static_artifacts =
Enum.reduce(bundles_info, [], fn bundle_info, acc ->
[bundle_info.static_bundle_path, bundle_info.static_source_map_path | acc]
end)
{page_digest_plt, page_digest_plt_dump_path} =
Compiler.build_page_digest_plt(bundles_info, Keyword.put(opts, :supervisor, sup))
PLT.dump(page_digest_plt, page_digest_plt_dump_path)
CallGraph.dump(call_graph, call_graph_dump_path)
PLT.dump(new_module_digest_plt, module_digest_plt_dump_path)
Enum.each(old_build_static_artifacts -- new_build_static_artifacts, &File.rm!/1)
Logger.info("Hologram: compiler finished")
:ok
after
duration = System.monotonic_time() - start_time
:telemetry.execute([:hologram, :compiler, :stop], %{duration: duration}, %{})
DynamicSupervisor.stop(sup)
end
end
defp compile_with_lock(opts) do
lock_path = Path.join(opts[:build_dir], Reflection.compiler_lock_file_name())
with_lock(lock_path, fn ->
compile(opts)
end)
end
defp compiler_enabled? do
# credo:disable-for-next-line Credo.Check.Warning.MixEnv
Mix.env() not in [:dev, :test] or System.get_env("HOLOGRAM_START") == "1"
end
defp language_server_build?(opts) do
path_components = Path.split(opts[:build_dir])
Enum.any?(@ls_build_dirs, fn dir -> dir in path_components end)
end
defp maybe_remove_file(lock_path) do
if File.exists?(lock_path) do
File.rm(lock_path)
end
end
defp maybe_remove_stale_lock(lock_path) do
if File.exists?(lock_path) do
case File.read(lock_path) do
{:ok, os_pid_str} ->
validate_lock_file_and_proceed_accordingly(lock_path, os_pid_str)
{:error, _reason} ->
remove_unreadable_lock_file(lock_path)
end
end
end
defp remove_lock_file_with_invalid_os_pid(lock_path) do
Logger.info("Hologram: removing lock file with invalid OS-level PID format")
File.rm(lock_path)
end
defp remove_lock_for_dead_process(lock_path, os_pid) do
Logger.info(
"Hologram: removing stale lock file (OS-level process #{os_pid} no longer exists)"
)
File.rm(lock_path)
end
defp remove_unreadable_lock_file(lock_path) do
Logger.info("Hologram: removing unreadable lock file")
File.rm(lock_path)
end
defp validate_lock_file_and_proceed_accordingly(lock_path, os_pid_str) do
case Integer.parse(os_pid_str) do
{os_pid, _remainder} ->
if not SystemUtils.os_process_alive?(os_pid) do
remove_lock_for_dead_process(lock_path, os_pid)
end
:error ->
remove_lock_file_with_invalid_os_pid(lock_path)
end
end
defp with_lock(lock_path, fun) do
lock_path
|> Path.dirname()
|> File.mkdir_p!()
maybe_remove_stale_lock(lock_path)
case File.open(lock_path, [:write, :exclusive]) do
{:ok, file} ->
# Write OS-level PID to lock file for stale lock detection
IO.write(file, "#{System.pid()}")
File.close(file)
try do
fun.()
catch
kind, reason ->
maybe_remove_file(lock_path)
:erlang.raise(kind, reason, __STACKTRACE__)
after
maybe_remove_file(lock_path)
end
{:error, :eexist} ->
Logger.info("Hologram: compiler already running, waiting...")
:timer.sleep(1_000)
with_lock(lock_path, fun)
{:error, reason} ->
raise "Hologram: failed to acquire compiler lock: #{inspect(reason)}"
end
end
end