Module: SafeExec
- Defined in:
- lib/stack-service-base/safe_exec.rb
Overview
SafeExec provides two related safety wrappers for Unix-like systems:
-
Run a Ruby block in an isolated fork with a hard timeout.
-
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_resultreturn values must be Marshal-serializable because data is sent from the child process to the parent over a pipe. -
captureyields streamed output as ‘|stream, chunk|`, wherestreamis:stdoutor:stderr.
Defined Under Namespace
Classes: CommandResult, Error, ExitError, SerializationError, SpawnError, StepResult, TimeoutError
Constant Summary collapse
- TERM_GRACE_SECONDS =
5
Class Method Summary collapse
-
.call(timeout:, &block) ⇒ Object
Execute a Ruby block in a forked subprocess.
-
.call!(timeout:, &block) ⇒ Object
Bang alias for
call. -
.call_result(timeout:, &block) ⇒ Object
Execute a Ruby block in a forked subprocess and return a structured result.
-
.capture(*cmd, timeout:, &block) ⇒ Object
Run an external command with timeout control and full output capture.
-
.capture!(*cmd, timeout:, &block) ⇒ Object
Raising variant of
capture. - .run(*cmd, timeout:, &block) ⇒ Object
- .run!(*cmd, timeout:, &block) ⇒ Object
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., 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.
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((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) |