stack-service-base 0.0.92 → 0.0.93

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fe5c7dd9ee5670570c4a543443518ead4ac1800d2e68d04cb976a792578afcd9
4
- data.tar.gz: 27604a6998f604731914d0b04cbd3fa4d4377904f133b5703654d6957c01337f
3
+ metadata.gz: d44d5ba201a8fd56ce89f29cd4918bae9b121fc011e3f480ead2b1d7b884f017
4
+ data.tar.gz: 14328155c925dfc5e8f1c71eb75b12ae7d2ae157ad160839dc130d522b8ac2a7
5
5
  SHA512:
6
- metadata.gz: 11fd84dd158e19f081c527af9c8e76ea551c87858fb2eb682ac7747e19555ed48ea73204d0174b26dab4a0a357ce1da59d6fd3e10da470a57e5c7af5225b886a
7
- data.tar.gz: cfc182e0798fc5ac69264f62ee98729bdfd5333bd9267f93c753e9613b06a716eec96ec8368cb4cd94579dcbf5c373f8c177889fcd00b711177f9c94434342a6
6
+ metadata.gz: ca55bce6c2d68ac6ac5632cc423c56e7d6353d831a2bf5020089963ba7f094bc3cba411033b190d668bf2aa36dcea205f5ba62f385b91e5421d6b515edbf1d5c
7
+ data.tar.gz: 8d9486558f823941e2a89e60133788ea6d0f4d60d99638abd3991a6fb0a0b818cd2e947a1db2364124edf7d6e277821bb76acca9b48811d5ee76db4c509afc2e
@@ -7,6 +7,7 @@ stages:
7
7
  needs: [ ]
8
8
  image: docker:27.5.1
9
9
  script:
10
+ - export CI_COMMIT_TAG="${CI_COMMIT_TAG:-0.0.0}"
10
11
  - docker run --rm `env | grep -o '^CI_[^=]*' | sed 's/^/-e /'`
11
12
  -v /var/run/docker.sock:/var/run/docker.sock
12
13
  -v /root/.docker:/root/.docker
@@ -1,2 +1,3 @@
1
1
 
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.
@@ -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
@@ -1,3 +1,3 @@
1
1
  module StackServiceBase
2
- VERSION = '0.0.92'
2
+ VERSION = '0.0.93'
3
3
  end
@@ -12,6 +12,7 @@ require 'stack-service-base/sinatra_ext'
12
12
  require 'stack-service-base/debugger'
13
13
  require 'stack-service-base/async_helpers'
14
14
  require 'stack-service-base/socket_trace'
15
+ require 'stack-service-base/safe_exec'
15
16
 
16
17
  module StackServiceBase
17
18
  class << self
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: stack-service-base
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.92
4
+ version: 0.0.93
5
5
  platform: ruby
6
6
  authors:
7
7
  - Artyom B
@@ -453,6 +453,7 @@ files:
453
453
  - lib/stack-service-base/prometheus_parser.rb
454
454
  - lib/stack-service-base/public/ssbase/main.js
455
455
  - lib/stack-service-base/rack_helpers.rb
456
+ - lib/stack-service-base/safe_exec.rb
456
457
  - lib/stack-service-base/sinatra_ext.rb
457
458
  - lib/stack-service-base/socket_trace.rb
458
459
  - lib/stack-service-base/stack_template/gitlab-c/.gitattributes