Skip to content

Commit 45bd7d3

Browse files
committed
feat: add SafeExec and update release templates
- added `SafeExec` in `lib/stack-service-base/safe_exec.rb` with `call`/`call_result` and `capture`/`capture!` APIs for fork isolation, timeouts, and process-group termination
1 parent 1026012 commit 45bd7d3

6 files changed

Lines changed: 365 additions & 3 deletions

File tree

Gemfile.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
stack-service-base (0.0.91)
4+
stack-service-base (0.0.93)
55
async
66
debug
77
dotenv

lib/stack-service-base.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
require 'stack-service-base/debugger'
1313
require 'stack-service-base/async_helpers'
1414
require 'stack-service-base/socket_trace'
15+
require 'stack-service-base/safe_exec'
1516

1617
module StackServiceBase
1718
class << self

lib/stack-service-base/project_template/gitlab-c/.gitlab-ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ stages:
77
needs: [ ]
88
image: docker:27.5.1
99
script:
10+
- export CI_COMMIT_TAG="${CI_COMMIT_TAG:-0.0.0}"
1011
- docker run --rm `env | grep -o '^CI_[^=]*' | sed 's/^/-e /'`
1112
-v /var/run/docker.sock:/var/run/docker.sock
1213
-v /root/.docker:/root/.docker
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11

2-
Refer to ~.codex/shared/AGENTS.md for the actual instructions.
2+
Refer to ~/.codex/shared/AGENTS.md for the actual instructions.
3+
Refer to ~/.codex/shared/skills for the list actual skills.
Lines changed: 359 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,359 @@
1+
# SafeExec provides two related safety wrappers for Unix-like systems:
2+
# 1. Run a Ruby block in an isolated fork with a hard timeout.
3+
# 2. Run an external command with captured output, streaming callbacks,
4+
# process-group cleanup, and optional raising "bang" variants.
5+
#
6+
# Design notes:
7+
# - Timeouts are enforced with a monotonic clock.
8+
# - Child work is placed into its own process group so timeout handling can
9+
# terminate the whole subtree, not just the direct child.
10+
# - `call`/`call_result` return values must be Marshal-serializable because
11+
# data is sent from the child process to the parent over a pipe.
12+
# - `capture` yields streamed output as `|stream, chunk|`, where `stream` is
13+
# `:stdout` or `:stderr`.
14+
module SafeExec
15+
TERM_GRACE_SECONDS = 5
16+
17+
# Result of block execution via `call_result`.
18+
#
19+
# Fields:
20+
# - ok: true when the child block completed without raising
21+
# - result: block return value, if execution succeeded
22+
# - exception: reconstructed exception object, if any
23+
# - timed_out: true when the child exceeded the deadline
24+
StepResult = Struct.new(:ok, :result, :exception, :timed_out, keyword_init: true) do
25+
def success? = ok && !timed_out && exception.nil?
26+
def timed_out? = !!timed_out
27+
end
28+
29+
# Result of external command execution via `capture`.
30+
#
31+
# Fields:
32+
# - stdout: collected standard output
33+
# - stderr: collected standard error
34+
# - status: Process::Status for completed commands
35+
# - timed_out: true when the process group was terminated on deadline
36+
# - exception: wrapper-level error, typically spawn/setup failure
37+
CommandResult = Struct.new(:stdout, :stderr, :status, :timed_out, :exception, keyword_init: true) do
38+
def success? = !timed_out && exception.nil? && status&.success?
39+
def timed_out? = !!timed_out
40+
def exitstatus = status&.exitstatus
41+
def out = stdout
42+
def err = stderr
43+
end
44+
45+
class Error < StandardError
46+
attr_reader :result
47+
48+
def initialize(message = nil, result: nil)
49+
super(message)
50+
@result = result
51+
end
52+
end
53+
54+
class TimeoutError < Error; end
55+
class SpawnError < Error; end
56+
class ExitError < Error; end
57+
class SerializationError < Error; end
58+
59+
module_function
60+
61+
def ensure_open3_loaded
62+
return if defined?(Open3)
63+
64+
require 'open3'
65+
end
66+
private_class_method :ensure_open3_loaded if respond_to?(:private_class_method)
67+
68+
def ensure_timeout_loaded
69+
return if defined?(Timeout::Error)
70+
71+
require 'timeout'
72+
end
73+
private_class_method :ensure_timeout_loaded if respond_to?(:private_class_method)
74+
75+
# Execute a Ruby block in a forked subprocess.
76+
#
77+
# Returns the block result on success.
78+
# Raises the reconstructed child exception or TimeoutError on failure.
79+
def call(timeout:, &block)
80+
ensure_timeout_loaded
81+
result = call_result(timeout:, &block)
82+
raise result.exception if result.exception
83+
84+
result.result
85+
end
86+
87+
# Bang alias for `call`.
88+
def call!(timeout:, &block) = call(timeout:, &block)
89+
90+
# Execute a Ruby block in a forked subprocess and return a structured result.
91+
#
92+
# This is the non-raising API for isolated block execution.
93+
# The block return value must be Marshal-serializable.
94+
def call_result(timeout:, &block)
95+
ensure_timeout_loaded
96+
reader, writer = IO.pipe
97+
pid = fork do
98+
reader.close
99+
Process.setpgrp
100+
101+
result = block.call
102+
dump_payload(writer, step_payload(result:))
103+
rescue => e
104+
dump_payload(writer, step_payload(exception: e))
105+
ensure
106+
writer.close unless writer.closed?
107+
exit! 0
108+
end
109+
110+
writer.close
111+
timed_out = wait_or_terminate(pid, timeout)
112+
if timed_out
113+
return StepResult.new(
114+
ok: false,
115+
timed_out: true,
116+
exception: TimeoutError.new("Timed out after #{timeout}s")
117+
)
118+
end
119+
120+
payload = Marshal.load(reader)
121+
StepResult.new(
122+
ok: payload[:ok],
123+
result: payload[:result],
124+
exception: build_exception(payload),
125+
timed_out: false
126+
)
127+
rescue EOFError => e
128+
StepResult.new(ok: false, exception: e, timed_out: false)
129+
ensure
130+
reader.close if reader && !reader.closed?
131+
end
132+
133+
# Run an external command with timeout control and full output capture.
134+
#
135+
# When a block is given, chunks are yielded as they arrive:
136+
# capture("bash", "-lc", "echo hi; echo warn >&2") { |stream, chunk| ... }
137+
#
138+
# Yields:
139+
# - stream: :stdout or :stderr
140+
# - chunk: String
141+
#
142+
# Returns CommandResult and does not raise for non-zero exit status.
143+
def capture(*cmd, timeout:, &block)
144+
ensure_open3_loaded
145+
stdout = +''
146+
stderr = +''
147+
status = nil
148+
149+
Open3.popen3(*cmd, pgroup: true) do |stdin, child_stdout, child_stderr, wait_thr|
150+
stdin.close
151+
pid = wait_thr.pid
152+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
153+
154+
until wait_thr.join(0)
155+
drain_pair(child_stdout, stdout, :stdout, &block)
156+
drain_pair(child_stderr, stderr, :stderr, &block)
157+
158+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
159+
terminate_process_group(pid)
160+
drain_all(child_stdout, stdout, :stdout, &block)
161+
drain_all(child_stderr, stderr, :stderr, &block)
162+
return CommandResult.new(stdout:, stderr:, timed_out: true)
163+
end
164+
165+
IO.select([child_stdout, child_stderr], nil, nil, 0.1)
166+
end
167+
168+
drain_all(child_stdout, stdout, :stdout, &block)
169+
drain_all(child_stderr, stderr, :stderr, &block)
170+
status = wait_thr.value
171+
end
172+
173+
CommandResult.new(stdout:, stderr:, status:, timed_out: false)
174+
rescue => e
175+
CommandResult.new(stdout:, stderr:, exception: SpawnError.new(e.message, result: nil), timed_out: false)
176+
end
177+
178+
# Raising variant of `capture`.
179+
#
180+
# Raises:
181+
# - TimeoutError on timeout
182+
# - SpawnError on wrapper-level execution/setup failure
183+
# - ExitError on non-zero exit status
184+
#
185+
# Returns CommandResult on success.
186+
def capture!(*cmd, timeout:, &block)
187+
ensure_timeout_loaded
188+
result = capture(*cmd, timeout:, &block)
189+
raise TimeoutError.new("Timed out after #{timeout}s", result:) if result.timed_out?
190+
raise result.exception if result.exception
191+
raise ExitError.new(exit_error_message(cmd, result), result:) unless result.status&.success?
192+
193+
result
194+
end
195+
196+
def run(*cmd, timeout:, &block) = capture(*cmd, timeout:, &block)
197+
def run!(*cmd, timeout:, &block) = capture!(*cmd, timeout:, &block)
198+
199+
# Marshal the child payload back to the parent process.
200+
# If the block result cannot be serialized, convert it into a structured error
201+
# so the parent still receives a useful failure.
202+
def dump_payload(writer, payload)
203+
Marshal.dump(payload, writer)
204+
rescue TypeError => e
205+
serializable = step_payload(
206+
exception: SerializationError.new("Result is not serializable: #{e.message}")
207+
)
208+
Marshal.dump(serializable, writer) rescue nil
209+
end
210+
private_class_method :dump_payload if respond_to?(:private_class_method)
211+
212+
def step_payload(result: nil, exception: nil)
213+
{
214+
ok: exception.nil?,
215+
result: result,
216+
exception_class: exception&.class&.name,
217+
exception_message: exception&.message,
218+
exception_backtrace: exception&.backtrace
219+
}
220+
end
221+
private_class_method :step_payload if respond_to?(:private_class_method)
222+
223+
# Rebuild a best-effort exception object in the parent process from a payload
224+
# received over Marshal.
225+
def build_exception(payload)
226+
return nil unless payload[:exception_message]
227+
228+
klass_name = payload[:exception_class].to_s
229+
klass = klass_name.empty? ? RuntimeError : Object.const_get(klass_name)
230+
exception = klass.new(payload[:exception_message])
231+
exception.set_backtrace(Array(payload[:exception_backtrace])) if exception.respond_to?(:set_backtrace)
232+
exception
233+
rescue NameError
234+
exception = RuntimeError.new(payload[:exception_message])
235+
exception.set_backtrace(Array(payload[:exception_backtrace])) if exception.respond_to?(:set_backtrace)
236+
exception
237+
end
238+
private_class_method :build_exception if respond_to?(:private_class_method)
239+
240+
# Drain as much currently available data as possible from one pipe without
241+
# blocking, append it to the target buffer, and optionally stream it to the
242+
# caller-supplied block.
243+
def drain_pair(io, buffer, stream)
244+
loop do
245+
chunk = io.read_nonblock(4096, exception: false)
246+
case chunk
247+
when String
248+
buffer << chunk
249+
yield stream, chunk if block_given?
250+
when :wait_readable, nil
251+
return
252+
end
253+
end
254+
rescue IOError, Errno::EIO
255+
nil
256+
end
257+
private_class_method :drain_pair if respond_to?(:private_class_method)
258+
259+
# Drain the remainder of a finished stream and optionally yield it.
260+
def drain_all(io, buffer, stream)
261+
chunk = io.read
262+
return unless chunk
263+
264+
buffer << chunk
265+
yield stream, chunk if block_given?
266+
rescue IOError, Errno::EIO
267+
nil
268+
end
269+
private_class_method :drain_all if respond_to?(:private_class_method)
270+
271+
# Wait for a child process until the deadline, then terminate the whole
272+
# process group if it is still running.
273+
def wait_or_terminate(pid, timeout)
274+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
275+
276+
loop do
277+
waited_pid, = Process.waitpid2(pid, Process::WNOHANG)
278+
return false if waited_pid
279+
break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
280+
sleep 0.2
281+
end
282+
283+
terminate_process_group(pid)
284+
true
285+
end
286+
private_class_method :wait_or_terminate if respond_to?(:private_class_method)
287+
288+
# Graceful shutdown policy:
289+
# - send TERM to the child process group
290+
# - wait up to TERM_GRACE_SECONDS
291+
# - send KILL if anything is still alive
292+
def terminate_process_group(pid)
293+
Process.kill('TERM', -pid)
294+
rescue Errno::ESRCH
295+
return
296+
ensure
297+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + TERM_GRACE_SECONDS
298+
loop do
299+
waited_pid, = Process.waitpid2(pid, Process::WNOHANG)
300+
return if waited_pid
301+
break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
302+
sleep 0.2
303+
end
304+
305+
begin
306+
Process.kill('KILL', -pid)
307+
rescue Errno::ESRCH
308+
nil
309+
end
310+
311+
begin
312+
Process.waitpid(pid)
313+
rescue Errno::ECHILD
314+
nil
315+
end
316+
end
317+
private_class_method :terminate_process_group if respond_to?(:private_class_method)
318+
319+
# Build an informative error message for `capture!` failures.
320+
def exit_error_message(cmd, result)
321+
parts = []
322+
parts << "Command failed: #{cmd.join(' ')}"
323+
parts << "exit status #{result.exitstatus}" if result.exitstatus
324+
parts.join(' with ')
325+
end
326+
private_class_method :exit_error_message if respond_to?(:private_class_method)
327+
end
328+
329+
if $PROGRAM_NAME == __FILE__
330+
puts '[self-test] capture with streaming'
331+
result = SafeExec.capture('bash', '-lc', 'echo out; echo err >&2', timeout: 2) do |stream, chunk|
332+
puts " #{stream}: #{chunk.inspect}"
333+
end
334+
puts " success?: #{result.success?}"
335+
puts " stdout: #{result.stdout.inspect}"
336+
puts " stderr: #{result.stderr.inspect}"
337+
338+
puts '[self-test] capture! exit error'
339+
begin
340+
SafeExec.capture!('bash', '-lc', 'echo boom >&2; exit 7', timeout: 2)
341+
rescue SafeExec::ExitError => e
342+
puts " exit_error: #{e.message}"
343+
puts " stderr: #{e.result.stderr.inspect}"
344+
end
345+
346+
puts '[self-test] call data result'
347+
data = SafeExec.call(timeout: 2) { { ok: true, items: [1, 2, 3] } }
348+
puts " data: #{data.inspect}"
349+
350+
puts '[self-test] call timeout'
351+
begin
352+
SafeExec.call(timeout: 1) do
353+
sleep 2
354+
true
355+
end
356+
rescue SafeExec::TimeoutError, Timeout::Error => e
357+
puts " timeout: #{e.class}: #{e.message}"
358+
end
359+
end

lib/stack-service-base/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
module StackServiceBase
2-
VERSION = '0.0.92'
2+
VERSION = '0.0.93'
33
end

0 commit comments

Comments
 (0)