Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/expert/lib/expert/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -133,11 +133,13 @@ defmodule Expert.Application do
{Forge.NodePortMapper, []},
document_store_child_spec(),
{DynamicSupervisor, Expert.Project.DynamicSupervisor.options()},
{DynamicSupervisor, Expert.EngineBuild.DynamicSupervisor.options()},
{DynamicSupervisor, name: Expert.DynamicSupervisor},
{GenLSP.Assigns, [name: Expert.Assigns]},
{Task.Supervisor, name: :expert_task_queue},
{GenLSP.Buffer, [name: Expert.Buffer] ++ buffer_opts},
{Expert.ActiveProjects, []},
{Expert.EngineBuilds, []},
{Expert,
name: Expert,
buffer: Expert.Buffer,
Expand Down
9 changes: 9 additions & 0 deletions apps/expert/lib/expert/engine_build/dynamic_supervisor.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
defmodule Expert.EngineBuild.DynamicSupervisor do
def name do
Expert.EngineBuildSupervisor
end

def options do
[name: name(), strategy: :one_for_one]
end
end
179 changes: 179 additions & 0 deletions apps/expert/lib/expert/engine_builds.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
defmodule Expert.EngineBuilds do
use GenServer

alias Expert.EngineNode.Builder
alias Forge.Project

require Logger

defmodule State do
defstruct ready: %{}, pending: %{}
end

@type build_result :: {[String.t()], String.t() | nil}

def child_spec(_) do
%{
id: __MODULE__,
start: {__MODULE__, :start_link, []}
}
end

def start_link do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end

@spec request_engine(Project.t()) :: {:ok, build_result()} | {:error, term()}
def request_engine(%Project{} = project) do
with {:ok, key, build} <- resolve_build(project) do
GenServer.call(__MODULE__, {:request_engine, key, project, build}, :infinity)
end
end

@impl GenServer
def init(_) do
{:ok, %State{}}
end

@impl GenServer
def handle_call({:request_engine, key, project, build}, from, state) do
case Map.fetch(state.ready, key) do
{:ok, result} ->
{:reply, {:ok, result}, state}

:error ->
case Map.fetch(state.pending, key) do
{:ok, pending} ->
pending = %{pending | waiters: [from | pending.waiters]}
pending_map = Map.put(state.pending, key, pending)

{:noreply, %State{state | pending: pending_map}}

:error ->
case start_builder(project, build, key) do
{:ok, pid} ->
pending = %{pid: pid, ref: Process.monitor(pid), waiters: [from]}
Comment thread
doorgan marked this conversation as resolved.
pending_map = Map.put(state.pending, key, pending)

{:noreply, %State{state | pending: pending_map}}

{:error, reason} ->
{:reply, {:error, reason}, state}
end
end
end
end

@impl GenServer
def handle_info({:engine_build_complete, key, pid, result}, state) do
case pop_pending_by_key(state, key, pid) do
{:ok, pending, state} ->
Process.demonitor(pending.ref, [:flush])
reply_all(pending.waiters, result)

state =
case result do
{:ok, engine} -> %State{state | ready: Map.put(state.ready, key, engine)}
_ -> state
end

{:noreply, state}

:error ->
{:noreply, state}
end
end

def handle_info({:DOWN, ref, :process, pid, reason}, state) do
case pop_pending_by_ref(state, ref, pid) do
{:ok, pending, state} ->
reply_all(pending.waiters, {:error, reason})
{:noreply, state}

:error ->
{:noreply, state}
end
end

defp resolve_build(%Project{} = project) do
with {:ok, elixir, env} <- Expert.Port.project_executable(project, "elixir"),
{:ok, erl, _env} <- Expert.Port.project_executable(project, "erl") do
Logger.info("Using path: #{System.get_env("PATH")}", project: project)
Logger.info("Found elixir executable at #{elixir}", project: project)
Logger.info("Found erl executable at #{erl}", project: project)

with {:ok, key} <- build_key(project, elixir, env) do
{:ok, key, [elixir: elixir, env: env]}
end
else
{:error, name, message} ->
Logger.error(message, project: project)
Expert.terminate("Failed to find an #{name} executable, shutting down", 1)
{:error, message}
end
end

defp build_key(%Project{} = project, elixir, env) do
case toolchain_versions(project, elixir, env) do
{output, 0} ->
output = String.trim(output)

with {:ok, binary} <- Base.decode64(output),
{elixir_version, erts_version} <- :erlang.binary_to_term(binary) do
{:ok, {elixir_version, erts_version}}
else
_ -> {:error, "Failed to determine Elixir/OTP runtime for project"}
end
Comment thread
doorgan marked this conversation as resolved.

{output, status} ->
{:error,
"Failed to determine Elixir/OTP runtime for project: #{String.trim(output)} (status #{status})"}
end
end

defp toolchain_versions(%Project{} = project, elixir, env) do
cmd =
"{System.version(), to_string(:erlang.system_info(:version))} |> :erlang.term_to_binary() |> Base.encode64() |> IO.write()"

System.cmd(to_string(elixir), ["--eval", cmd],
Comment thread
doorgan marked this conversation as resolved.
env: env,
cd: Project.root_path(project),
stderr_to_stdout: true
)
end

defp start_builder(project, build, key) do
DynamicSupervisor.start_child(
Expert.EngineBuild.DynamicSupervisor.name(),
{Builder, {project, build, self(), key}}
)
end

defp pop_pending_by_key(state, key, pid) do
case state.pending do
%{^key => %{pid: ^pid} = pending} ->
pending_map = Map.delete(state.pending, key)
{:ok, pending, %State{state | pending: pending_map}}

%{} ->
:error
end
end

defp pop_pending_by_ref(state, ref, pid) do
case Enum.find(state.pending, fn {_key, pending} ->
pending.ref == ref and pending.pid == pid
end) do
{key, pending} ->
pending_map = Map.delete(state.pending, key)
{:ok, pending, %State{state | pending: pending_map}}

nil ->
:error
end
end

defp reply_all(waiters, result) do
Enum.each(waiters, &GenServer.reply(&1, result))
end
end
2 changes: 1 addition & 1 deletion apps/expert/lib/expert/engine_node.ex
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ defmodule Expert.EngineNode do

defp prepare_engine(project) do
Expert.Progress.with_progress("[#{Project.name(project)}] Preparing engine", fn _token ->
result = Expert.EngineNode.Builder.build_engine(project)
result = Expert.EngineBuilds.request_engine(project)

{:done, result, "Engine is ready"}
end)
Expand Down
105 changes: 48 additions & 57 deletions apps/expert/lib/expert/engine_node/builder.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,38 +9,41 @@ defmodule Expert.EngineNode.Builder do
require Logger

defmodule State do
defstruct [:project, :last_line, :from, :port, :mix_home, :buffer, attempts: 0]
defstruct [:project, :build, :owner, :key, :last_line, :port, :buffer, attempts: 0]
end

@max_attempts 1

def build_engine(project) do
with {:ok, pid} <- start_link(project) do
GenServer.call(pid, :build, :infinity)
end
def child_spec({project, build, owner, key}) do
%{
id: {__MODULE__, key},
start: {__MODULE__, :start_link, [{project, build, owner, key}]},
restart: :temporary
}
end

def start_link(project) do
GenServer.start_link(__MODULE__, project)
def start_link({project, build, owner, key}) do
GenServer.start_link(__MODULE__, {project, build, owner, key})
end

@impl GenServer
def init(project) do
{:ok, %State{project: project, last_line: "", buffer: ""}}
def init({project, build, owner, key}) do
state = %State{
project: project,
build: build,
owner: owner,
key: key,
last_line: "",
buffer: ""
}

{:ok, state, {:continue, :build}}
end

@impl GenServer
def handle_call(:build, from, %State{} = state) do
state =
case start_build(state.project, from) do
{:ok, port} ->
%State{state | port: port}

_ ->
state
end

{:noreply, %State{state | from: from}}
def handle_continue(:build, %State{} = state) do
{:ok, port} = start_build(state.project, state.build)
{:noreply, %State{state | port: port}}
end

@impl GenServer
Expand Down Expand Up @@ -68,7 +71,7 @@ defmodule Expert.EngineNode.Builder do
project: state.project
)

GenServer.reply(state.from, {:ok, {ebin_paths(engine_path), mix_home}})
notify(state, {:ok, {ebin_paths(engine_path), mix_home}})
{:stop, :normal, state}

:error ->
Expand All @@ -85,14 +88,15 @@ defmodule Expert.EngineNode.Builder do
{:noreply, state}
end

def handle_info({:build_result, result}, state) do
notify(state, result)
{:stop, :normal, state}
end

def handle_info({_port, {:exit_status, status}}, state) do
Logger.error("Engine build script exited with status: #{status}", project: state.project)

GenServer.reply(
state.from,
{:error, "Build script exited with status: #{status}", state.last_line}
)

notify(state, {:error, "Build script exited with status: #{status}", state.last_line})
{:stop, :normal, state}
end

Expand All @@ -101,7 +105,7 @@ defmodule Expert.EngineNode.Builder do
project: state.project
)

GenServer.reply(state.from, {:error, reason, state.last_line})
notify(state, {:error, reason, state.last_line})
{:stop, :normal, state}
end

Expand All @@ -115,15 +119,15 @@ defmodule Expert.EngineNode.Builder do
@excluded_apps [:patch, :nimble_parsec]
@allowed_apps [:engine | Mix.Project.deps_apps()] -- @excluded_apps

def start_build(_, from, _ \\ []) do
def start_build(_, _build, _opts \\ []) do
entries =
[Mix.Project.build_path(), "**/ebin"]
|> Forge.Path.glob()
|> Enum.filter(fn entry ->
Enum.any?(@allowed_apps, &String.contains?(entry, to_string(&1)))
end)

GenServer.reply(from, {:ok, {entries, nil}})
send(self(), {:build_result, {:ok, {entries, nil}}})
{:ok, :fake_port}
end

Expand All @@ -132,22 +136,12 @@ defmodule Expert.EngineNode.Builder do
# In dev and prod environments, the engine source code is included in the
# Expert release, and we build it on the fly for the project elixir+opt
# versions if it was not built yet.
defp start_build(%Project{} = project, from, opts \\ []) do
with {:ok, elixir, env} <- Expert.Port.project_executable(project, "elixir"),
{:ok, erl, _env} <- Expert.Port.project_executable(project, "erl") do
Logger.info("Using path: #{System.get_env("PATH")}", project: project)
Logger.info("Found elixir executable at #{elixir}", project: project)
Logger.info("Found erl executable at #{erl}", project: project)

port = launch_engine_builder(project, elixir, env, opts)
{:ok, port}
else
{:error, name, message} = error ->
Logger.error(message, project: project)
GenServer.reply(from, {:error, message})
Expert.terminate("Failed to find an #{name} executable, shutting down", 1)
error
end
def start_build(%Project{} = project, build, opts \\ []) do
elixir = Keyword.fetch!(build, :elixir)
env = Keyword.fetch!(build, :env)

port = launch_engine_builder(project, elixir, env, opts)
{:ok, port}
end

defp close_port(port), do: Port.close(port)
Expand Down Expand Up @@ -195,6 +189,10 @@ defmodule Expert.EngineNode.Builder do
)
end

defp notify(state, result) do
send(state.owner, {:engine_build_complete, state.key, self(), result})
end

defp ebin_paths(base_path) do
Forge.Path.glob([base_path, "lib/**/ebin"])
end
Expand All @@ -207,22 +205,15 @@ defmodule Expert.EngineNode.Builder do
)

close_port(state.port)
state = %State{state | attempts: state.attempts + 1}
{:ok, port} = start_build(state.project, state.build, force: true)

state =
case start_build(state.project, state.from, force: true) do
{:ok, port} ->
%State{state | port: port}

_ ->
state
end

{:noreply, %State{state | attempts: state.attempts + 1}}
{:noreply, %State{state | port: port}}
else
Logger.error("Maximum build attempts reached. Failing the build.", project: state.project)

GenServer.reply(
state.from,
notify(
state,
{:error, "Build failed due to dependency errors after #{@max_attempts} attempts", line}
)

Expand Down
Loading
Loading