Packages
Multi-surface application runtime for Elixir. One TEA module renders to terminal, browser (LiveView), SSH, and MCP (agents). 30+ widgets, flexbox + CSS grid, AI agent runtime, distributed swarm with CRDTs, time-travel debugging, session recording, sandboxed REPL, and agentic commerce.
Current section
Files
Jump to
Current section
Files
lib/raxol/terminal/session.ex
defmodule Raxol.Terminal.Session do
@moduledoc """
Terminal session module.
This module manages terminal sessions with pure functional patterns.
REFACTORED: All try/rescue blocks replaced with functional error handling.
Features:
- Session lifecycle
- Input/output handling
- State management
- Configuration
- Session persistence and recovery
"""
use Raxol.Core.Behaviours.BaseManager
require Raxol.Core.Runtime.Log
alias Raxol.Terminal.Emulator.Struct, as: EmulatorStruct
alias Raxol.Terminal.{Renderer, ScreenBuffer}
alias Raxol.Terminal.ScreenBufferAdapter, as: ScreenBuffer
alias Raxol.Terminal.Session.Storage
@type t :: %__MODULE__{
id: String.t(),
emulator: EmulatorStruct.t(),
renderer: Raxol.Terminal.Renderer.t(),
width: non_neg_integer() | nil,
height: non_neg_integer() | nil,
title: String.t() | nil,
theme: map() | nil,
auto_save: boolean()
}
defstruct [
:id,
:emulator,
:renderer,
:width,
:height,
:title,
:theme,
auto_save: true
]
@doc """
Stops a terminal session.
## Examples
iex> {:ok, pid} = Session.start_link()
iex> :ok = Session.stop(pid)
iex> Process.alive?(pid)
false
"""
@spec stop(GenServer.server()) :: :ok
def stop(pid) do
GenServer.stop(pid)
end
@doc """
Sends input to a terminal session.
## Examples
iex> {:ok, pid} = Session.start_link()
iex> :ok = Session.send_input(pid, "test")
iex> state = Session.get_state(pid)
iex> state.input.buffer
"test"
"""
@spec send_input(GenServer.server(), String.t()) :: :ok
def send_input(pid, input) do
GenServer.cast(pid, {:input, input})
end
@doc """
Gets the current state of a terminal session.
## Examples
iex> {:ok, pid} = Session.start_link()
iex> state = Session.get_state(pid)
iex> state.width
80
"""
@spec get_state(GenServer.server()) :: t()
def get_state(pid) do
GenServer.call(pid, :get_state)
end
@doc """
Updates the configuration of a terminal session.
## Examples
iex> {:ok, pid} = Session.start_link()
iex> :ok = Session.update_config(pid, %{width: 100, height: 30})
iex> state = Session.get_state(pid)
iex> state.width
100
"""
@spec update_config(GenServer.server(), map()) :: :ok
def update_config(pid, config) do
GenServer.call(pid, {:update_config, config})
end
@doc """
Saves the current session state to persistent storage.
"""
@spec save_session(GenServer.server()) :: :ok
def save_session(pid) do
execute_save_by_environment(pid)
end
@doc """
Loads a session from persistent storage.
"""
@spec load_session(String.t()) :: {:ok, pid()} | {:error, term()}
def load_session(session_id) do
case Storage.load_session(session_id) do
{:ok, session_state} ->
start_link(
id: session_state.id,
width: session_state.width,
height: session_state.height,
title: session_state.title,
theme: session_state.theme,
auto_save: session_state.auto_save
)
{:error, reason} ->
{:error, reason}
end
end
@doc """
Lists all saved sessions.
"""
@spec list_saved_sessions() :: {:ok, [String.t()]} | {:error, term()}
def list_saved_sessions do
Storage.list_sessions()
end
@doc """
Sets whether the session should be automatically saved.
"""
@spec set_auto_save(GenServer.server(), boolean()) :: :ok
def set_auto_save(pid, enabled) do
GenServer.call(pid, {:set_auto_save, enabled})
end
@spec count_active_sessions() :: non_neg_integer()
def count_active_sessions do
# Guard against potential nil return or other issues
case Raxol.Core.GlobalRegistry.count(:sessions) do
count when is_integer(count) and count >= 0 -> count
_ -> 0
end
end
@doc false
@spec child_spec(keyword()) :: Supervisor.child_spec()
def child_spec(opts) do
%{
id: __MODULE__,
start: {__MODULE__, :start_link, [opts]},
type: :worker,
restart: :transient,
shutdown: 500
}
end
# Callbacks
@impl true
def init_manager(opts) when is_list(opts) do
# Handle keyword list options
id =
Keyword.get(opts, :id, "session_#{:erlang.unique_integer([:positive])}")
width = Keyword.get(opts, :width, 80)
height = Keyword.get(opts, :height, 24)
title = Keyword.get(opts, :title, "Raxol Session")
theme = Keyword.get(opts, :theme, :default)
auto_save = Keyword.get(opts, :auto_save, false)
init_manager({id, width, height, title, theme, auto_save})
end
def init_manager({id, width, height, title, theme, auto_save}) do
# Create emulator with explicit dimensions
scrollback_limit =
Application.get_env(:raxol, :terminal, [])
|> Keyword.get(:scrollback_lines, 1000)
emulator = EmulatorStruct.new(width, height, scrollback: scrollback_limit)
# Create a default screen buffer using safe access
screen_buffer = safe_get_screen_buffer(emulator, width, height)
# Create renderer with screen buffer
renderer = Renderer.new(screen_buffer, theme)
# Build state struct
state = %__MODULE__{
id: id,
emulator: emulator,
renderer: renderer,
width: width,
height: height,
title: title,
theme: theme,
auto_save: auto_save
}
# Register with error handling
safe_register_session(id, state)
{:ok, state}
end
@impl true
def handle_manager_cast({:input, input}, state) do
# Handle process_input with safe execution
new_state = safe_process_input(state, input)
{:noreply, new_state}
end
def handle_manager_cast(:save_session, state) do
_ =
Task.start(fn ->
safe_save_session_async(state)
end)
{:noreply, state}
end
@impl true
def handle_manager_call(:get_state, _from, state) do
{:reply, state, state}
end
def handle_manager_call({:update_config, config}, _from, state) do
new_state = update_state_from_config(state, config)
{:reply, :ok, new_state}
end
def handle_manager_call({:set_auto_save, enabled}, _from, state) do
new_state = %{state | auto_save: enabled}
{:reply, :ok, new_state}
end
def handle_manager_call(:save_session, _from, state) do
Raxol.Core.Runtime.Log.info(
"Starting save_session for session: #{state.id}"
)
result = safe_save_session_sync(state)
{:reply, result, state}
end
@impl true
def handle_manager_info(:auto_save, state) do
_ = execute_auto_save(state.auto_save, state)
# Schedule next auto-save
timer_id = System.unique_integer([:positive])
Process.send_after(self(), {:auto_save, timer_id}, :timer.minutes(5))
# Store timer_id in state if needed
{:noreply, state}
end
# Private functions
# Helper functions for if-statement elimination
defp execute_save_by_environment(pid) do
handle_save_by_env(Mix.env(), pid)
end
defp handle_save_by_env(:test, pid) do
# Use synchronous save in test environment
GenServer.call(pid, :save_session, 5000)
end
defp handle_save_by_env(_env, pid) do
# Use asynchronous save in other environments
GenServer.cast(pid, :save_session)
:ok
end
defp execute_auto_save(false, _state), do: :ok
defp execute_auto_save(true, state) do
{:ok, _pid} = Task.start(fn -> Storage.save_session(state) end)
end
defp update_state_from_config(state, config) do
%{
state
| width: Map.get(config, :width, state.width),
height: Map.get(config, :height, state.height),
title: Map.get(config, :title, state.title),
theme: Map.get(config, :theme, state.theme),
auto_save: Map.get(config, :auto_save, state.auto_save)
}
end
# Safe execution functions using Task
defp safe_get_screen_buffer(emulator, width, height) do
task =
Task.async(fn ->
# Access main buffer directly since we know new emulators default to main buffer
emulator.main_screen_buffer
end)
case Task.yield(task, 100) || Task.shutdown(task, :brutal_kill) do
{:ok, buffer} when not is_nil(buffer) ->
buffer
_ ->
ScreenBuffer.new(width, height)
end
end
defp safe_register_session(id, state) do
task =
Task.async(fn ->
Raxol.Core.GlobalRegistry.register(:sessions, id, state)
end)
case Task.yield(task, 100) || Task.shutdown(task, :brutal_kill) do
{:ok, _} ->
:ok
_ ->
Raxol.Core.Runtime.Log.error(
"Failed to register session: timeout or error"
)
:ok
end
end
defp safe_process_input(state, input) do
task =
Task.async(fn ->
case EmulatorStruct.process_input(state.emulator, input) do
{new_emulator, _output}
when is_struct(new_emulator, EmulatorStruct) ->
%{state | emulator: new_emulator}
_ ->
state
end
end)
case Task.yield(task, 1000) || Task.shutdown(task, :brutal_kill) do
{:ok, new_state} ->
new_state
_ ->
state
end
end
defp safe_save_session_async(state) do
task =
Task.async(fn ->
Storage.save_session(state)
end)
case Task.yield(task, 5000) || Task.shutdown(task, :brutal_kill) do
{:ok, :ok} ->
Raxol.Core.Runtime.Log.info("Session saved successfully: #{state.id}")
{:ok, {:error, reason}} ->
Raxol.Core.Runtime.Log.error(
"Failed to save session #{state.id}: #{inspect(reason)}"
)
_ ->
Raxol.Core.Runtime.Log.error(
"Exception saving session #{state.id}: timeout or crash"
)
end
end
defp safe_save_session_sync(state) do
Raxol.Core.Runtime.Log.info("Calling Storage.save_session...")
task =
Task.async(fn ->
Storage.save_session(state)
end)
case Task.yield(task, 5000) || Task.shutdown(task, :brutal_kill) do
{:ok, result} ->
Raxol.Core.Runtime.Log.info(
"Storage.save_session completed with result: #{inspect(result)}"
)
result
_ ->
Raxol.Core.Runtime.Log.error(
"Exception in save_session: timeout or crash"
)
{:error, :save_failed}
end
end
end