Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,66 @@ Bash.stdout(result)
#=> "/tmp\n"
```

### Async Execution & Cancellation

`Bash.Session.execute_async/3` returns a handle so you can cancel a long-running
script — including running any user-defined `trap` handlers before exit.

```elixir
{:ok, session} = Bash.Session.new()
{:ok, ast} = Bash.Parser.parse("""
trap 'echo cleanup' EXIT
while true; do echo working; done
""")

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

# ... later, from anywhere with the session pid or the ref ...
:ok = Bash.Session.signal(exec_ref, :sigint)

{:error, result} = Bash.Session.await(exec_ref, 5_000)
result.exit_code
#=> 130

# `cleanup` was emitted by the EXIT trap before the script exited
Bash.Session.get_output(session) |> elem(0)
#=> "working\n...working\ncleanup\n"
```

**Signals:**

* `:sigint` — cooperative cancel, runs `INT` then `EXIT` traps, exit 130
* `:sigterm` — cooperative cancel, runs `TERM` then `EXIT` traps, exit 143
* `:sigkill` — untrappable hard kill, exit 137 (use when traps may hang)

**Grace period:** Pass `grace: ms` to escalate a cooperative cancel to
`:sigkill` if it doesn't land within the window. Useful when a script may
be stuck in a non-yielding operation.

```elixir
:ok = Bash.Session.signal(exec_ref, :sigint, grace: 5_000)
# If INT cancel hasn't landed within 5s, sigkill is sent automatically.
```

**Queueing:** Calling `execute_async/3` while another execution is in flight
queues the new one. Each chunk gets its own `ExecRef` and runs sequentially with
shared session state (variables, traps, cwd). To cancel a queued execution
before it runs, signal its ref — it's removed from the queue and `await/2`
returns the matching error result.

```elixir
{:ok, ref1} = Bash.Session.execute_async(session, slow_ast)
{:ok, ref2} = Bash.Session.execute_async(session, follow_up_ast)
{:ok, ref3} = Bash.Session.execute_async(session, never_run_ast)

:ok = Bash.Session.signal(ref3, :sigint) # cancel before it runs
{:error, %{exit_code: 130}} = Bash.Session.await(ref3)

# ref1 still running, ref2 still queued — both proceed normally
{:ok, _} = Bash.Session.await(ref1, 30_000)
{:ok, _} = Bash.Session.await(ref2, 30_000)
```

### Elixir Interop

Define Elixir functions callable from Bash:
Expand Down
1 change: 1 addition & 0 deletions lib/bash/ast/for_loop.ex
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ defmodule Bash.AST.ForLoop do
end

defp execute_for_loop([item | rest], var_name, body, session_state, env_acc, count, _last_exit) do
Executor.check_cancel(session_state)
# Set the loop variable in the session state with accumulated updates
new_variables =
Map.merge(
Expand Down
2 changes: 2 additions & 0 deletions lib/bash/ast/helpers.ex
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ defmodule Bash.AST.Helpers do
options: acc_options
}

Executor.check_cancel(stmt_session)

case Executor.execute(stmt, stmt_session, nil) do
{:ok, result, updates} ->
variables_from_stmt = Map.get(updates, :variables, %{})
Expand Down
2 changes: 2 additions & 0 deletions lib/bash/ast/while_loop.ex
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,8 @@ defmodule Bash.AST.WhileLoop do
iteration,
effective_stdin
) do
Executor.check_cancel(session_state)

# Safety check to prevent infinite loops
if iteration >= @max_loop_iterations do
# Write error to stderr sink
Expand Down
25 changes: 25 additions & 0 deletions lib/bash/builtin/trap.ex
Original file line number Diff line number Diff line change
Expand Up @@ -423,4 +423,29 @@ defmodule Bash.Builtin.Trap do
def get_return_trap(session_state) do
get_trap(session_state, "RETURN")
end

# Run the trap registered for the given signal name (e.g. "INT", "EXIT").
# No-op if no trap is set, the trap is :ignore, the signal name is invalid,
# or the session is already inside a trap (the :in_trap flag prevents
# recursion). Returns :ok regardless of trap success.
@doc false
@spec run(Bash.Session.t(), String.t()) :: :ok
def run(%{in_trap: true}, _signal_name), do: :ok

def run(session_state, signal_name) do
session_state
|> get_trap(signal_name)
|> do_run(session_state)
end

defp do_run(nil, _state), do: :ok
defp do_run(:ignore, _state), do: :ok

defp do_run(command, state) when is_binary(command) do
with {:ok, ast} <- Bash.Parser.parse(command) do
Bash.AST.Helpers.execute_body(ast.statements, %{state | in_trap: true}, %{})
end

:ok
end
end
21 changes: 21 additions & 0 deletions lib/bash/executor.ex
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,25 @@ defmodule Bash.Executor do
def execute(token, _session_state, _stdin, _opts) do
{:error, {:invalid_ast, token}}
end

@doc """
Yield to a pending cooperative cancel.

Loop iterators call this at the start of each iteration with the current
threaded session state. If the session has signalled this Task with
`{:cancel, signal}` (via `Bash.Session.signal/2` with `:sigint`/`:sigterm`),
this throws `{:cancelled, signal, state}` so the Task closure can run any
matching trap (using the current traps map) and return a cancellation
result. Returns `:ok` when no cancel is pending.
"""
@spec check_cancel(map()) :: :ok | no_return()
def check_cancel(%{in_trap: true}), do: :ok

def check_cancel(state) do
receive do
{:cancel, signal} -> throw({:cancelled, signal, state})
after
0 -> :ok
end
end
end
33 changes: 3 additions & 30 deletions lib/bash/script.ex
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,9 @@ defmodule Bash.Script do
"""

alias Bash.AST
alias Bash.AST.Helpers
alias Bash.AST.Pipeline
alias Bash.Builtin.Trap
alias Bash.Executor
alias Bash.Parser
alias Bash.Variable

@type separator :: {:separator, String.t()}
Expand Down Expand Up @@ -247,34 +245,7 @@ defmodule Bash.Script do
defp mark_executed(%{exit_code: _} = stmt, code), do: %{stmt | exit_code: code}
defp mark_executed(stmt, _code), do: stmt

# Execute EXIT trap if one is set
# The EXIT trap runs when the shell exits (script finishes)
defp execute_exit_trap(session_state) do
# Skip if already executing a trap (prevent infinite recursion)
if Map.get(session_state, :in_trap, false) do
:ok
else
case Trap.get_exit_trap(session_state) do
nil ->
:ok

:ignore ->
:ok

trap_command when is_binary(trap_command) ->
# Parse and execute the trap command
case Parser.parse(trap_command) do
{:ok, ast} ->
# Execute trap with in_trap flag to prevent recursion
trap_session = Map.put(session_state, :in_trap, true)
Helpers.execute_body(ast.statements, trap_session, %{})

{:error, _, _, _} ->
:ok
end
end
end
end
defp execute_exit_trap(session_state), do: Trap.run(session_state, "EXIT")

# Execute statements sequentially, accumulating results
defp execute_statements([], _session_state, executed, last_exit_code, output, updates) do
Expand Down Expand Up @@ -321,6 +292,8 @@ defmodule Bash.Script do
# In noexec mode, read but don't execute - continue with exit code 0
execute_statements(rest, session_state, [stmt | executed], 0, output, updates)
else
Executor.check_cancel(updated_session)

case Executor.execute(stmt, updated_session, nil) do
{:ok, executed_stmt, stmt_updates} ->
new_output = output ++ extract_output(executed_stmt)
Expand Down
Loading