Current section
Files
Jump to
Current section
Files
lib/mix/interactive/supervised_shell.ex
defmodule Mix.Interactive.SupervisedShell do
@moduledoc false
alias Mix.Interactive.CLI
alias Mix.Interactive.Commands
alias Mix.Interactive.UI
defstruct [
:transport_module,
:transport_opts,
:client_opts,
:transport_pid,
:client_pid,
:restart_count,
:max_restarts
]
@max_restarts 3
@restart_delay 1000
@doc """
Starts a supervised interactive shell session.
## Options
* `:transport_module` - The transport module (e.g., Hermes.Transport.StreamableHTTP)
* `:transport_opts` - Options for starting the transport
* `:client_opts` - Options for starting the client (excluding transport)
* `:max_restarts` - Maximum number of automatic restarts (default: 3)
"""
def start(opts) do
state = %__MODULE__{
transport_module: Keyword.fetch!(opts, :transport_module),
transport_opts: Keyword.fetch!(opts, :transport_opts),
client_opts: Keyword.fetch!(opts, :client_opts),
restart_count: 0,
max_restarts: Keyword.get(opts, :max_restarts, @max_restarts)
}
Process.flag(:trap_exit, true)
case start_processes(state) do
{:ok, state} ->
IO.puts(
"\nType #{UI.colors().command}help#{UI.colors().reset} for available commands\n"
)
supervised_loop(state)
{:error, reason} ->
IO.puts(
"#{UI.colors().error}Failed to start processes: #{inspect(reason)}#{UI.colors().reset}"
)
{:error, reason}
end
end
defp supervised_loop(%{client_pid: client} = state) do
if Process.alive?(client) do
IO.write("#{UI.colors().prompt}mcp> #{UI.colors().reset}")
parent = self()
input_pid =
spawn_link(fn ->
line = IO.gets("")
send(parent, {:input, line})
end)
receive do
{:EXIT, pid, reason}
when pid == state.client_pid or pid == state.transport_pid ->
Process.exit(input_pid, :kill)
handle_process_exit(pid, reason, state)
{:EXIT, ^input_pid, _reason} ->
supervised_loop(state)
{:input, :eof} ->
:ok
{:input, line} ->
line
|> String.trim()
|> Commands.process_command(client, fn -> supervised_loop(state) end)
end
else
handle_process_exit(state.client_pid, :noproc, state)
end
end
defp handle_process_exit(pid, reason, state) do
cond do
pid == state.client_pid ->
IO.puts(
"\n#{UI.colors().error}✗ Client process crashed: #{format_exit_reason(reason)}#{UI.colors().reset}"
)
handle_restart(state)
pid == state.transport_pid ->
IO.puts(
"\n#{UI.colors().error}✗ Transport process crashed: #{format_exit_reason(reason)}#{UI.colors().reset}"
)
handle_restart(state)
true ->
supervised_loop(state)
end
end
defp format_exit_reason({:timeout, _}), do: "Request timeout"
defp format_exit_reason({:error, %{reason: reason}}), do: "Error: #{reason}"
defp format_exit_reason(:normal), do: "Normal termination"
defp format_exit_reason(:shutdown), do: "Shutdown"
defp format_exit_reason(reason), do: inspect(reason)
defp handle_restart(state) do
if state.restart_count < state.max_restarts do
IO.puts(
"#{UI.colors().info}→ Attempting automatic restart (#{state.restart_count + 1}/#{state.max_restarts})...#{UI.colors().reset}"
)
Process.sleep(@restart_delay)
cleanup_processes(state)
new_state = %{state | restart_count: state.restart_count + 1}
case start_processes(new_state) do
{:ok, restarted_state} ->
IO.puts(
"#{UI.colors().success}✓ Successfully restarted#{UI.colors().reset}"
)
IO.write("\n#{UI.colors().prompt}mcp> #{UI.colors().reset}")
supervised_loop(restarted_state)
{:error, reason} ->
IO.puts(
"#{UI.colors().error}✗ Restart failed: #{inspect(reason)}#{UI.colors().reset}"
)
offer_manual_restart(new_state)
end
else
IO.puts(
"#{UI.colors().error}✗ Maximum restart attempts reached#{UI.colors().reset}"
)
offer_manual_restart(state)
end
end
defp offer_manual_restart(state) do
IO.puts("\n#{UI.colors().info}Options:#{UI.colors().reset}")
IO.puts(" #{UI.colors().command}r#{UI.colors().reset} - Retry connection")
IO.puts(" #{UI.colors().command}q#{UI.colors().reset} - Quit")
IO.write("\n#{UI.colors().prompt}Choice: #{UI.colors().reset}")
case "" |> IO.gets() |> String.trim() |> String.downcase() do
"r" ->
IO.puts("#{UI.colors().info}→ Retrying connection...#{UI.colors().reset}")
cleanup_processes(state)
new_state = %{state | restart_count: 0}
case start_processes(new_state) do
{:ok, restarted_state} ->
IO.puts(
"#{UI.colors().success}✓ Successfully reconnected#{UI.colors().reset}"
)
supervised_loop(restarted_state)
{:error, reason} ->
IO.puts(
"#{UI.colors().error}✗ Retry failed: #{inspect(reason)}#{UI.colors().reset}"
)
offer_manual_restart(new_state)
end
"q" ->
IO.puts("#{UI.colors().info}Exiting...#{UI.colors().reset}")
cleanup_processes(state)
:ok
_ ->
offer_manual_restart(state)
end
end
defp start_processes(state) do
# Start client first - it will hibernate waiting for transport's :initialize message
with {:ok, client_pid} <- start_client(state),
{:ok, transport_pid} <- start_transport(state) do
Process.monitor(transport_pid)
Process.monitor(client_pid)
IO.puts("#{UI.colors().info}• Checking connection...#{UI.colors().reset}")
CLI.check_client_connection(client_pid)
{:ok, %{state | transport_pid: transport_pid, client_pid: client_pid}}
end
end
defp start_transport(%{transport_module: module, transport_opts: opts}) do
IO.puts("#{UI.colors().info}• Starting transport...#{UI.colors().reset}")
case module.start_link(opts) do
{:ok, pid} ->
IO.puts("#{UI.colors().success}✓ Transport started#{UI.colors().reset}")
{:ok, pid}
{:error, {:already_started, pid}} ->
IO.puts("#{UI.colors().info}• Transport already running#{UI.colors().reset}")
{:ok, pid}
error ->
error
end
end
defp start_client(%{client_opts: opts}) do
IO.puts("#{UI.colors().info}• Starting client...#{UI.colors().reset}")
case Hermes.Client.Base.start_link(opts) do
{:ok, pid} ->
IO.puts("#{UI.colors().success}✓ Client started#{UI.colors().reset}")
{:ok, pid}
{:error, {:already_started, pid}} ->
IO.puts("#{UI.colors().info}• Client already running#{UI.colors().reset}")
{:ok, pid}
error ->
error
end
end
defp cleanup_processes(state) do
if state.client_pid && Process.alive?(state.client_pid) do
Process.exit(state.client_pid, :shutdown)
end
if state.transport_pid && Process.alive?(state.transport_pid) do
Process.exit(state.transport_pid, :shutdown)
end
Process.sleep(100)
end
end