Class: Roast::CommandRunner

Inherits:
Object
  • Object
show all
Defined in:
lib/roast/command_runner.rb

Overview

The canonical way to execute shell commands in Roast.

CommandRunner is the standard command execution interface for Roast and should be used for all command invocations in this project.

Features:

  • Separate stdout/stderr capture (using Async fibers for concurrency)

  • Line-by-line streaming callbacks for custom handling

  • Optional timeout support with automatic process cleanup

  • Direct command execution (no shell by default for safety)

Note: Currently executes commands directly without shell features. Shell support (pipes, redirects, etc.) will be added in a future version.

Defined Under Namespace

Classes: CommandRunnerError, NoCommandProvidedError, TimeoutError

Class Method Summary collapse

Class Method Details

.execute(args, working_directory: nil, timeout: nil, stdin_content: nil, stdout_handler: nil, stderr_handler: nil) ⇒ Array<String, String, Process::Status>

Execute a command with optional stream handlers

: ( | Array, | ?working_directory: (Pathname | String)?, | ?timeout: (Integer | Float)?, | ?stdin_content: String?, | ?stdout_handler: (^(String) -> void)?, | ?stderr_handler: (^(String) -> void)?, | ) -> [String, String, Process::Status]

Examples:

Basic usage

stdout, stderr, status = CommandRunner.execute(["echo", "hello"])

With handlers for streaming output

CommandRunner.execute(
  ["ls", "-la"],
  stdout_handler: ->(line) { puts "[OUT] #{line}" }
)

With explicit timeout

CommandRunner.execute(["sleep", "5"], timeout: 2)  # Will timeout after 2 seconds

Parameters:

  • args (Array<String>)

    Command and arguments as an array

  • timeout (Integer, nil) (defaults to: nil)

    Timeout in seconds (default: nil, no timeout)

  • stdout_handler (Proc, nil) (defaults to: nil)

    Called for each stdout line

  • stderr_handler (Proc, nil) (defaults to: nil)

    Called for each stderr line

Returns:

  • (Array<String, String, Process::Status>)

    stdout, stderr, status



53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
# File 'lib/roast/command_runner.rb', line 53

def execute(
  args,
  working_directory: nil,
  timeout: nil,
  stdin_content: nil,
  stdout_handler: nil,
  stderr_handler: nil
)
  args.compact!
  raise NoCommandProvidedError if args.blank?

  env_overrides = ENV.each_with_object({}) do |item, obj|
    key, value = item
    obj[key.sub(/^ROAST_CMD_ENV_OVERRIDE__/, "")] = value if key.start_with?("ROAST_CMD_ENV_OVERRIDE__")
  end.compact

  stdin, stdout, stderr, wait_thread = Bundler.with_unbundled_env do
    Open3 #: as untyped
      .popen3(
        env_overrides.merge({ "PWD" => working_directory&.to_s }).compact,
        *args.map(&:to_s),
        { chdir: working_directory }.compact,
      )
  end
  stdin.puts stdin_content if stdin_content.present?
  stdin.close
  pid = wait_thread.pid

  # If timeout is specified, start a timer in a separate fiber
  timeout_task = if timeout
    Async do |task|
      task.annotate("CommandRunner Timeout Monitor")
      sleep(timeout)
      kill_process(pid) if pid
    end
  end

  # Read stdout and stderr concurrently
  stdout_content, stderr_content = Sync do |sync_task|
    sync_task.annotate("CommandRunner Process Handler")
    stdout_task = Async do |task|
      task.annotate("CommandRunner Standard Output Reader")
      buffer = "" #: String
      stdout.each_line do |line|
        buffer += line
        begin
          stdout_handler&.call(line)
        rescue => e
          Event << { warn: "stdout_handler raised: #{e.class} - #{e.message}" }
        end
      end
      buffer
    rescue IOError
      buffer
    end

    stderr_task = Async do |task|
      task.annotate("CommandRunner Standard Error Reader")
      buffer = "" #: String
      stderr.each_line do |line|
        buffer += line
        begin
          stderr_handler&.call(line)
        rescue => e
          Event << { warn: "stderr_handler raised: #{e.class} - #{e.message}" }
        end
      end
      buffer
    rescue IOError
      buffer
    end

    [stdout_task.wait, stderr_task.wait]
  end

  # Wait for the process to complete
  status = wait_thread.value

  # Cancel the timeout task if it's still running
  timeout_task&.stop

  # Check if the process was killed due to timeout
  if timeout && status.signaled? && (status.termsig == 15 || status.termsig == 9)
    raise TimeoutError, "Command timed out after #{timeout} seconds"
  end

  [stdout_content, stderr_content, status]
ensure
  # Clean up resources
  begin
    [stdout, stderr].compact.each(&:close)
  rescue
    nil
  end
  # If we haven't waited for the process yet, kill it
  if pid && wait_thread&.alive?
    Async do |task|
      task.annotate("CommandRunner Process Killer")
      kill_process(pid)
    end.wait
    wait_thread.join(1) # Give it a second to finish
  end
end