Packages
Shared execution-surface substrate for local subprocess, SSH exec, and guest-bridge placement.
Current section
Files
Jump to
Current section
Files
lib/external_runtime_transport/transport.ex
defmodule ExternalRuntimeTransport.Transport do
@moduledoc """
Behaviour for the raw subprocess transport layer.
In addition to the long-lived subscriber-driven transport API, the transport
layer also owns synchronous non-PTY command execution through `run/2`.
Legacy subscribers receive bare transport tuples:
- `{:transport_message, line}`
- `{:transport_data, chunk}`
- `{:transport_error, %ExternalRuntimeTransport.Transport.Error{}}`
- `{:transport_stderr, chunk}`
- `{:transport_exit, %ExternalRuntimeTransport.ProcessExit{}}`
Tagged subscribers receive:
- `{event_tag, ref, {:message, line}}`
- `{event_tag, ref, {:data, chunk}}`
- `{event_tag, ref, {:error, %ExternalRuntimeTransport.Transport.Error{}}}`
- `{event_tag, ref, {:stderr, chunk}}`
- `{event_tag, ref, {:exit, %ExternalRuntimeTransport.ProcessExit{}}}`
When `:replay_stderr_on_subscribe?` is enabled at startup, newly attached
subscribers also receive the retained stderr tail immediately after
subscription. When `:buffer_events_until_subscribe?` is enabled, stdout,
stderr, and error events emitted before the first subscriber attaches are
replayed in order.
"""
alias ExternalRuntimeTransport.{Command, ProcessExit, TaskSupport, Transport.Error}
alias ExternalRuntimeTransport.ExecutionSurface
alias ExternalRuntimeTransport.ExecutionSurface.Capabilities
alias ExternalRuntimeTransport.Transport.Delivery
alias ExternalRuntimeTransport.Transport.Info
alias ExternalRuntimeTransport.Transport.RunResult
@default_call_timeout_ms 5_000
@default_force_close_timeout_ms 5_000
@typedoc "Opaque transport reference."
@type t :: pid()
@typedoc "Legacy subscribers use `:legacy`; tagged subscribers use a reference."
@type subscription_tag :: :legacy | reference()
@typedoc "The tagged event atom prefix."
@type event_tag :: atom()
@typedoc "Generic execution-surface placement kind."
@type surface_kind :: :local_subprocess | :ssh_exec | :guest_bridge
@typedoc "Transport events delivered to subscribers."
@type message ::
{:transport_message, binary()}
| {:transport_data, binary()}
| {:transport_error, Error.t()}
| {:transport_stderr, binary()}
| {:transport_exit, ProcessExit.t()}
| {event_tag(), reference(), {:message, binary()}}
| {event_tag(), reference(), {:data, binary()}}
| {event_tag(), reference(), {:error, Error.t()}}
| {event_tag(), reference(), {:stderr, binary()}}
| {event_tag(), reference(), {:exit, ProcessExit.t()}}
@typedoc "Normalized transport event payload extracted from a mailbox message."
@type extracted_event ::
{:message, binary()}
| {:data, binary()}
| {:error, Error.t()}
| {:stderr, binary()}
| {:exit, ProcessExit.t()}
@callback start(keyword()) :: {:ok, t()} | {:error, {:transport, Error.t()}}
@callback start_link(keyword()) :: {:ok, t()} | {:error, {:transport, Error.t()}}
@callback run(Command.t(), keyword()) ::
{:ok, RunResult.t()} | {:error, {:transport, Error.t()}}
@callback send(t(), iodata() | map() | list()) :: :ok | {:error, {:transport, Error.t()}}
@callback subscribe(t(), pid()) :: :ok | {:error, {:transport, Error.t()}}
@callback subscribe(t(), pid(), subscription_tag()) ::
:ok | {:error, {:transport, Error.t()}}
@callback unsubscribe(t(), pid()) :: :ok
@callback close(t()) :: :ok
@callback force_close(t()) :: :ok | {:error, {:transport, Error.t()}}
@callback interrupt(t()) :: :ok | {:error, {:transport, Error.t()}}
@callback status(t()) :: :connected | :disconnected | :error
@callback end_input(t()) :: :ok | {:error, {:transport, Error.t()}}
@callback stderr(t()) :: binary()
@callback info(t()) :: Info.t()
@doc """
Starts the default raw transport implementation.
"""
@spec start(keyword()) :: {:ok, t()} | {:error, {:transport, Error.t()}}
def start(opts) when is_list(opts) do
case ExecutionSurface.resolve(opts) do
{:ok, %{dispatch: dispatch, adapter_options: adapter_options} = resolved} ->
with :ok <- validate_start_capabilities(resolved, opts) do
dispatch.start.(adapter_options)
end
{:error, reason} ->
transport_error(reason)
end
end
@doc """
Starts the default raw transport implementation and links it to the caller.
"""
@spec start_link(keyword()) :: {:ok, t()} | {:error, {:transport, Error.t()}}
def start_link(opts) when is_list(opts) do
case ExecutionSurface.resolve(opts) do
{:ok, %{dispatch: dispatch, adapter_options: adapter_options} = resolved} ->
with :ok <- validate_start_capabilities(resolved, opts) do
dispatch.start_link.(adapter_options)
end
{:error, reason} ->
transport_error(reason)
end
end
@doc """
Runs a one-shot non-PTY command and captures exact stdout, stderr, and exit
data.
"""
@spec run(Command.t(), keyword()) :: {:ok, RunResult.t()} | {:error, {:transport, Error.t()}}
def run(%Command{} = command, opts \\ []) when is_list(opts) do
case ExecutionSurface.resolve(opts) do
{:ok, %{dispatch: dispatch, adapter_options: adapter_options} = resolved} ->
with :ok <- validate_run_capabilities(resolved, command) do
dispatch.run.(command, adapter_options)
end
{:error, reason} ->
transport_error(reason)
end
end
@doc """
Sends data to the subprocess stdin.
"""
@spec send(t(), iodata() | map() | list()) :: :ok | {:error, {:transport, Error.t()}}
def send(transport, message) when is_pid(transport) do
case safe_call(transport, {:send, message}) do
{:ok, result} -> result
{:error, reason} -> transport_error(reason)
end
end
@doc """
Subscribes the caller in legacy mode.
"""
@spec subscribe(t(), pid()) :: :ok | {:error, {:transport, Error.t()}}
def subscribe(transport, pid) when is_pid(transport) and is_pid(pid) do
subscribe(transport, pid, :legacy)
end
@doc """
Subscribes a process with an explicit tag mode.
"""
@spec subscribe(t(), pid(), subscription_tag()) :: :ok | {:error, {:transport, Error.t()}}
def subscribe(transport, pid, tag)
when is_pid(transport) and is_pid(pid) and (tag == :legacy or is_reference(tag)) do
case safe_call(transport, {:subscribe, pid, tag}) do
{:ok, result} -> result
{:error, reason} -> transport_error(reason)
end
end
def subscribe(_transport, _pid, tag) do
transport_error(Error.invalid_options({:invalid_subscriber, tag}))
end
@doc """
Removes a subscriber.
"""
@spec unsubscribe(t(), pid()) :: :ok
def unsubscribe(transport, pid) when is_pid(transport) and is_pid(pid) do
case safe_call(transport, {:unsubscribe, pid}) do
{:ok, :ok} -> :ok
{:error, _reason} -> :ok
end
end
@doc """
Stops the transport.
"""
@spec close(t()) :: :ok
def close(transport) when is_pid(transport) do
GenServer.stop(transport, :normal)
catch
:exit, {:noproc, _} -> :ok
:exit, :noproc -> :ok
end
@doc """
Forces the subprocess down immediately.
"""
@spec force_close(t()) :: :ok | {:error, {:transport, Error.t()}}
def force_close(transport) when is_pid(transport) do
do_force_close(transport, @default_force_close_timeout_ms)
end
@doc """
Sends SIGINT to the subprocess.
"""
@spec interrupt(t()) :: :ok | {:error, {:transport, Error.t()}}
def interrupt(transport) when is_pid(transport) do
case safe_call(transport, :interrupt) do
{:ok, result} ->
normalize_call_result(result)
{:error, reason} ->
transport_error(reason)
end
end
@doc """
Returns transport connectivity status.
"""
@spec status(t()) :: :connected | :disconnected | :error
def status(transport) when is_pid(transport) do
case safe_call(transport, :status) do
{:ok, status} when status in [:connected, :disconnected, :error] -> status
{:ok, _other} -> :error
{:error, _reason} -> :disconnected
end
end
@doc """
Closes stdin for EOF-driven CLIs.
Pipe-backed transports send `:eof`; PTY-backed transports send the terminal
EOF byte (`Ctrl-D`).
"""
@spec end_input(t()) :: :ok | {:error, {:transport, Error.t()}}
def end_input(transport) when is_pid(transport) do
case safe_call(transport, :end_input) do
{:ok, result} -> result
{:error, reason} -> transport_error(reason)
end
end
@doc """
Returns the stderr ring buffer tail.
"""
@spec stderr(t()) :: binary()
def stderr(transport) when is_pid(transport) do
case safe_call(transport, :stderr) do
{:ok, data} when is_binary(data) -> data
_ -> ""
end
end
@doc """
Returns the current transport metadata snapshot.
"""
@spec info(t()) :: Info.t()
def info(transport) when is_pid(transport) do
case safe_call(transport, :info) do
{:ok, %Info{} = info} -> info
_other -> Info.disconnected()
end
end
defp validate_start_capabilities(
%{adapter_capabilities: %Capabilities{} = capabilities, surface: surface},
opts
)
when is_list(opts) do
[
{capabilities.supports_streaming_stdio?, :streaming_stdio},
{not Keyword.get(opts, :pty?, false) or capabilities.supports_pty?, :pty},
{not has_user?(opts) or capabilities.supports_user?, :user},
{not has_env?(opts) or capabilities.supports_env?, :env},
{not has_cwd?(opts) or capabilities.supports_cwd?, :cwd}
]
|> validate_family_capabilities(surface.surface_kind)
end
defp validate_run_capabilities(
%{adapter_capabilities: %Capabilities{} = capabilities, surface: surface},
%Command{} = command
) do
[
{capabilities.supports_run?, :run},
{not has_user?(command) or capabilities.supports_user?, :user},
{not has_env?(command) or capabilities.supports_env?, :env},
{not has_cwd?(command) or capabilities.supports_cwd?, :cwd}
]
|> validate_family_capabilities(surface.surface_kind)
end
defp validate_family_capabilities(checks, surface_kind) when is_list(checks) do
Enum.reduce_while(checks, :ok, fn {allowed?, capability}, :ok ->
case require_family_capability(allowed?, capability, surface_kind) do
:ok -> {:cont, :ok}
error -> {:halt, error}
end
end)
end
defp require_family_capability(true, _capability, _surface_kind), do: :ok
defp require_family_capability(false, capability, surface_kind) do
transport_error(Error.unsupported_capability(capability, surface_kind))
end
defp has_user?(opts) when is_list(opts) do
case Keyword.get(opts, :user) do
user when is_binary(user) and user != "" -> true
_other -> false
end
end
defp has_user?(%Command{user: user}) when is_binary(user) and user != "", do: true
defp has_user?(%Command{}), do: false
defp has_env?(opts) when is_list(opts) do
clear_env? = Keyword.get(opts, :clear_env?, false)
case Keyword.get(opts, :env, %{}) do
env when is_map(env) -> map_size(env) > 0 or clear_env?
env when is_list(env) -> env != [] or clear_env?
_other -> clear_env?
end
end
defp has_env?(%Command{env: env, clear_env?: clear_env?}) when is_map(env),
do: map_size(env) > 0 or clear_env?
defp has_env?(%Command{}), do: false
defp has_cwd?(opts) when is_list(opts) do
case Keyword.get(opts, :cwd) do
cwd when is_binary(cwd) and cwd != "" -> true
_other -> false
end
end
defp has_cwd?(%Command{cwd: cwd}) when is_binary(cwd) and cwd != "", do: true
defp has_cwd?(%Command{}), do: false
@doc """
Extracts a normalized transport event from a legacy mailbox message.
Tagged subscribers should use `extract_event/2` so their code does not depend
on a specific outer event tag.
"""
@spec extract_event(term()) :: {:ok, extracted_event()} | :error
def extract_event({:transport_message, line}) when is_binary(line), do: {:ok, {:message, line}}
def extract_event({:transport_data, chunk}) when is_binary(chunk), do: {:ok, {:data, chunk}}
def extract_event({:transport_error, %Error{} = error}), do: {:ok, {:error, error}}
def extract_event({:transport_stderr, chunk}) when is_binary(chunk), do: {:ok, {:stderr, chunk}}
def extract_event({:transport_exit, %ProcessExit{} = exit}), do: {:ok, {:exit, exit}}
def extract_event(_message), do: :error
@doc """
Extracts a normalized transport event for a tagged subscriber reference.
This is the stable core-owned way for adapters to consume tagged transport
delivery without hard-coding the configured outer event atom.
"""
@spec extract_event(term(), reference()) :: {:ok, extracted_event()} | :error
def extract_event({event_tag, ref, event}, ref) when is_atom(event_tag) do
extract_tagged_event(event)
end
def extract_event(message, _ref), do: extract_event(message)
@doc """
Returns stable mailbox-delivery metadata for the current transport snapshot.
"""
@spec delivery_info(t()) :: Delivery.t()
def delivery_info(transport) do
case info(transport) do
%Info{delivery: %Delivery{} = delivery} -> delivery
_other -> Delivery.new(:external_runtime_transport)
end
end
defp extract_tagged_event({:message, line}) when is_binary(line), do: {:ok, {:message, line}}
defp extract_tagged_event({:data, chunk}) when is_binary(chunk), do: {:ok, {:data, chunk}}
defp extract_tagged_event({:error, %Error{} = error}), do: {:ok, {:error, error}}
defp extract_tagged_event({:stderr, chunk}) when is_binary(chunk), do: {:ok, {:stderr, chunk}}
defp extract_tagged_event({:exit, %ProcessExit{} = exit}), do: {:ok, {:exit, exit}}
defp extract_tagged_event(_event), do: :error
defp safe_call(transport, message, timeout \\ @default_call_timeout_ms)
defp safe_call(transport, message, timeout)
when is_pid(transport) and is_integer(timeout) and timeout >= 0 do
case TaskSupport.async_nolink(fn ->
try do
{:ok, GenServer.call(transport, message, :infinity)}
catch
:exit, reason -> {:error, normalize_call_exit(reason)}
end
end) do
{:ok, task} ->
await_task_result(task, timeout)
{:error, reason} ->
{:error, normalize_call_task_start_error(reason)}
end
end
defp await_task_result(task, timeout) do
case TaskSupport.await(task, timeout, :brutal_kill) do
{:ok, result} -> result
{:exit, reason} -> {:error, normalize_call_exit(reason)}
{:error, :timeout} -> {:error, Error.timeout()}
end
end
defp normalize_call_task_start_error(:noproc), do: Error.transport_stopped()
defp normalize_call_task_start_error(reason), do: Error.call_exit(reason)
defp normalize_call_exit({:noproc, _}), do: Error.not_connected()
defp normalize_call_exit(:noproc), do: Error.not_connected()
defp normalize_call_exit({:normal, _}), do: Error.not_connected()
defp normalize_call_exit({:shutdown, _}), do: Error.not_connected()
defp normalize_call_exit({:timeout, _}), do: Error.timeout()
defp normalize_call_exit(reason), do: Error.call_exit(reason)
defp normalize_call_result(:ok), do: :ok
defp normalize_call_result({:error, {:transport, %Error{}}} = error), do: error
defp normalize_call_result({:error, %Error{} = error}), do: transport_error(error)
defp normalize_call_result({:error, reason}), do: transport_error(reason)
defp normalize_call_result(other),
do: transport_error(Error.transport_error({:unexpected_task_result, other}))
defp do_force_close(transport, timeout_ms)
when is_pid(transport) and is_integer(timeout_ms) and timeout_ms >= 0 do
GenServer.stop(transport, :normal, timeout_ms)
:ok
catch
:exit, reason ->
transport_error(normalize_force_close_exit(reason))
end
defp normalize_force_close_exit({:noproc, _}), do: Error.not_connected()
defp normalize_force_close_exit(:noproc), do: Error.not_connected()
defp normalize_force_close_exit({:normal, _}), do: Error.not_connected()
defp normalize_force_close_exit({:shutdown, _}), do: Error.not_connected()
defp normalize_force_close_exit({:timeout, {GenServer, :stop, _}}), do: Error.timeout()
defp normalize_force_close_exit({:timeout, _}), do: Error.timeout()
defp normalize_force_close_exit(reason), do: Error.call_exit(reason)
defp transport_error({:transport, %Error{}} = error), do: {:error, error}
defp transport_error(%Error{} = error), do: {:error, {:transport, error}}
defp transport_error(reason), do: {:error, {:transport, Error.transport_error(reason)}}
end