From ed4b7825b370c0126ebf2c0db6a421c4969acbb7 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Wed, 26 Aug 2026 14:40:25 +0100 Subject: [PATCH 1/2] fix: surface anvil boot failures instead of looping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stack whose options anvil rejects exited 2 before opening its RPC port, and all that reached the logs was our own 10s readiness timeout — anvil's message went only to the in-memory log queue. Every later request paid the same 10s and failed the same way. - log anvil's last output lines when a boot fails, with its exit status - keep :failed after a clap usage error (exit 2), since the args cannot change while the process lives; other exits stay resumable - ensure_running/2 returns {:error, reason}, so the proxy reports why instead of "Stack not found" - reject fork_block_number without fork_url, which is one way to hit exit 2 - drop caller flags that collide with server-managed ones: a repeated flag is a clap error, not a last-one-wins override - hold one http port per stack for its lifetime; every resume claimed a new one and leaked the old, and suspend now waits for the process to die before the port can be rebound Co-Authored-By: Claude Opus 5 (1M context) --- server/lib/ethui/services/anvil.ex | 230 +++++++++++++++++----- server/lib/ethui/stacks/server.ex | 8 + server/lib/ethui/stacks/stack.ex | 5 + server/test/ethui/services/anvil_test.exs | 58 ++++++ server/test/ethui/stacks/stack_test.exs | 7 + 5 files changed, 255 insertions(+), 53 deletions(-) diff --git a/server/lib/ethui/services/anvil.ex b/server/lib/ethui/services/anvil.ex index ceb5f6b..4ae2ad4 100644 --- a/server/lib/ethui/services/anvil.ex +++ b/server/lib/ethui/services/anvil.ex @@ -37,7 +37,9 @@ defmodule Ethui.Services.Anvil do chain_id: String.t(), # idle timer idle_timer: reference() | nil, - status: atom(), + status: :suspended | :running | :failed, + # why the last boot attempt failed, when status is :failed + error: term(), last_used: integer } @@ -61,7 +63,7 @@ defmodule Ethui.Services.Anvil do end # Booting a forked instance waits on the upstream chain, well past a default call timeout - @spec ensure_running(id, timeout) :: :ok + @spec ensure_running(id, timeout) :: :ok | {:error, {:exit, integer} | term} def ensure_running(id, timeout \\ :timer.seconds(30)) do GenServer.call(id, :ensure_running, timeout) end @@ -114,6 +116,7 @@ defmodule Ethui.Services.Anvil do args: opts_to_args(opts[:anvil_opts]), idle_timer: nil, status: :suspended, + error: nil, last_used: nil }} else @@ -123,30 +126,34 @@ defmodule Ethui.Services.Anvil do @impl GenServer def handle_info({:EXIT, _pid, exit_status}, %{port: port} = state) do - Ethui.Stacks.HttpPorts.free(port) - - new_state = %{state | port: nil} - case exit_status do - 0 -> - {:stop, :normal, new_state} - + # we killed it ourselves on suspend: the port stays claimed so the stack + # keeps the same URL when it resumes :killed -> - {:noreply, new_state} + {:noreply, %{state | proc: nil}} + + 0 -> + Ethui.Stacks.HttpPorts.free(port) + {:stop, :normal, %{state | port: nil, proc: nil}} exit_code -> Logger.error("anvil exited with code #{inspect(exit_code)}") - {:stop, :normal, new_state} + Ethui.Stacks.HttpPorts.free(port) + {:stop, :normal, %{state | port: nil, proc: nil}} end end + def handle_info({:anvil_output, line}, state) do + {:noreply, log_line(state, line)} + end + def handle_info( :suspend, %{status: :running, proc: proc, last_used: last_used} = state ) do Logger.info("Suspending #{state.slug}: #{last_used}") - Process.exit(proc, :kill) + kill_proc(proc) {:noreply, %{state | proc: nil, status: :suspended, idle_timer: nil}} end @@ -166,36 +173,36 @@ defmodule Ethui.Services.Anvil do _from, %{slug: slug, status: status} = state ) do - state = - case status do - :running -> - state - - :suspended -> - Logger.info("restarting slug: #{slug}") - - start_anvil(state) - end - - {:reply, :ok, state} + case status do + :running -> + {:reply, :ok, state} + + # anvil rejected these args once and they cannot change while this process + # lives, so retrying only burns another readiness timeout per request + :failed -> + {:reply, {:error, state.error}, state} + + :suspended -> + Logger.info("restarting slug: #{slug}") + + case start_anvil(state) do + {:ok, state} -> {:reply, :ok, state} + {:error, reason, state} -> {:reply, {:error, reason}, state} + end + end end @impl GenServer def handle_cast(:destroy, %{proc: proc} = state) do _ = remove_dir(state) - GenServer.stop(proc) + # proc is nil while suspended + if proc, do: GenServer.stop(proc) {:stop, :normal, state} end @impl GenServer - def handle_cast({:log, line}, %{logs: logs, log_subscribers: subs} = state) do - for s <- subs do - send(s, {:logs, :anvil, state.slug, [line]}) - end - - new_logs = :queue.in(line, logs) |> trim() - - {:noreply, %{state | logs: new_logs}} + def handle_cast({:log, line}, state) do + {:noreply, log_line(state, line)} end @impl GenServer @@ -212,8 +219,23 @@ defmodule Ethui.Services.Anvil do {:noreply, %{touch(state) | log_subscribers: MapSet.delete(subs, pid)}} end + @impl GenServer + def terminate(_reason, %{port: port}) when is_integer(port) do + Ethui.Stacks.HttpPorts.free(port) + end + + def terminate(_reason, _state), do: :ok + ## aux + defp log_line(%{logs: logs, log_subscribers: subs} = state, line) do + for s <- subs do + send(s, {:logs, :anvil, state.slug, [line]}) + end + + %{state | logs: :queue.in(line, logs) |> trim()} + end + defp remove_dir(state) do case File.rm_rf(state.dir) do {:ok, _files} -> @@ -276,6 +298,25 @@ defmodule Ethui.Services.Anvil do defp dashify(key) when is_binary(key), do: String.replace(key, "_", "-") + @managed_flags ~w(--port --state --host --chain-id --preserve-historical-states) + + defp drop_managed([]), do: [] + + defp drop_managed([arg | rest]) do + if arg in @managed_flags do + rest |> drop_value() |> drop_managed() + else + [arg | drop_managed(rest)] + end + end + + # a flag's value, if it has one: the next element unless it is another flag + defp drop_value([value | rest]) do + if String.starts_with?(value, "--"), do: [value | rest], else: rest + end + + defp drop_value([]), do: [] + # Without --preserve-historical-states, every suspend/resume cycle silently drops # pre-restart state: blocks and logs survive, but eth_call at any older block fails # with BlockOutOfRangeError, which breaks indexers replaying chain history. @@ -328,12 +369,17 @@ defmodule Ethui.Services.Anvil do end defp start_anvil(%{dir: dir, chain_id: chain_id, args: args, slug: slug} = state) do - {:ok, port} = Ethui.Stacks.HttpPorts.claim() - + # The port is claimed once, in init/1, and held for the lifetime of the + # stack: claiming a fresh one on every resume leaked the previous one and + # moved the stack's URL out from under anyone holding it. + port = state.port pid = self() - # Caller args go first so the server-managed ones win any duplicate: clap - # takes the last occurrence of a flag. + # A repeated flag is a clap usage error ("cannot be used multiple times", + # exit 2), not a last-one-wins override, so a caller flag that collides + # with a server-managed one is dropped rather than appended to. + args = drop_managed(args) + anvil_args = args ++ [ @@ -347,23 +393,101 @@ defmodule Ethui.Services.Anvil do to_string(chain_id) ] ++ history_args(args) - with {:ok, proc} <- - MuonTrap.Daemon.start_link( - anvil_bin(), - anvil_args, - logger_fun: fn f -> GenServer.cast(pid, {:log, f}) end, - # TODO maybe patch muontrap to have a separate stream for stderr - stderr_to_stdout: true, - exit_status_to_reason: & &1 - ), - :ok <- wait_until_ready(port) do - Logger.info("restarting slug with port: #{slug} #{port}") - - %{state | proc: proc, status: :running, port: port} |> touch() - else + case MuonTrap.Daemon.start_link( + anvil_bin(), + anvil_args, + logger_fun: fn line -> send(pid, {:anvil_output, line}) end, + # TODO maybe patch muontrap to have a separate stream for stderr + stderr_to_stdout: true, + exit_status_to_reason: & &1 + ) do + {:ok, proc} -> + case wait_until_ready(port) do + :ok -> + Logger.info("restarting slug with port: #{slug} #{port}") + {:ok, %{state | proc: proc, status: :running, error: nil} |> touch()} + + {:error, reason} -> + failed_to_boot(state, proc, reason) + end + {:error, reason} -> - Logger.error("Failed to start anvil: #{inspect(reason)}") - state + Logger.error("Failed to start anvil for #{slug}: #{inspect(reason)}") + {:error, reason, state} + end + end + + # anvil writes the real reason to stdout and exits before the RPC port ever + # opens - a rejected flag exits 2 immediately - so without replaying its + # output all that reaches the logs is our own readiness timeout. + defp failed_to_boot(%{slug: slug} = state, proc, reason) do + {lines, state} = drain_output(state) + exit_status = stop_proc(proc) + + Logger.error( + "Failed to start anvil for #{slug}: #{inspect(reason)}" <> + exit_description(exit_status) <> output_description(lines) + ) + + case exit_status do + # clap's usage error: anvil rejected the arguments themselves, and they + # cannot change while this process lives, so every later request fails + # from here instead of paying for another boot that cannot work + {:exited, 2} -> + {:error, {:exit, 2}, %{state | proc: nil, status: :failed, error: {:exit, 2}}} + + # anything else - an unreachable fork, a port that is still bound - can + # succeed on the next try, so the stack stays resumable + {:exited, code} when is_integer(code) -> + {:error, {:exit, code}, %{state | proc: nil, status: :suspended, error: {:exit, code}}} + + _ -> + {:error, reason, %{state | proc: nil, status: :suspended, error: reason}} + end + end + + # log lines arrive as messages, and this runs inside the call that started + # anvil, so everything it printed is still sitting in our mailbox + defp drain_output(state, acc \\ []) do + receive do + {:anvil_output, line} -> drain_output(state, [line | acc]) + after + 0 -> + lines = Enum.reverse(acc) + {lines, Enum.reduce(lines, state, &log_line(&2, &1))} + end + end + + defp stop_proc(proc) do + receive do + {:EXIT, ^proc, status} -> + {:exited, status} + after + 0 -> + kill_proc(proc) + :killed end end + + # waits for the exit so the http port is free again before the next resume + # tries to bind it + defp kill_proc(proc) do + Process.exit(proc, :kill) + + receive do + {:EXIT, ^proc, _status} -> :ok + after + :timer.seconds(5) -> :ok + end + end + + defp exit_description({:exited, status}), do: ", anvil exited with #{inspect(status)}" + defp exit_description(:killed), do: ", anvil never became ready and was killed" + + defp output_description([]), do: ", no output" + + defp output_description(lines) do + tail = lines |> Enum.take(-10) |> Enum.map_join(" | ", &String.trim/1) + ", last output: #{tail}" + end end diff --git a/server/lib/ethui/stacks/server.ex b/server/lib/ethui/stacks/server.ex index 38ca5fd..3dfc79a 100644 --- a/server/lib/ethui/stacks/server.ex +++ b/server/lib/ethui/stacks/server.ex @@ -65,6 +65,14 @@ defmodule Ethui.Stacks.Server do url when not is_nil(url) <- Anvil.url(pid) do {:ok, url} else + # anvil rejected its arguments and exited; the message it printed is in + # the stack's logs + {:error, {:exit, code}} -> + {:error, "Stack failed to start: anvil exited with code #{code}, check its anvil options"} + + {:error, :timeout} -> + {:error, "Stack is taking too long to start"} + _ -> {:error, "Stack not found"} end diff --git a/server/lib/ethui/stacks/stack.ex b/server/lib/ethui/stacks/stack.ex index 7aeff95..db3af57 100644 --- a/server/lib/ethui/stacks/stack.ex +++ b/server/lib/ethui/stacks/stack.ex @@ -112,6 +112,11 @@ defmodule Ethui.Stacks.Stack do opts = get_field(changeset, :anvil_opts) || %{} changeset + |> conflict( + :anvil_opts, + opts["fork_block_number"] && is_nil(opts["fork_url"]), + "fork_block_number requires fork_url" + ) |> conflict( :anvil_opts, opts["mixed_mining"] && is_nil(opts["block_time"]), diff --git a/server/test/ethui/services/anvil_test.exs b/server/test/ethui/services/anvil_test.exs index 68823fd..c028d2a 100644 --- a/server/test/ethui/services/anvil_test.exs +++ b/server/test/ethui/services/anvil_test.exs @@ -106,6 +106,64 @@ defmodule Ethui.Services.AnvilTest do Anvil.destroy(anvil) end + test "ignores server-managed flags handed in as anvil options" do + {:ok, anvil} = + Anvil.start_link( + ports: HttpPorts, + slug: "managed", + hash: "hash", + # anvil would exit 2 on the repeated flags if these reached the command + anvil_opts: %{"port" => 1, "host" => "10.0.0.1", "chain_id" => 5}, + id: 1 + ) + + assert :ok = Anvil.ensure_running(anvil) + + client = Rpc.new_client(:http, rpc_url: Anvil.url(anvil)) + assert {:ok, _} = Rpc.request("anvil_nodeInfo", []) |> Rpc.send(client) + + Anvil.destroy(anvil) + end + + test "reports a boot failure instead of retrying it forever" do + {:ok, anvil} = + Anvil.start_link( + ports: HttpPorts, + slug: "badopts", + hash: "hash", + # anvil refuses a fork block number with no fork url, and exits 2 + # before it ever opens the rpc port + anvil_opts: %{"fork_block_number" => 100}, + id: 1 + ) + + assert {:error, {:exit, 2}} = Anvil.ensure_running(anvil) + + # the second call answers from the recorded failure, without waiting on + # another boot + assert {:error, {:exit, 2}} = Anvil.ensure_running(anvil, :timer.seconds(1)) + assert Process.alive?(anvil) + + Anvil.destroy(anvil) + end + + test "keeps the same port across suspend and resume" do + {:ok, anvil} = Anvil.start_link(ports: HttpPorts, slug: "resumed", hash: "hash", id: 1) + :ok = Anvil.ensure_running(anvil) + url = Anvil.url(anvil) + + send(anvil, :suspend) + Process.sleep(200) + + :ok = Anvil.ensure_running(anvil) + assert Anvil.url(anvil) == url + + client = Rpc.new_client(:http, rpc_url: url) + assert {:ok, _} = Rpc.request("anvil_nodeInfo", []) |> Rpc.send(client) + + Anvil.destroy(anvil) + end + test "creates multiple anvil processes" do anvils = for i <- 1..10 do diff --git a/server/test/ethui/stacks/stack_test.exs b/server/test/ethui/stacks/stack_test.exs index 37b8a53..9ddd250 100644 --- a/server/test/ethui/stacks/stack_test.exs +++ b/server/test/ethui/stacks/stack_test.exs @@ -70,6 +70,13 @@ defmodule Ethui.Stacks.StackTest do assert msg =~ "no_mining cannot be combined with block_time or mixed_mining" end + test "rejects a fork block number without a fork url" do + assert %{anvil_opts: [msg]} = errors_on(anvil_opts(%{"fork_block_number" => 100})) + assert msg =~ "fork_block_number requires fork_url" + + assert anvil_opts(%{"fork_url" => "http://localhost:1", "fork_block_number" => 100}).valid? + end + test "rejects a non-map value" do assert %{anvil_opts: ["is invalid"]} = errors_on(anvil_opts("--port 4000")) end From 8266473605deafd56c10c69dbfcac5fd2d15bfbf Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Wed, 26 Aug 2026 14:40:29 +0100 Subject: [PATCH 2/2] chore: bump credo and mix_test_watch Both fail to compile on OTP 28, which the devenv shell now provides: credo hits a PCRE2 character-class error, mix_test_watch escapes a reference into a struct default. Neither is reachable from the release. Co-Authored-By: Claude Opus 5 (1M context) --- server/mix.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/server/mix.lock b/server/mix.lock index ae36eaf..b0cca94 100644 --- a/server/mix.lock +++ b/server/mix.lock @@ -15,9 +15,9 @@ "cowboy": {:hex, :cowboy, "2.13.0", "09d770dd5f6a22cc60c071f432cd7cb87776164527f205c5a6b0f24ff6b38990", [:make, :rebar3], [{:cowlib, ">= 2.14.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "e724d3a70995025d654c1992c7b11dbfea95205c047d86ff9bf1cda92ddc5614"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, "cowlib": {:hex, :cowlib, "2.15.0", "3c97a318a933962d1c12b96ab7c1d728267d2c523c25a5b57b0f93392b6e9e25", [:make, :rebar3], [], "hexpm", "4f00c879a64b4fe7c8fcb42a4281925e9ffdb928820b03c3ad325a617e857532"}, - "credo": {:hex, :credo, "1.7.11", "d3e805f7ddf6c9c854fd36f089649d7cf6ba74c42bc3795d587814e3c9847102", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "56826b4306843253a66e47ae45e98e7d284ee1f95d53d1612bb483f88a8cf219"}, + "credo": {:hex, :credo, "1.7.19", "cc52129665fc7c15143d47838fda0f9cd6dac9ceced7bf4da6f85fcbfe64b12a", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "2d8bc95d5a7bb99dd2613621d4f08c6a3575c3fd4b62e6a2b48a100352a557b8"}, "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, - "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, + "decimal": {:hex, :decimal, "2.4.1", "6c0fbede12fb122ba685e9ab41c6a40c129e322b3aa192f9e072e61f3a6ffaf2", [:mix], [], "hexpm", "7e618897933a8455f19a727d7c5e50a2c071a544b700e5e724298ecb4340187f"}, "dets_plus": {:hex, :dets_plus, "2.4.3", "d440791412ecb4b2aef283837c50d40ba4f4c28ed617fff46e3f1f515ec7ebc9", [:mix], [{:paged_file, "~> 1.1", [hex: :paged_file, repo: "hexpm", optional: false]}], "hexpm", "6c7e0a89cebac1a8ce5cdf3f5ce9947909749108e399d2625bbac6b6794d73d1"}, "dialyxir": {:hex, :dialyxir, "1.4.5", "ca1571ac18e0f88d4ab245f0b60fa31ff1b12cbae2b11bd25d207f865e8ae78a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b0fb08bb8107c750db5c0b324fa2df5ceaa0f9307690ee3c1f6ba5b9eb5d35c3"}, "dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"}, @@ -31,7 +31,7 @@ "expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"}, "exqlite": {:hex, :exqlite, "0.30.1", "a85ed253ab7304c3733a74d3bc62b68afb0c7245ce30416aa6f9d0cfece0e58f", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "15714871147d8d6c12be034013d351ce670e02c09b7f49accabb23e9290d80a0"}, "exth": {:hex, :exth, "0.2.2", "1dbbd42308a3d4d4ef54a3af8237bc697dddc0cf334979bed644eca0ce6ec926", [:mix], [{:tesla, "~> 1.14", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "15d32f9faf00bc78085fc183708cef0b76b3bf2ca04b1906c4f28cb13b90942a"}, - "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, "floki": {:hex, :floki, "0.37.1", "d7aaee758c8a5b4a7495799a4260754fec5530d95b9c383c03b27359dea117cf", [:mix], [], "hexpm", "673d040cb594d31318d514590246b6dd587ed341d3b67e17c1c0eb8ce7ca6f04"}, "gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"}, @@ -42,7 +42,7 @@ "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "httpoison": {:hex, :httpoison, "2.2.3", "a599d4b34004cc60678999445da53b5e653630651d4da3d14675fedc9dd34bd6", [:mix], [{:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "fa0f2e3646d3762fdc73edb532104c8619c7636a6997d20af4003da6cfc53e53"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, - "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, "joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"}, "jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"}, "logger_json": {:hex, :logger_json, "6.2.1", "a1db30e1164e6057f2328a1e4d6b632b9583c015574fdf6c38cf73721128edcb", [:mix], [{:decimal, ">= 0.0.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:ecto, "~> 3.11", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "34acd0bfd419d5fcf08c4108a8a4b59b695fcc60409dc1dd1a868b70c42e1d1f"}, @@ -51,7 +51,7 @@ "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mimerl": {:hex, :mimerl, "1.3.0", "d0cd9fc04b9061f82490f6581e0128379830e78535e017f7780f37fea7545726", [:rebar3], [], "hexpm", "a1e15a50d1887217de95f0b9b0793e32853f7c258a5cd227650889b38839fe9d"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, - "mix_test_watch": {:hex, :mix_test_watch, "1.2.0", "1f9acd9e1104f62f280e30fc2243ae5e6d8ddc2f7f4dc9bceb454b9a41c82b42", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "278dc955c20b3fb9a3168b5c2493c2e5cffad133548d307e0a50c7f2cfbf34f6"}, + "mix_test_watch": {:hex, :mix_test_watch, "1.4.0", "d88bcc4fbe3198871266e9d2f00cd8ae350938efbb11d3fa1da091586345adbb", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "2b4693e17c8ead2ef56d4f48a0329891e8c2d0d73752c0f09272a2b17dc38d1b"}, "money": {:hex, :money, "1.14.0", "61c1e9d9ae1dd45dae7f72568987b3e7275031c3f5a0bf8a053bd74259555934", [:mix], [{:decimal, "~> 1.2 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:ecto, "~> 2.1 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.0 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "b8691009e0c31715d2e5a3cca68ca2e1a46895d63c11257b317d8801ee2c54e3"}, "mua": {:hex, :mua, "0.2.4", "a9172ab0a1ac8732cf2699d739ceac3febcb9b4ffc540260ad2e32c0b6632af9", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "e7e4dacd5ad65f13e3542772e74a159c00bd2d5579e729e9bb72d2c73a266fb7"}, "muontrap": {:hex, :muontrap, "1.6.1", "4a81a159f64e4c7bf01162a7863559d634bc48929218690ada309a9a98a9ac22", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "8ad31072402bebed3f554c9a463aa272c6dd964168c9cb81385f8711f068ed47"},