Module: SafeExec

Defined in:
lib/stack-service-base/safe_exec.rb

Overview

SafeExec provides two related safety wrappers for Unix-like systems:

  1. Run a Ruby block in an isolated fork with a hard timeout.

  2. Run an external command with captured output, streaming callbacks, process-group cleanup, and optional raising “bang” variants.

Design notes:

  • Timeouts are enforced with a monotonic clock.

  • Child work is placed into its own process group so timeout handling can terminate the whole subtree, not just the direct child.

  • call/call_result return values must be Marshal-serializable because data is sent from the child process to the parent over a pipe.

  • capture yields streamed output as ‘|stream, chunk|`, where stream is :stdout or :stderr.

Defined Under Namespace

Classes: CommandResult, Error, ExitError, SerializationError, SpawnError, StepResult, TimeoutError

Constant Summary collapse

TERM_GRACE_SECONDS =
5

Class Method Summary collapse

Class Method Details

.call(timeout:, &block) ⇒ Object

Execute a Ruby block in a forked subprocess.

Returns the block result on success. Raises the reconstructed child exception or TimeoutError on failure.



79
80
81
82
83
84
85
# File 'lib/stack-service-base/safe_exec.rb', line 79

def call(timeout:, &block)
  ensure_timeout_loaded
  result = call_result(timeout:, &block)
  raise result.exception if result.exception

  result.result
end

.call!(timeout:, &block) ⇒ Object

Bang alias for call.



88
# File 'lib/stack-service-base/safe_exec.rb', line 88

def call!(timeout:, &block) = call(timeout:, &block)

.call_result(timeout:, &block) ⇒ Object

Execute a Ruby block in a forked subprocess and return a structured result.

This is the non-raising API for isolated block execution. The block return value must be Marshal-serializable.



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
# File 'lib/stack-service-base/safe_exec.rb', line 94

def call_result(timeout:, &block)
  ensure_timeout_loaded
  reader, writer = IO.pipe
  pid = fork do
    reader.close
    Process.setpgrp

    result = block.call
    dump_payload(writer, step_payload(result:))
  rescue => e
    dump_payload(writer, step_payload(exception: e))
  ensure
    writer.close unless writer.closed?
    exit! 0
  end

  writer.close
  timed_out = wait_or_terminate(pid, timeout)
  if timed_out
    return StepResult.new(
      ok: false,
      timed_out: true,
      exception: TimeoutError.new("Timed out after #{timeout}s")
    )
  end

  payload = Marshal.load(reader)
  StepResult.new(
    ok: payload[:ok],
    result: payload[:result],
    exception: build_exception(payload),
    timed_out: false
  )
rescue EOFError => e
  StepResult.new(ok: false, exception: e, timed_out: false)
ensure
  reader.close if reader && !reader.closed?
end

.capture(*cmd, timeout:, &block) ⇒ Object

Run an external command with timeout control and full output capture.

When a block is given, chunks are yielded as they arrive:

capture("bash", "-lc", "echo hi; echo warn >&2") { |stream, chunk| ... }

Yields:

  • stream: :stdout or :stderr

  • chunk: String

Returns CommandResult and does not raise for non-zero exit status.



143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
# File 'lib/stack-service-base/safe_exec.rb', line 143

def capture(*cmd, timeout:, &block)
  ensure_open3_loaded
  stdout = +''
  stderr = +''
  status = nil

  Open3.popen3(*cmd, pgroup: true) do |stdin, child_stdout, child_stderr, wait_thr|
    stdin.close
    pid = wait_thr.pid
    deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout

    until wait_thr.join(0)
      drain_pair(child_stdout, stdout, :stdout, &block)
      drain_pair(child_stderr, stderr, :stderr, &block)

      if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
        terminate_process_group(pid)
        drain_all(child_stdout, stdout, :stdout, &block)
        drain_all(child_stderr, stderr, :stderr, &block)
        return CommandResult.new(stdout:, stderr:, timed_out: true)
      end

      IO.select([child_stdout, child_stderr], nil, nil, 0.1)
    end

    drain_all(child_stdout, stdout, :stdout, &block)
    drain_all(child_stderr, stderr, :stderr, &block)
    status = wait_thr.value
  end

  CommandResult.new(stdout:, stderr:, status:, timed_out: false)
rescue => e
  CommandResult.new(stdout:, stderr:, exception: SpawnError.new(e.message, result: nil), timed_out: false)
end

.capture!(*cmd, timeout:, &block) ⇒ Object

Raising variant of capture.

Raises:

  • TimeoutError on timeout

  • SpawnError on wrapper-level execution/setup failure

  • ExitError on non-zero exit status

Returns CommandResult on success.

Raises:



186
187
188
189
190
191
192
193
194
# File 'lib/stack-service-base/safe_exec.rb', line 186

def capture!(*cmd, timeout:, &block)
  ensure_timeout_loaded
  result = capture(*cmd, timeout:, &block)
  raise TimeoutError.new("Timed out after #{timeout}s", result:) if result.timed_out?
  raise result.exception if result.exception
  raise ExitError.new(exit_error_message(cmd, result), result:) unless result.status&.success?

  result
end

.run(*cmd, timeout:, &block) ⇒ Object



196
# File 'lib/stack-service-base/safe_exec.rb', line 196

def run(*cmd, timeout:, &block) = capture(*cmd, timeout:, &block)

.run!(*cmd, timeout:, &block) ⇒ Object



197
# File 'lib/stack-service-base/safe_exec.rb', line 197

def run!(*cmd, timeout:, &block) = capture!(*cmd, timeout:, &block)