Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

improvement: capture and suppress output in installers #186

Merged
merged 1 commit into from
Jan 5, 2025
Merged
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
245 changes: 245 additions & 0 deletions installer/lib/capture_server.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
defmodule Igniter.Installer.CaptureServer do
@moduledoc false
@compile {:no_warn_undefined, Logger}
@timeout :infinity
@name __MODULE__
@ets __MODULE__

use GenServer

def start_link(_opts) do
GenServer.start_link(__MODULE__, :ok, name: @name)
end

def device_capture_on(name, encoding, input) do
GenServer.call(@name, {:device_capture_on, name, encoding, input}, @timeout)
end

def device_output(name, ref) do
GenServer.call(@name, {:device_output, name, ref}, @timeout)
end

def device_capture_off(ref) do
GenServer.call(@name, {:device_capture_off, ref}, @timeout)
end

def log_capture_on(pid, string_io, opts) do
GenServer.call(@name, {:log_capture_on, pid, string_io, opts}, @timeout)
end

def log_capture_off(ref) do
GenServer.call(@name, {:log_capture_off, ref}, @timeout)
end

## Callbacks

@impl true
def init(:ok) do
:ets.new(@name, [:named_table, :public, :set])

state = %{
devices: %{},
log_captures: %{},
log_status: :error
}

{:ok, state}
end

@impl true
def handle_call(call, from, state)

def handle_call({:device_capture_on, name, encoding, input}, {caller, _}, config) do
capture_device(name, encoding, input, config, caller)
end

def handle_call({:device_output, name, ref}, _from, config) do
device = Map.fetch!(config.devices, name)
{_, output} = StringIO.contents(device.pid)
total = byte_size(output)
{_pid, offset} = Map.fetch!(device.refs, ref)
output_size = total - offset
{:reply, binary_part(output, offset, output_size), config}
end

def handle_call({:device_capture_off, ref}, _from, config) do
{:reply, :ok, release_device(ref, config)}
end

def handle_call({:log_capture_on, pid, string_io, opts}, _from, config) do
ref = Process.monitor(pid)
refs = Map.put(config.log_captures, ref, true)

{level, opts} = Keyword.pop(opts, :level)
{formatter_mod, formatter_config} = Logger.default_formatter(opts)
true = :ets.insert(@ets, {ref, string_io, level || :all, formatter_mod, formatter_config})

if map_size(refs) == 1 do
:ok = :logger.add_handler(@name, __MODULE__, %{})

status =
with {:ok, config} <- :logger.get_handler_config(:default),
:ok <- :logger.remove_handler(:default) do
{:ok, config}
else
_ -> :error
end

{:reply, ref, %{config | log_captures: refs, log_status: status}}
else
{:reply, ref, %{config | log_captures: refs}}
end
end

def handle_call({:log_capture_off, ref}, _from, config) do
Process.demonitor(ref, [:flush])
config = remove_log_capture(ref, config)
{:reply, :ok, config}
end

@impl true
def handle_info({:DOWN, ref, _, _, _}, config) do
config = remove_log_capture(ref, config)
config = release_device(ref, config)
{:noreply, config}
end

defp capture_device(name, encoding, input, config, caller) do
case config.devices do
%{^name => device} ->
dead_refs = for {ref, {pid, _}} <- device.refs, not Process.alive?(pid), do: ref

case dead_refs do
[] ->
capture_existing_device(name, encoding, input, config, caller)

_ ->
config = Enum.reduce(dead_refs, config, &release_device/2)
capture_device(name, encoding, input, config, caller)
end

%{} ->
capture_new_device(name, encoding, input, config, caller)
end
end

defp capture_existing_device(name, encoding, input, config, caller) do
case Map.fetch!(config.devices, name) do
%{input?: input?} when input? or input != "" ->
{:reply, {:error, :input_on_already_captured_device}, config}

%{encoding: ^encoding} = device ->
{_, output} = StringIO.contents(device.pid)
ref = Process.monitor(caller)
config = put_in(config.devices[name].refs[ref], {caller, byte_size(output)})
{:reply, {:ok, ref}, config}

%{encoding: other_encoding} ->
{:reply, {:error, {:changed_encoding, other_encoding}}, config}
end
end

defp capture_new_device(name, encoding, input, config, caller) do
{:ok, pid} = StringIO.open(input, encoding: encoding)
original_pid = Process.whereis(name)

try do
Process.unregister(name)
Process.register(pid, name)
rescue
ArgumentError ->
{:reply, {:error, :no_device}, config}
else
_ ->
ref = Process.monitor(caller)

device = %{
original_pid: original_pid,
pid: pid,
refs: %{ref => {caller, 0}},
encoding: encoding,
input?: input != ""
}

{:reply, {:ok, ref}, put_in(config.devices[name], device)}
end
end

defp release_device(ref, %{devices: devices} = config) do
Process.demonitor(ref, [:flush])

case Enum.find(devices, fn {_, device} -> Map.has_key?(device.refs, ref) end) do
{name, device} ->
case Map.delete(device.refs, ref) do
refs when map_size(refs) == 0 ->
revert_device_to_original_pid(name, device.original_pid)
close_string_io(device.pid)
%{config | devices: Map.delete(devices, name)}

refs ->
put_in(config.devices[name].refs, refs)
end

_ ->
config
end
end

defp revert_device_to_original_pid(name, pid) do
Process.unregister(name)
rescue
ArgumentError -> nil
after
Process.register(pid, name)
end

defp close_string_io(pid) do
StringIO.close(pid)
rescue
ArgumentError -> nil
end

defp remove_log_capture(ref, %{log_captures: refs} = config) do
true = :ets.delete(@ets, ref)

case Map.pop(refs, ref, false) do
{true, refs} ->
maybe_revert_to_default_handler(refs, config.log_status)
%{config | log_captures: refs}

{false, _refs} ->
config
end
end

defp maybe_revert_to_default_handler(refs, status) when map_size(refs) == 0 do
:logger.remove_handler(@name)

with {:ok, %{module: module} = config} <- status do
:logger.add_handler(:default, module, config)
end
end

defp maybe_revert_to_default_handler(_refs, _config) do
:ok
end

## :logger handler callback.

def log(event, _config) do
for {_ref, string_io, level, formatter_mod, formatter_config} <- :ets.tab2list(@ets),
:logger.compare_levels(event.level, level) in [:gt, :eq] do
chardata = formatter_mod.format(event, formatter_config)
# There is a race condition where the capture_log is removed
# but another process is attempting to log to string io device
# that no longer exists, so we wrap it in try/catch.
try do
IO.write(string_io, chardata)
rescue
_ -> :ok
end
end

:ok
end
end
34 changes: 21 additions & 13 deletions installer/lib/mix/tasks/igniter.install.ex
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ if !Code.ensure_loaded?(Mix.Tasks.Igniter.Install) do
* `--dry-run` - Run the task without making any changes.
* `--yes` - Automatically answer yes to any prompts.
* `--yes-to-deps` - Automatically answer yes to any prompts about installing new deps.
* `--verbose` - display additional output from various operations
* `--example` - Request that installed packages include initial example code.
"""
use Mix.Task
Expand All @@ -36,11 +37,19 @@ if !Code.ensure_loaded?(Mix.Tasks.Igniter.Install) do
@impl true
@shortdoc "Install a package or packages, and run any associated installers."
def run(argv) do
Mix.Task.run("deps.compile", ["--long-compilation-threshold", "300"])
Igniter.Installer.Task.with_spinner(
"deps.compile",
fn ->
Mix.Task.run("deps.compile")

if Code.ensure_loaded?(Igniter.Util.Install) do
Mix.Task.run("compile", ["--no-compile"])
if Code.ensure_loaded?(Igniter.Util.Install) do
Mix.Task.run("deps.compile", ["--no-compile"])
end
end,
verbose?: "--verbose" in argv
)

if Code.ensure_loaded?(Igniter.Util.Install) do
{argv, positional} = extract_positional_args(argv)

packages =
Expand Down Expand Up @@ -111,18 +120,17 @@ if !Code.ensure_loaded?(Mix.Tasks.Igniter.Install) do

System.cmd("mix", ["deps.get"])

for task <- @tasks, do: Mix.Task.reenable(task)
Igniter.Installer.Task.with_spinner(
"deps.compile",
fn ->
for task <- @tasks, do: Mix.Task.reenable(task)

for task <- @tasks do
options =
if String.ends_with?(task, "compile") do
["--long-compilation-threshold", "300"]
else
[]
for task <- @tasks do
Mix.Task.run(task, [])
end

Mix.Task.run(task, options)
end
end,
verbose?: "--verbose" in argv
)

Mix.Task.reenable("igniter.install")
Mix.Task.run("igniter.install", argv)
Expand Down
Loading
Loading