Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,5 @@ erl_crash.dump
bash-*.tar

.grepai/
.claude/settings.local.json
docs/plans
3 changes: 2 additions & 1 deletion lib/bash/ast/command.ex
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,8 @@ defmodule Bash.AST.Command do

@doc false
def process_input_redirects(redirects, _session_state, default_stdin)
when redirects in [nil, []], do: default_stdin
when redirects in [nil, []],
do: default_stdin

def process_input_redirects(redirects, session_state, default_stdin) do
redirects
Expand Down
2 changes: 1 addition & 1 deletion lib/bash/ast/coproc.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ defmodule Bash.AST.Coproc do
the coproc array variable and PID variable use that name. Otherwise,
the default name "COPROC" is used.

Simple commands are executed via `ExCmd.Process` (external OS process).
Simple commands are executed via `CommandPort` (external OS process).
Compound commands are executed within the Elixir bash interpreter in a
spawned BEAM process with message-passing I/O.

Expand Down
11 changes: 10 additions & 1 deletion lib/bash/ast/helpers.ex
Original file line number Diff line number Diff line change
Expand Up @@ -1379,9 +1379,18 @@ defmodule Bash.AST.Helpers do
_result = Executor.execute(ast, subst_session, nil)

# Extract stdout from the temporary collector
{stdout_iodata, _stderr_iodata} = OutputCollector.flush_split(temp_collector)
{stdout_iodata, stderr_iodata} = OutputCollector.flush_split(temp_collector)
GenServer.stop(temp_collector, :normal)

stderr_output = IO.iodata_to_binary(stderr_iodata)

if stderr_output != "" do
case Map.get(session_state, :stderr_sink) do
sink when is_function(sink) -> sink.({:stderr, stderr_output})
_ -> :ok
end
end

# Convert iodata to string and trim trailing newline (bash behavior)
IO.iodata_to_binary(stdout_iodata)
|> String.trim_trailing("\n")
Expand Down
52 changes: 43 additions & 9 deletions lib/bash/ast/pipeline.ex
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ defmodule Bash.AST.Pipeline do
alias Bash.AST
alias Bash.AST.Helpers
alias Bash.Builtin
alias Bash.CommandPort
alias Bash.Executor
alias Bash.Session
alias Bash.OutputCollector
alias Bash.Sink
alias Bash.Variable
Expand Down Expand Up @@ -83,7 +85,7 @@ defmodule Bash.AST.Pipeline do
end

# Execute a mixed pipeline by segmenting into external command runs and non-external commands.
# External segments stream via ExCmd. Non-external commands execute individually.
# External segments stream via CommandPort. Non-external commands execute individually.
# Only accumulates at builtin boundaries that require stdin.
defp execute_mixed(
%__MODULE__{commands: commands, negate: negate, meta: meta} = pipeline,
Expand Down Expand Up @@ -291,9 +293,18 @@ defmodule Bash.AST.Pipeline do
end

# Extract output from the temporary collector
{stdout_iodata, _stderr_iodata} = OutputCollector.flush_split(temp_collector)
{stdout_iodata, stderr_iodata} = OutputCollector.flush_split(temp_collector)
GenServer.stop(temp_collector, :normal)

stderr_output = IO.iodata_to_binary(stderr_iodata)

if stderr_output != "" do
case Map.get(session_state, :stderr_sink) do
sink when is_function(sink) -> sink.({:stderr, stderr_output})
_ -> :ok
end
end

output = IO.iodata_to_binary(stdout_iodata)
{exit_code, env_updates} = result

Expand Down Expand Up @@ -488,9 +499,18 @@ defmodule Bash.AST.Pipeline do
{result.exit_code || 0, %{}}
end

{stdout_iodata, _stderr_iodata} = OutputCollector.flush_split(temp_collector)
{stdout_iodata, stderr_iodata} = OutputCollector.flush_split(temp_collector)
GenServer.stop(temp_collector, :normal)

stderr_output = IO.iodata_to_binary(stderr_iodata)

if stderr_output != "" do
case Map.get(session_state, :stderr_sink) do
sink when is_function(sink) -> sink.({:stderr, stderr_output})
_ -> :ok
end
end

output = IO.iodata_to_binary(stdout_iodata)
{exit_code, env_updates} = result

Expand All @@ -514,6 +534,8 @@ defmodule Bash.AST.Pipeline do

# Check if a single command is external (not a builtin or function) and has no redirects.
# Commands with redirects need sequential execution to handle the redirect logic.
defp external_command?(_command, %{options: %{restricted: true}}), do: false

defp external_command?(%AST.Command{name: name, redirects: redirects}, session_state) do
# Commands with redirects can't use simple streaming
if redirects != [] do
Expand Down Expand Up @@ -622,24 +644,30 @@ defmodule Bash.AST.Pipeline do
end
end

# Build nested ExCmd.stream calls from innermost (first command) to outermost (last command)
# Build nested stream pipeline from innermost (first command) to outermost (last command)
defp build_stream_pipeline([cmd], stdin, session_state) do
{name, args, env} = resolve_external_command(cmd, session_state)
restricted = Session.restricted?(session_state)

ExCmd.stream([name | args],
opts = [
input: stdin,
cd: session_state.working_dir,
env: env,
stderr: :redirect_to_stdout
)
]

case CommandPort.stream([name | args], opts, restricted) do
{:error, :restricted} -> Stream.map([], & &1)
stream -> stream
end
end

defp build_stream_pipeline([cmd | rest], stdin, session_state) do
# Build upstream first (inner stream)
upstream = build_stream_pipeline(rest, stdin, session_state)

# Filter out exit tuples - only pass binary data to downstream command
# ExCmd.stream yields {:exit, exit_info} as last element which can't be used as input
# CommandPort.stream yields {:exit, exit_info} as last element which can't be used as input
filtered_upstream =
Stream.filter(upstream, fn
{:exit, _} -> false
Expand All @@ -648,13 +676,19 @@ defmodule Bash.AST.Pipeline do
end)

{name, args, env} = resolve_external_command(cmd, session_state)
restricted = Session.restricted?(session_state)

ExCmd.stream([name | args],
opts = [
input: filtered_upstream,
cd: session_state.working_dir,
env: env,
stderr: :redirect_to_stdout
)
]

case CommandPort.stream([name | args], opts, restricted) do
{:error, :restricted} -> Stream.map([], & &1)
stream -> stream
end
end

# Resolve command name, args, and environment from AST
Expand Down
9 changes: 8 additions & 1 deletion lib/bash/ast/while_loop.ex
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,14 @@ defmodule Bash.AST.WhileLoop do

%AST.Redirect{direction: :input, target: {:file, file_word}} ->
# Read from file (including process substitution results via /dev/fd/N)
file_path = Bash.AST.Helpers.word_to_string(file_word, session_state)
file_path =
file_word
|> Bash.AST.Helpers.word_to_string(session_state)
|> then(fn p ->
if Path.type(p) == :relative,
do: Path.join(session_state.working_dir, p),
else: p
end)

case File.read(file_path) do
{:ok, content} -> content
Expand Down
8 changes: 7 additions & 1 deletion lib/bash/builtin/command.ex
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ defmodule Bash.Builtin.Command do
use Bash.Builtin

alias Bash.Builtin
alias Bash.CommandPort
alias Bash.Session
alias Bash.Variable

# Standard utilities path that is guaranteed to find all standard utilities
Expand Down Expand Up @@ -282,7 +284,11 @@ defmodule Bash.Builtin.Command do
]

try do
case System.cmd(path, args, cmd_opts) do
case CommandPort.system_cmd(path, args, cmd_opts, Session.restricted?(state)) do
{:error, :restricted} ->
error("bash: #{path}: restricted")
{:ok, 1}

{stdout, exit_code} ->
write(stdout)
{:ok, exit_code}
Expand Down
21 changes: 11 additions & 10 deletions lib/bash/builtin/coproc.ex
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ defmodule Bash.Builtin.Coproc do
```mermaid
stateDiagram-v2
[*] --> running: start_link external
running --> running: read/write via ExCmd.Process
running --> running: read/write via CommandPort
running --> closing: close_stdin
closing --> stopped: process exits
running --> stopped: process exits
Expand Down Expand Up @@ -65,6 +65,7 @@ defmodule Bash.Builtin.Coproc do

require Logger

alias Bash.CommandPort
alias Bash.Executor
alias Bash.Variable

Expand Down Expand Up @@ -263,10 +264,10 @@ defmodule Bash.Builtin.Coproc do
stderr: :redirect_to_stdout
]

case ExCmd.Process.start_link(cmd, proc_opts) do
case CommandPort.start_link(cmd, proc_opts, false) do
{:ok, pid} ->
os_pid =
case ExCmd.Process.os_pid(pid) do
case CommandPort.os_pid(pid) do
{:ok, os_pid} -> os_pid
os_pid when is_integer(os_pid) -> os_pid
end
Expand Down Expand Up @@ -326,7 +327,7 @@ defmodule Bash.Builtin.Coproc do
end

def handle_call(:read, _from, %{mode: :external} = state) do
case ExCmd.Process.read(state.proc) do
case CommandPort.read(state.proc) do
{:ok, data} -> {:reply, {:ok, data}, state}
:eof -> {:reply, :eof, state}
{:error, reason} -> {:reply, {:error, reason}, state}
Expand All @@ -341,7 +342,7 @@ defmodule Bash.Builtin.Coproc do
end

def handle_call({:write, data}, _from, %{mode: :external} = state) do
case ExCmd.Process.write(state.proc, data) do
case CommandPort.write(state.proc, data) do
:ok -> {:reply, :ok, state}
{:error, reason} -> {:reply, {:error, reason}, state}
end
Expand All @@ -355,7 +356,7 @@ defmodule Bash.Builtin.Coproc do
end

def handle_call(:close_stdin, _from, %{mode: :external} = state) do
ExCmd.Process.close_stdin(state.proc)
CommandPort.close_stdin(state.proc)
{:reply, :ok, state}
end

Expand All @@ -365,7 +366,7 @@ defmodule Bash.Builtin.Coproc do
end

def handle_call(:close_stdout, _from, %{mode: :external} = state) do
ExCmd.Process.close_stdout(state.proc)
CommandPort.close_stdout(state.proc)
{:reply, :ok, state}
end

Expand All @@ -374,7 +375,7 @@ defmodule Bash.Builtin.Coproc do
end

def handle_call(:status, _from, %{mode: :external} = state) do
case ExCmd.Process.await_exit(state.proc, 0) do
case CommandPort.await_exit(state.proc, 0) do
{:ok, exit_code} -> {:reply, {:exited, exit_code}, state}
{:error, :timeout} -> {:reply, :running, state}
{:error, reason} -> {:reply, {:error, reason}, state}
Expand Down Expand Up @@ -409,8 +410,8 @@ defmodule Bash.Builtin.Coproc do
@impl true
def terminate(_reason, %{mode: :external} = state) do
if state.proc do
ExCmd.Process.close_stdin(state.proc)
ExCmd.Process.await_exit(state.proc, 1000)
CommandPort.close_stdin(state.proc)
CommandPort.await_exit(state.proc, 1000)
end

:ok
Expand Down
Loading