Bash.Session (Bash v0.6.0)

Copy Markdown View Source

Session GenServer for maintaining Bash execution context.

Each session maintains its own environment variables, working directory, and I/O context for executing Bash commands.

VariableDescription
BASH_VERSION"5.3" Version information for this Bash.
CDPATHA colon-separated list of directories to search for directories given as arguments to cd.
GLOBIGNOREA colon-separated list of patterns describing filenames to be ignored by pathname expansion.
HISTFILEThe name of the file where your command history is stored.
HISTFILESIZEThe maximum number of lines this file can contain.
HISTSIZEThe maximum number of history lines that a running shell can access.
HOMEThe complete pathname to your login directory.
HOSTNAMEThe name of the current host.
HOSTTYPEThe type of CPU this version of Bash is running under.
IGNOREEOFControls the action of the shell on receipt of an EOF character as the sole input. If set, then the value of it is the number of EOF characters that can be seen in a row on an empty line before the shell will exit (default 10). When unset, EOF signifies the end of input.
MACHTYPEA string describing the current system Bash is running on.
MAILCHECKHow often, in seconds, Bash checks for new mail.
MAILPATH(Unsupported) A colon-separated list of filenames which Bash checks for new mail.
OSTYPEThe version of Unix this version of Bash is running on.
PATHA colon-separated list of directories to search when looking for commands.
PROMPT_COMMAND(Unsupported) A command to be executed before the printing of each primary prompt.
PS1(Unsupported) The primary prompt string.
PS2(Unsupported) The secondary prompt string.
PWDThe full pathname of the current directory.
SHELLOPTSA colon-separated list of enabled shell options.
TERM(Always set to "dumb") The name of the current terminal type.
TIMEFORMATThe output format for timing statistics displayed by the time reserved word.
auto_resume(Unsupported) Non-null means a command word appearing on a line by itself is first looked for in the list of currently stopped jobs. If found there, that job is foregrounded. A value of exact means that the command word must exactly match a command in the list of stopped jobs. A value of substring means that the command word must match a substring of the job. Any other value means that the command must be a prefix of a stopped job.
histchars(Unsupported) Characters controlling history expansion and quick substitution. The first character is the history substitution character, usually !. The second is the quick substitution character, usually ^. The third is the history comment character, usually #.

| HISTIGNORE | A colon-separated list of patterns used to decide which commands should be saved on the history list. |

Summary

Functions

Awaits the result of an execution started with execute_async/3.

Send job to background (resume if stopped).

Begin a new command execution with fresh StringIO streams.

Changes the working directory for the session.

Close a file descriptor.

End the current execution and move it to completed executions.

Executes a command AST within this session synchronously.

Executes a command AST asynchronously and returns a handle.

Get a specific execution by index.

Clear the accumulated output and return what was collected.

Bring job to foreground.

Gets all environment variables from the session.

Get the command history for this session.

Gets the current working directory for the session.

Gets an environment variable from the session.

Get a specific job by number.

Get accumulated output from the session's output collector.

Get the session state (for builtins that need direct access).

Gets a variable value from the session, with optional index/key for arrays.

Read a line from stdin (convenience wrapper for read/3).

Lists all running sessions.

List loaded API namespaces.

Get all jobs for this session.

Load an Elixir API module into a session.

Creates a new session with default environment.

Creates a child session that inherits state from a parent.

Open a file descriptor for reading or writing.

Open a StringIO device for stdin from a string.

Wire the previous execution's stdout to the current stdin for pipeline stages.

Get and clear completed jobs for notification display.

Write a line to stdout (convenience wrapper for write/3).

Sets an environment variable in the session.

Sends a signal to a foreground execution.

Start a background job and return its job number and OS PID.

Get the merged stderr content from all completed executions.

Get the merged stdout content from all completed executions.

Stops a session and its job supervisor.

Wait for job(s) to complete.

Write to an output destination.

Types

signal()

@type signal() ::
  :sigint
  | :sigkill
  | :sigterm
  | :sighup
  | :sigquit
  | :sigusr1
  | :sigusr2
  | :sigcont
  | :sigstop
  | :int
  | :kill
  | :term
  | :hup
  | :quit
  | :usr1
  | :usr2
  | :cont
  | :stop
  | integer()

t()

@type t() :: %Bash.Session{
  aliases: %{required(String.t()) => String.t()},
  call_stack: [
    %{
      line_number: pos_integer(),
      function_name: String.t(),
      source_file: String.t()
    }
  ],
  call_timeout: timeout(),
  command_history: [Bash.CommandResult.t()],
  command_policy: Bash.CommandPolicy.t(),
  completed_jobs: [Bash.Job.t()],
  current: Bash.Execution.t() | nil,
  current_execution: running_execution() | nil,
  current_function_name: term(),
  current_job: pos_integer() | nil,
  dir_stack: [String.t()],
  elixir_modules: %{required(String.t()) => module()},
  executions: [Bash.Execution.t()],
  file_descriptors: %{
    required(non_neg_integer()) => pid() | {:coproc, pid(), :read | :write}
  },
  filesystem: Bash.Filesystem.t(),
  functions: %{required(String.t()) => Bash.AST.Function.t()},
  hash: %{required(String.t()) => {pos_integer(), String.t()}},
  id: String.t(),
  in_function: boolean(),
  in_loop: boolean(),
  in_trap: boolean(),
  is_pipeline_tail: boolean(),
  job_supervisor: pid() | nil,
  jobs: %{required(pos_integer()) => pid()},
  local_vars: MapSet.t(String.t()),
  next_job_number: pos_integer(),
  options: %{required(atom()) => boolean()},
  output_collector: pid() | nil,
  pending_executions: [pending_execution()],
  pipe_stdin: term(),
  positional_params: [[String.t()]],
  previous_job: pos_integer() | nil,
  saved_vars: [map()],
  signal_jobs_fn: term(),
  special_vars: %{required(String.t()) => integer() | String.t() | nil},
  start_background_job_fn: term(),
  start_runtime_ms: term(),
  stderr: pid() | nil,
  stderr_sink: Bash.Sink.t() | nil,
  stdin: pid() | nil,
  stdin_device: pid() | nil,
  stdout: pid() | nil,
  stdout_sink: Bash.Sink.t() | nil,
  traps: %{required(String.t()) => String.t() | :ignore},
  variables: %{required(String.t()) => Bash.Variable.t()},
  working_dir: String.t()
}

Functions

await(exec_ref, timeout \\ :infinity)

@spec await(Bash.Session.ExecRef.t(), timeout()) ::
  {:ok | :error | :exit | :exec, Bash.CommandResult.t()}
  | {:error, :timeout | {:session_down, term()}}

Awaits the result of an execution started with execute_async/3.

Blocks until the execution completes, is cancelled, or timeout elapses.

Returns:

  • {:ok | :error | :exit | :exec, CommandResult.t()} — execution finished (or was cancelled — exit code reflects the signal: 130/143/137 with error: :cancelled).

  • {:error, :timeout} — timeout exceeded; the execution is still in flight or queued. Call again later or signal/2 to cancel.
  • {:error, {:session_down, reason}} — the session GenServer crashed while this execution was outstanding.

Examples

{:ok, exec_ref} = Session.execute_async(session, ast)

# Wait indefinitely
{:ok, result} = Session.await(exec_ref)

# Wait at most 5 seconds; cancel and try again on timeout
case Session.await(exec_ref, 5_000) do
  {:error, :timeout} ->
    Session.signal(exec_ref, :sigint)
    Session.await(exec_ref)

  result ->
    result
end

background_job(session, job_spec \\ nil)

@spec background_job(pid(), pos_integer() | nil) :: :ok | {:error, term()}

Send job to background (resume if stopped).

begin_execution(session, command, opts \\ [])

@spec begin_execution(t(), String.t(), keyword()) :: t()

Begin a new command execution with fresh StringIO streams.

Creates a new Execution struct with separate stdout/stderr streams and sets it as the current execution.

Options

  • :pipeline_tail - Whether this command is at the end of a pipeline (default: true). Only pipeline tail commands forward to user sinks.

Examples

session = Session.begin_execution(session, "echo hello")
session = Session.begin_execution(session, "cat", pipeline_tail: false)

chdir(session, path)

Changes the working directory for the session.

close_fd(session, fd)

@spec close_fd(t(), non_neg_integer()) :: t()

Close a file descriptor.

Examples

session = Session.close_fd(session, 3)

end_execution(session, opts)

@spec end_execution(
  t(),
  keyword()
) :: t()

End the current execution and move it to completed executions.

Marks the execution with the given exit code and timestamp, then appends it to the executions list.

Options

  • :exit_code - The exit code for the execution (default: 0)

Examples

session = Session.end_execution(session, exit_code: 0)
session = Session.end_execution(session, exit_code: 1)

execute(session, ast, opts \\ [])

@spec execute(pid(), Bash.AST.t(), keyword()) :: term()

Executes a command AST within this session synchronously.

Blocks until the command completes and returns the result.

Options

  • :on_output - Callback function for streaming output. When provided, output is streamed to the callback as it arrives instead of being accumulated in the result. The callback receives {:stdout, binary} or {:stderr, binary} tuples.

Examples

# Standard execution (accumulates output)
{:ok, session} = Session.new()
{:ok, result} = Session.execute(session, ast)

# Streaming execution (output flows to callback)
{:ok, session} = Session.new()
{:ok, result} = Session.execute(session, ast, on_output: fn
  {:stdout, data} -> IO.write(data)
  {:stderr, data} -> IO.write(:stderr, data)
end)

execute_async(session, ast, opts \\ [])

@spec execute_async(pid(), Bash.AST.t(), keyword()) :: {:ok, Bash.Session.ExecRef.t()}

Executes a command AST asynchronously and returns a handle.

Returns {:ok, exec_ref} immediately. Pass the exec_ref to await/2 to retrieve the result, or to signal/2 to interrupt the execution.

Bash semantics allow only one foreground execution per session at a time. If another execution is already running, this call queues behind it; the queued execution starts as soon as the current one finishes (or is cancelled). State (variables, traps, working directory) flows from one chunk to the next, so two execute_async/3 calls behave like appending AST chunks to a single sequential script — but each chunk has its own awaitable handle and can be cancelled independently.

Examples

{:ok, session} = Session.new()
{:ok, exec_ref} = Session.execute_async(session, ast)
{:ok, result} = Session.await(exec_ref)

# Cancel an in-flight execution; user-defined `trap` handlers run.
{:ok, exec_ref} = Session.execute_async(session, long_running_ast)
:ok = Session.signal(exec_ref, :sigint)
{:error, %CommandResult{exit_code: 130}} = Session.await(exec_ref)

# Queue multiple chunks; each runs after the previous one completes.
{:ok, ref1} = Session.execute_async(session, parsed("echo first"))
{:ok, ref2} = Session.execute_async(session, parsed("echo second"))
{:ok, _} = Session.await(ref1)
{:ok, _} = Session.await(ref2)

# Cancel a queued chunk before it runs:
{:ok, ref3} = Session.execute_async(session, parsed("never"))
:ok = Session.signal(ref3, :sigint)
{:error, %CommandResult{exit_code: 130}} = Session.await(ref3)

execution(session, index)

@spec execution(t(), non_neg_integer()) :: Bash.Execution.t() | nil

Get a specific execution by index.

Examples

exec = Session.execution(session, 0)
Execution.stdout_contents(exec)

flush_output(session)

@spec flush_output(pid()) :: {String.t(), String.t()}

Clear the accumulated output and return what was collected.

Useful for tests that want to run multiple commands and check output after each.

foreground_job(session, job_spec \\ nil)

@spec foreground_job(pid(), pos_integer() | nil) ::
  {:ok, Bash.CommandResult.t()} | {:error, term()}

Bring job to foreground.

Blocks until the job completes and returns a CommandResult.

get_all_env(session)

Gets all environment variables from the session.

get_command_history(session)

@spec get_command_history(pid()) :: [Bash.CommandResult.t()]

Get the command history for this session.

get_cwd(session)

Gets the current working directory for the session.

get_env(session, key)

Gets an environment variable from the session.

get_job(session, job_number)

@spec get_job(pid(), pos_integer()) :: {:ok, Bash.Job.t()} | {:error, :not_found}

Get a specific job by number.

get_output(session)

@spec get_output(pid()) :: {String.t(), String.t()}

Get accumulated output from the session's output collector.

Returns {stdout, stderr} tuple with all output captured during execution. This is the primary way to retrieve output in tests.

Examples

{:ok, session} = Session.new()
{:ok, _, _} = Bash.run(~BASH"echo hello", session)
{stdout, stderr} = Session.get_output(session)
assert stdout =~ "hello"

get_state(session)

@spec get_state(pid()) :: t()

Get the session state (for builtins that need direct access).

get_var(session, var_name)

Gets a variable value from the session, with optional index/key for arrays.

Retrieves the session state and delegates to Variable.get/2 or Variable.get/3.

Examples

Session.get_var(session, "myvar")
Session.get_var(session, "myarray", 0)
Session.get_var(session, "myassoc", "key")

get_var(session, var_name, index_or_key)

gets(session, opts \\ [])

@spec gets(
  t(),
  keyword()
) :: {:ok, String.t()} | :eof | {:error, term()}

Read a line from stdin (convenience wrapper for read/3).

Options

  • :source - Source to read from (default: :stdin)
  • :delimiter - Line delimiter (default: "\n")

Examples

{:ok, line} = Session.gets(session)
{:ok, line} = Session.gets(session, source: {:fd, 3})

list(opts \\ [])

@spec list(keyword()) :: [{String.t(), pid()}]

Lists all running sessions.

Returns a list of tuples containing the session ID and pid.

Options

  • :registry - The registry to query (default: Bash.SessionRegistry)

Examples

iex> {:ok, session} = Bash.Session.new()
iex> sessions = Bash.Session.list()
iex> Enum.any?(sessions, fn {_id, pid} -> pid == session end)
true

list_apis(session)

@spec list_apis(pid() | t()) :: [String.t()]

List loaded API namespaces.

list_jobs(session)

@spec list_jobs(pid()) :: [Bash.Job.t()]

Get all jobs for this session.

load_api(session, module)

@spec load_api(pid(), module()) :: :ok

Load an Elixir API module into a session.

The module must use Bash.Interop and define a namespace. Once loaded, functions defined with defbash become callable from bash scripts as namespace.function_name.

Examples

# Load into a running session (recommended)
{:ok, session} = Session.new()
:ok = Session.load_api(session, MyApp.BashAPI)

# Or load at session creation
{:ok, session} = Session.new(apis: [MyApp.BashAPI])

# Now myapp.* functions are available in bash scripts

new(opts \\ [])

Creates a new session with default environment.

Options

  • :id — unique session identifier (auto-generated if omitted)

  • :working_dir — initial working directory (defaults to File.cwd!())

  • :env — map of environment variables to set (e.g., %{"HOME" => "/root"})

  • :env_include — list of host env vars to include (defaults to all)

  • :env_exclude — list of host env vars to exclude

  • :options — shell options map (e.g., %{hashall: true, braceexpand: true})

  • :command_policy — %CommandPolicy{} struct or keyword list for building one. Controls which commands the session can execute across all categories (builtins, externals, functions, interop). See Bash.CommandPolicy.

    # Block all external commands
    Bash.Session.new(command_policy: [commands: :no_external])
    
    # Allow only builtins and specific externals
    Bash.Session.new(command_policy: [commands: [{:allow, [:builtins, "cat", "grep"]}]])
    
    # Allow builtins and functions, block externals and interop
    Bash.Session.new(command_policy: [commands: [{:allow, [:builtins, :functions]}]])
    
    # Deny specific commands
    Bash.Session.new(command_policy: [commands: [{:disallow, ["rm"]}, {:allow, :all}]])
  • :apis — list of Bash.Interop modules to load at creation

  • :aliases — map of command aliases

  • :functions — map of pre-defined bash functions

  • :script_name — value of $0 (defaults to "bash")

  • :args — positional parameters ($1, $2, etc.)

  • :call_timeout — timeout for GenServer calls (defaults to :infinity)

new_child(parent, opts \\ [])

@spec new_child(
  t() | pid(),
  keyword()
) :: {:ok, pid()} | {:error, term()}

Creates a child session that inherits state from a parent.

The child session inherits (per bash behavior):

  • Environment variables
  • Working directory
  • Functions
  • Shell options

The child session does NOT inherit (per bash behavior):

  • Aliases
  • Hash table (command path cache)

The child session gets its own:

  • Job supervisor and job table
  • Session ID

This is used for subshell execution where changes to env/cwd should not propagate back to the parent.

open_fd(session, fd, path, modes)

@spec open_fd(t(), non_neg_integer(), String.t(), [atom()]) ::
  {:ok, t()} | {:error, term()}

Open a file descriptor for reading or writing.

Examples

session = Session.open_fd(session, 3, "/path/to/file", [:read])
session = Session.open_fd(session, 4, "/path/to/output", [:write])

open_stdin(session, content)

@spec open_stdin(t(), String.t()) :: t()

Open a StringIO device for stdin from a string.

Useful for providing initial input to a session or pipeline.

Examples

session = Session.open_stdin(session, "line1\nline2\n")

pipe_forward(session)

@spec pipe_forward(t()) :: t()

Wire the previous execution's stdout to the current stdin for pipeline stages.

Takes the stdout content from the last completed execution and creates a new StringIO device for reading as the next command's stdin.

Examples

# After cmd1 completes:
session = Session.pipe_forward(session)
# Now stdin reads from cmd1's stdout

pop_completed_jobs(session)

@spec pop_completed_jobs(pid()) :: [Bash.Job.t()]

Get and clear completed jobs for notification display.

puts(session, data)

@spec puts(t(), iodata()) :: t()

Write a line to stdout (convenience wrapper for write/3).

Appends a newline to the data.

Examples

session = Session.puts(session, "hello")  # writes "hello\n"

read(session, source \\ :stdin, mode \\ :line)

@spec read(t(), :stdin | {:fd, non_neg_integer()}, :line | :all | non_neg_integer()) ::
  {:ok, String.t()} | :eof | {:error, term()}

Read from an input source.

Sources

  • :stdin - Read from session's stdin
  • {:fd, n} - Read from file descriptor n

Modes

  • :line - Read a single line (default)
  • :all - Read all available content
  • n when is_integer(n) - Read n bytes

Examples

{:ok, line} = Session.read(session, :stdin, :line)
{:ok, all} = Session.read(session, :stdin, :all)
{:ok, chunk} = Session.read(session, {:fd, 3}, 1024)

set_env(session, key, value)

Sets an environment variable in the session.

signal(session_or_ref, sig, opts \\ [])

@spec signal(pid() | Bash.Session.ExecRef.t(), signal(), keyword()) ::
  :ok | {:error, :not_found}

Sends a signal to a foreground execution.

Targeting

  • Pass a session pid() — signals the currently running foreground execution, if any. Pending (queued) executions are unaffected.
  • Pass an ExecRef.t() — signals that specific execution, whether it's currently running or still queued.

Returns :ok if a signal was delivered, {:error, :not_found} if no matching execution is in flight.

Signals

  • :sigint — cooperative cancel. The script's INT and EXIT traps run (with output captured in the session's collector); exit code 130.
  • :sigterm — cooperative cancel. The script's TERM and EXIT traps run; exit code 143.
  • :sigkill — untrappable hard kill. No traps run. Use this when traps could hang. Exit code 137.

Cancelling a queued (not-yet-running) execution removes it from the queue and produces an {:error, %CommandResult{exit_code: ..., error: :cancelled}} via await/2. No traps run because the script never executed.

For signaling background jobs (started via & or job control), use signal_job/3.

Options

  • :grace — milliseconds to wait for a cooperative :sigint/:sigterm to land before escalating to :sigkill. Useful when a script may be stuck in a non-yielding builtin or a misbehaving trap. Ignored for :sigkill and for queued (not-running) executions.

Examples

# Signal a specific execution by ref
{:ok, exec_ref} = Session.execute_async(session, long_ast)
:ok = Session.signal(exec_ref, :sigint)
{:error, %CommandResult{exit_code: 130}} = Session.await(exec_ref)

# Signal whatever is running by session pid (no ref needed)
:ok = Session.signal(session, :sigterm)

# Hard kill — bypasses traps
:ok = Session.signal(exec_ref, :sigkill)

# Cooperative cancel with automatic escalation to :sigkill after 5s
:ok = Session.signal(exec_ref, :sigint, grace: 5_000)

# Cancel a queued execution before it runs
{:ok, ref1} = Session.execute_async(session, current_ast)
{:ok, ref2} = Session.execute_async(session, queued_ast)
:ok = Session.signal(ref2, :sigint)
{:error, %CommandResult{exit_code: 130}} = Session.await(ref2)

signal_job(session, job_spec, signal)

@spec signal_job(pid(), pos_integer(), atom() | integer()) :: :ok | {:error, term()}

Send signal to job.

start_background_job(session, opts)

@spec start_background_job(
  pid(),
  keyword()
) ::
  {:ok, job_number :: pos_integer(), os_pid :: pos_integer() | nil}
  | {:error, term()}

Start a background job and return its job number and OS PID.

Options

  • :command - Command name to execute (required)
  • :args - List of arguments (default: [])

start_link(opts)

stderr(session, opts \\ [])

@spec stderr(
  t(),
  keyword()
) :: Enumerable.t() | String.t()

Get the merged stderr content from all completed executions.

Options

  • :index - Get stderr from a specific execution by index

Examples

# All stderr as a stream
Session.stderr(session) |> Enum.to_list()

# Specific execution's stderr
Session.stderr(session, index: 0)

stdout(session, opts \\ [])

@spec stdout(
  t(),
  keyword()
) :: Enumerable.t() | String.t()

Get the merged stdout content from all completed executions.

Options

  • :index - Get stdout from a specific execution by index

Examples

# All stdout as a stream
Session.stdout(session) |> Enum.to_list()

# Specific execution's stdout
Session.stdout(session, index: 0)

stop(session)

@spec stop(pid()) :: :ok

Stops a session and its job supervisor.

wait_for_jobs(session, job_specs \\ nil)

@spec wait_for_jobs(pid(), [pos_integer()] | nil) ::
  {:ok, [integer()]} | {:error, term()}

Wait for job(s) to complete.

write(session, arg2, data)

@spec write(t(), :stdout | :stderr | {:fd, non_neg_integer()}, iodata()) :: t()

Write to an output destination.

Writes to the current execution's StringIO stream. If the session is at the pipeline tail and has user-provided sinks, also forwards to those.

Destinations

  • :stdout - Write to stdout
  • :stderr - Write to stderr
  • {:fd, n} - Write to file descriptor n

Examples

session = Session.write(session, :stdout, "hello\n")
session = Session.write(session, :stderr, "error message\n")
session = Session.write(session, {:fd, 3}, data)