BUCKYVERSE

Date: 2025-03-01

Status

Accepted

Context

Our ebook conversion system relies heavily on external command-line tools such as LaTeX, Pandoc, Calibre, and others. In our current Ruby implementation, we use the CommandRunner and CommandExecutor classes to handle command execution, with features like:

  1. Error handling and logging
  2. Timeout management
  3. Working directory control
  4. Environment variable management
  5. Retry logic for flaky commands
  6. Platform-specific command handling

As we migrate to Elixir, we need to design a robust command execution pattern that:

  1. Leverages Elixir's concurrency model
  2. Provides proper error handling and recovery
  3. Integrates with our supervision strategy (ADR-0021)
  4. Supports timeout and cancellation
  5. Handles platform-specific commands
  6. Provides comprehensive logging and telemetry

Decision

We will implement a command execution pattern in Elixir with the following components:

1. Core Command Execution Module

defmodule BookProcessing.System.CommandExecutor do
  @moduledoc """
  Handles execution of system commands with proper error handling,
  timeout management, and logging.
  """
  
  @doc """
  Executes a system command and returns the result.
  
  ## Options
  
  * `:timeout` - Timeout in milliseconds (default: 30000)
  * `:cd` - Working directory for the command
  * `:env` - Environment variables as a map
  * `:return_on_error` - Return error output instead of raising (default: false)
  
  ## Returns
  
  * `{:ok, output}` - Command executed successfully
  * `{:error, reason}` - Command failed and return_on_error is true
  
  ## Raises
  
  * `BookProcessing.System.CommandError` - Command failed and return_on_error is false
  """
  def execute(command, opts \\ [])
end

2. Command Execution GenServer

defmodule BookProcessing.System.CommandRunner do
  @moduledoc """
  GenServer for managing command execution with additional features
  like retry logic, circuit breaking, and telemetry.
  """
  use GenServer
  
  # Client API
  
  @doc """
  Starts the CommandRunner.
  """
  def start_link(opts \\ [])
  
  @doc """
  Runs a command with the given options.
  """
  def run(command, opts \\ [])
  
  @doc """
  Runs a command with retry logic.
  """
  def run_with_retry(command, opts \\ [])
  
  @doc """
  Checks if a command exists in the system PATH.
  """
  def command_exists?(command)
  
  # Server callbacks and implementation
end

3. Port-Based Command Execution

For long-running commands or those requiring interactive input/output:

defmodule BookProcessing.System.PortCommand do
  @moduledoc """
  Handles execution of commands via Erlang ports, allowing
  for streaming output and interactive communication.
  """
  
  @doc """
  Executes a command via a port and returns a stream of output.
  """
  def stream_command(command, opts \\ [])
  
  @doc """
  Executes a command via a port with the ability to send input.
  """
  def interactive_command(command, opts \\ [])
end

4. Platform-Specific Command Handling

defmodule BookProcessing.System.PlatformCommands do
  @moduledoc """
  Handles platform-specific command execution.
  """
  
  @doc """
  Executes a platform-specific command based on the current OS.
  
  ## Examples
  
      iex> PlatformCommands.run(%{
      ...>   darwin: "open -a Preview file.pdf",
      ...>   linux: "xdg-open file.pdf",
      ...>   windows: "start file.pdf"
      ...> })
  """
  def run(commands, opts \\ [])
  
  @doc """
  Returns the current platform identifier (:darwin, :linux, :windows).
  """
  def current_platform
end

5. Command Execution Supervisor

defmodule BookProcessing.System.Supervisor do
  @moduledoc """
  Supervises command execution processes.
  """
  use Supervisor
  
  def start_link(init_arg) do
    Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
  end
  
  @impl true
  def init(_init_arg) do
    children = [
      {BookProcessing.System.CommandRunner, []},
      {DynamicSupervisor, name: BookProcessing.System.CommandSupervisor, strategy: :one_for_one}
    ]
    
    Supervisor.init(children, strategy: :one_for_one)
  end
end

6. Command Execution Telemetry

defmodule BookProcessing.System.CommandTelemetry do
  @moduledoc """
  Telemetry events for command execution.
  """
  
  @doc """
  Emits telemetry events for command execution.
  """
  def emit_event(event, measurements, metadata)
  
  # Event definitions
  def start_event, do: [:book_processing, :command, :start]
  def stop_event, do: [:book_processing, :command, :stop]
  def error_event, do: [:book_processing, :command, :error]
end

7. Error Handling

defmodule BookProcessing.System.CommandError do
  @moduledoc """
  Error raised when a command fails.
  """
  defexception [:message, :command, :output, :exit_code]
  
  @doc """
  Creates a new CommandError.
  """
  def new(command, output, exit_code) do
    %__MODULE__{
      message: "Command failed with exit code #{exit_code}: #{command}",
      command: command,
      output: output,
      exit_code: exit_code
    }
  end
end

Implementation Example

Basic Command Execution

defmodule BookProcessing.System.CommandExecutor do
  require Logger
  
  def execute(command, opts \\ []) do
    timeout = Keyword.get(opts, :timeout, 30_000)
    cd = Keyword.get(opts, :cd)
    env = Keyword.get(opts, :env, %{})
    return_on_error = Keyword.get(opts, :return_on_error, false)
    
    Logger.debug("Executing command: #{command}")
    
    # Prepare options for System.cmd
    cmd_opts = [
      stderr_to_stdout: true,
      into: "",
      cd: cd,
      env: env
    ]
    
    # Add timeout if specified
    cmd_opts = if timeout, do: [{:timeout, timeout} | cmd_opts], else: cmd_opts
    
    # Execute the command
    try do
      {cmd, args} = parse_command(command)
      {output, exit_code} = System.cmd(cmd, args, cmd_opts)
      
      if exit_code == 0 do
        Logger.debug("Command succeeded: #{command}")
        {:ok, String.trim(output)}
      else
        Logger.error("Command failed (#{exit_code}): #{command}\nOutput: #{output}")
        
        if return_on_error do
          {:error, %{output: output, exit_code: exit_code}}
        else
          raise BookProcessing.System.CommandError.new(command, output, exit_code)
        end
      end
    rescue
      e in [ErlangError] ->
        case e do
          %ErlangError{original: :timeout} ->
            Logger.error("Command timed out after #{timeout}ms: #{command}")
            
            if return_on_error do
              {:error, :timeout}
            else
              raise BookProcessing.System.CommandError.new(command, "Timed out after #{timeout}ms", :timeout)
            end
          _ ->
            reraise e, __STACKTRACE__
        end
    end
  end
  
  defp parse_command(command) when is_binary(command) do
    case String.split(command, " ", parts: 2) do
      [cmd] -> {cmd, []}
      [cmd, args] -> {cmd, String.split(args, " ")}
    end
  end
end

Command Runner GenServer

defmodule BookProcessing.System.CommandRunner do
  use GenServer
  require Logger
  alias BookProcessing.System.CommandExecutor
  alias BookProcessing.System.CommandTelemetry
  
  # Client API
  
  def start_link(opts \\ []) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end
  
  def run(command, opts \\ []) do
    GenServer.call(__MODULE__, {:run, command, opts}, :infinity)
  end
  
  def run_with_retry(command, opts \\ []) do
    max_retries = Keyword.get(opts, :max_retries, 3)
    delay = Keyword.get(opts, :delay, 1000)
    backoff = Keyword.get(opts, :backoff, 2.0)
    
    GenServer.call(__MODULE__, {:run_with_retry, command, opts, max_retries, delay, backoff}, :infinity)
  end
  
  def command_exists?(command) do
    GenServer.call(__MODULE__, {:command_exists, command})
  end
  
  # Server callbacks
  
  @impl true
  def init(opts) do
    {:ok, %{opts: opts}}
  end
  
  @impl true
  def handle_call({:run, command, opts}, _from, state) do
    start_time = System.monotonic_time()
    
    CommandTelemetry.emit_event(
      CommandTelemetry.start_event(),
      %{system_time: System.system_time()},
      %{command: command}
    )
    
    result = CommandExecutor.execute(command, opts)
    
    end_time = System.monotonic_time()
    duration = System.convert_time_unit(end_time - start_time, :native, :millisecond)
    
    case result do
      {:ok, output} ->
        CommandTelemetry.emit_event(
          CommandTelemetry.stop_event(),
          %{duration: duration},
          %{command: command, result: :success}
        )
        
        {:reply, {:ok, output}, state}
        
      {:error, reason} ->
        CommandTelemetry.emit_event(
          CommandTelemetry.error_event(),
          %{duration: duration},
          %{command: command, reason: reason}
        )
        
        {:reply, {:error, reason}, state}
    end
  end
  
  @impl true
  def handle_call({:run_with_retry, command, opts, max_retries, delay, backoff}, from, state) do
    # Implementation of retry logic
    {:reply, {:ok, "result"}, state}
  end
  
  @impl true
  def handle_call({:command_exists, command}, _from, state) do
    result = case :os.type() do
      {:unix, _} ->
        case CommandExecutor.execute("which #{command}", return_on_error: true) do
          {:ok, _} -> true
          {:error, _} -> false
        end
      {:win32, _} ->
        case CommandExecutor.execute("where #{command}", return_on_error: true) do
          {:ok, _} -> true
          {:error, _} -> false
        end
    end
    
    {:reply, result, state}
  end
end

Consequences

Positive

  1. Concurrency: Leverages Elixir's concurrency model for parallel command execution
  2. Supervision: Integrates with OTP supervision for fault tolerance
  3. Telemetry: Provides comprehensive telemetry for monitoring and debugging
  4. Error handling: Structured error handling with detailed context
  5. Timeout management: Proper handling of command timeouts
  6. Retry logic: Built-in support for retrying flaky commands
  7. Platform abstraction: Consistent interface across different platforms
  8. Cancellation: Support for cancelling long-running commands

Negative

  1. Complexity: More complex than simple System.cmd calls
  2. Resource management: Need to manage port resources carefully
  3. Error propagation: Need to ensure errors are properly propagated
  4. Testing challenges: System commands are harder to test
  5. Security considerations: Command injection risks must be addressed

Mitigation Strategies

  1. Comprehensive testing: Create thorough tests for command execution
  2. Command sanitization: Implement proper command sanitization to prevent injection
  3. Resource monitoring: Monitor port usage to prevent resource leaks
  4. Timeout defaults: Set sensible default timeouts for different command types
  5. Mock interface: Create a mock interface for testing without executing real commands
  6. Documentation: Thoroughly document command execution patterns and best practices

References