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. """ 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) 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() runtime_mfas = CallGraph.list_runtime_mfas(call_graph_for_runtime) runtime_entry_file_path = Compiler.create_runtime_entry_file(runtime_mfas, ir_plt, async_mfas, opts) page_modules = Reflection.list_pages() Compiler.validate_page_modules(page_modules) 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 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