coatepec 0.4.0

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.
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coatepec
4
+ module MCP
5
+ # The `rails_spec_run` MCP tool: runs targeted RSpec examples against
6
+ # the warm test worker and returns a structured pass/fail result.
7
+ class SpecRunTool < ::MCP::Tool
8
+ tool_name "rails_spec_run"
9
+ description "Run targeted RSpec examples against a warm, isolated Rails test worker"
10
+ annotations(read_only_hint: false, destructive_hint: true, idempotent_hint: false, open_world_hint: true)
11
+ input_schema(
12
+ properties: {
13
+ paths: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 100 },
14
+ example: { type: %w[string null] },
15
+ seed: { type: %w[integer null], minimum: 0, maximum: 65_535 },
16
+ fail_fast: { type: "boolean" },
17
+ timeout_seconds: { type: "integer", minimum: 1, maximum: 900 }
18
+ },
19
+ required: ["paths"],
20
+ additionalProperties: false
21
+ )
22
+
23
+ class << self
24
+ # rubocop:disable Metrics/ParameterLists -- mirrors the tool's own input_schema
25
+ # (paths/example/seed/fail_fast/timeout_seconds) plus the MCP-framework-injected
26
+ # server_context; splitting it would fight the ::MCP::Tool#call contract.
27
+ def call(paths:, server_context:, example: nil, seed: nil, fail_fast: false, timeout_seconds: 120)
28
+ # rubocop:enable Metrics/ParameterLists
29
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
30
+ data = server_context[:worker_manager].run_spec(
31
+ paths: paths, example: example, seed: seed, fail_fast: fail_fast, timeout_seconds: timeout_seconds
32
+ )
33
+ Response.ok(data: data, meta: meta_for(server_context, started_at))
34
+ rescue Coatepec::Error => e
35
+ Response.error(e)
36
+ end
37
+
38
+ private
39
+
40
+ def meta_for(server_context, started_at)
41
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
42
+ { project_root: server_context[:project_root], environment: "test", duration_ms: duration_ms }
43
+ end
44
+ end
45
+ end
46
+
47
+ # The `rails_runtime_status` MCP tool: reports the test worker's Ruby/Rails
48
+ # versions, PID, boot_id, and lifecycle state. Worker::Server#handle boots
49
+ # the Rails runtime before dispatching any command, so the first call to
50
+ # this tool starts (and blocks on) a full Rails boot just like a spec run.
51
+ class RuntimeStatusTool < ::MCP::Tool
52
+ tool_name "rails_runtime_status"
53
+ description "Report the Coatepec test worker's identity and boot status " \
54
+ "(boots the warm worker if it is not up yet)"
55
+ annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
56
+ input_schema(properties: {}, required: [], additionalProperties: false)
57
+
58
+ class << self
59
+ def call(server_context:)
60
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
61
+ data = server_context[:worker_manager].status
62
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
63
+ Response.ok(data: data,
64
+ meta: { project_root: server_context[:project_root], environment: "test",
65
+ duration_ms: duration_ms })
66
+ rescue Coatepec::Error => e
67
+ Response.error(e)
68
+ end
69
+ end
70
+ end
71
+
72
+ # The `rails_routes` MCP tool: lists/filters/paginates the target
73
+ # Rails app's routes.
74
+ class RoutesTool < ::MCP::Tool
75
+ tool_name "rails_routes"
76
+ description "Return a bounded, filterable list of the Rails app's routes"
77
+ annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
78
+ input_schema(
79
+ properties: {
80
+ query: { type: %w[string null] },
81
+ limit: { type: "integer", minimum: 1, maximum: 200 },
82
+ offset: { type: "integer", minimum: 0 }
83
+ },
84
+ required: [],
85
+ additionalProperties: false
86
+ )
87
+
88
+ class << self
89
+ def call(server_context:, query: nil, limit: 50, offset: 0)
90
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
91
+ data = server_context[:worker_manager].routes(query: query, limit: limit, offset: offset)
92
+ Response.ok(data: data, meta: meta_for(server_context, started_at))
93
+ rescue Coatepec::Error => e
94
+ Response.error(e)
95
+ end
96
+
97
+ private
98
+
99
+ def meta_for(server_context, started_at)
100
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
101
+ { project_root: server_context[:project_root], environment: "test", duration_ms: duration_ms }
102
+ end
103
+ end
104
+ end
105
+
106
+ # The `rails_model` MCP tool: returns an ActiveRecord model's schema,
107
+ # associations, validators, and enums.
108
+ class ModelTool < ::MCP::Tool
109
+ tool_name "rails_model"
110
+ description "Return bounded ActiveRecord schema, associations, validators, and enums for a model, " \
111
+ "without row data"
112
+ annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
113
+ input_schema(
114
+ properties: {
115
+ name: { type: "string", pattern: '^[A-Z]\w*(?:::[A-Z]\w*)*$' }
116
+ },
117
+ required: ["name"],
118
+ additionalProperties: false
119
+ )
120
+
121
+ class << self
122
+ def call(name:, server_context:)
123
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
124
+ data = server_context[:worker_manager].model(name: name)
125
+ Response.ok(data: data, meta: meta_for(server_context, started_at))
126
+ rescue Coatepec::Error => e
127
+ Response.error(e)
128
+ end
129
+
130
+ private
131
+
132
+ def meta_for(server_context, started_at)
133
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
134
+ { project_root: server_context[:project_root], environment: "test", duration_ms: duration_ms }
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp"
4
+ require "coatepec"
5
+ require_relative "mcp/response"
6
+ require_relative "mcp/tools"
7
+
8
+ module Coatepec
9
+ # Wires the `rails_spec_run`, `rails_runtime_status`, `rails_routes`, and
10
+ # `rails_model` tools into an `::MCP::Server` instance backed by the given
11
+ # project's worker manager.
12
+ module MCP
13
+ def self.build_server(project:, worker_manager:)
14
+ ::MCP::Server.new(
15
+ name: "coatepec",
16
+ version: Coatepec::VERSION,
17
+ tools: [SpecRunTool, RuntimeStatusTool, RoutesTool, ModelTool],
18
+ server_context: { worker_manager: worker_manager, project_root: project.root }
19
+ )
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coatepec
4
+ # A Rails application checkout rooted at an absolute path (must contain a
5
+ # Gemfile); knows where its own and pack/engine/gem spec directories live.
6
+ class Project
7
+ attr_reader :root
8
+
9
+ def initialize(root)
10
+ @root = File.expand_path(root)
11
+
12
+ return if File.exist?(File.join(@root, "Gemfile"))
13
+
14
+ raise Coatepec::Error.new(:project_not_found, "No Gemfile found at #{@root}")
15
+ end
16
+
17
+ def spec_root_candidates
18
+ [
19
+ File.join(root, "spec"),
20
+ *Dir.glob(File.join(root, "packs/*/spec")),
21
+ *Dir.glob(File.join(root, "engines/*/spec")),
22
+ *Dir.glob(File.join(root, "gems/*/spec"))
23
+ ].select { |path| File.directory?(path) }
24
+ end
25
+
26
+ def config
27
+ @config ||= ProjectConfig.new(root)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Coatepec
6
+ # Optional per-project settings loaded from `.coatepec.yml` at the
7
+ # project root. A missing file means every setting takes its default --
8
+ # this file has never been required for Coatepec to work.
9
+ class ProjectConfig
10
+ def initialize(root)
11
+ @data = load(root)
12
+ end
13
+
14
+ def macos_fork?
15
+ !!@data["macos_fork"]
16
+ end
17
+
18
+ def macos_fork_unsafe_gems
19
+ Array(@data["macos_fork_unsafe_gems"]).map(&:to_s)
20
+ end
21
+
22
+ private
23
+
24
+ def load(root)
25
+ path = File.join(root, ".coatepec.yml")
26
+ return {} unless File.exist?(path)
27
+
28
+ parse(File.read(path))
29
+ # Psych::Exception, not just SyntaxError: safe_load also raises
30
+ # AliasesNotEnabled (anchors/aliases) and DisallowedClass (e.g. an
31
+ # unquoted date), which are equally the user's config being wrong.
32
+ rescue Psych::Exception => e
33
+ raise Coatepec::Error.new(:invalid_config, "Invalid .coatepec.yml: #{e.message}")
34
+ end
35
+
36
+ def parse(text)
37
+ data = YAML.safe_load(text)
38
+ return {} if data.nil?
39
+ return data if data.is_a?(Hash)
40
+
41
+ raise Coatepec::Error.new(:invalid_config, ".coatepec.yml must be a YAML mapping")
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Coatepec
6
+ # Reads and writes the newline-delimited JSON (NDJSON) messages exchanged
7
+ # with the test worker over its private stdio pipe.
8
+ class Protocol
9
+ class FramingError < StandardError; end
10
+
11
+ def initialize(input:, output:)
12
+ @input = input
13
+ @output = output
14
+ end
15
+
16
+ def write(message)
17
+ @output.puts(JSON.generate(message))
18
+ @output.flush
19
+ end
20
+
21
+ def read
22
+ line = @input.gets
23
+ return nil if line.nil?
24
+
25
+ JSON.parse(line, symbolize_names: true)
26
+ rescue JSON::ParserError => e
27
+ raise FramingError, "Malformed NDJSON message: #{e.message}"
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coatepec
4
+ module Spec
5
+ # Runs RSpec in a `Process.fork`ed child (Linux only): cheap and reuses
6
+ # the warm worker's loaded Rails boot, but isolated from the parent's
7
+ # ActiveRecord connections and global state.
8
+ class ForkStrategy < ProcessStrategy
9
+ private
10
+
11
+ def start(full_args, out_w, err_w)
12
+ Process.fork do
13
+ Process.setpgid(0, 0)
14
+ redirect_output(out_w, err_w)
15
+ # Forked children must not share the parent's live DB sockets.
16
+ ActiveRecord::Base.connection_handler.clear_all_connections! if defined?(ActiveRecord::Base)
17
+
18
+ status = RSpec::Core::Runner.run(full_args, $stderr, $stdout)
19
+ $stdout.flush
20
+ $stderr.flush
21
+ Kernel.exit!(status)
22
+ end
23
+ end
24
+
25
+ # rubocop:disable Style/GlobalStdStream -- exe/coatepec-worker aliases $stdout to
26
+ # $stderr so Rails boot output can't corrupt the NDJSON protocol on fd 1. That
27
+ # makes $stdout/$stderr the same object here, so only the STDOUT/STDERR
28
+ # constants can move the underlying fds -- and fd 1 must move off the
29
+ # protocol pipe.
30
+ def redirect_output(out_w, err_w)
31
+ STDOUT.reopen(out_w)
32
+ STDERR.reopen(err_w)
33
+ $stdout = STDOUT
34
+ $stderr = STDERR
35
+ end
36
+ # rubocop:enable Style/GlobalStdStream
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coatepec
4
+ module Spec
5
+ # Opt-in macOS fork strategy: attempts Process.fork like ForkStrategy
6
+ # (reusing the warm worker's boot), but only after two cheap guard
7
+ # checks pass, and transparently falls back to a fresh SpawnStrategy
8
+ # run -- for this call only -- when a guard fails or the forked child
9
+ # crashes. See docs/superpowers/specs/2026-07-31-macos-guarded-fork-design.md.
10
+ class GuardedForkStrategy < ForkStrategy
11
+ # Intentionally empty at ship time: the one documented crash this
12
+ # guards against didn't name a specific culprit gem, just "something
13
+ # ObjC-initializing on another thread." This is an extension point
14
+ # for real incidents (refine via a project's own .coatepec.yml
15
+ # macos_fork_unsafe_gems), not a researched-and-complete list.
16
+ BUILTIN_UNSAFE_GEMS = [].freeze
17
+
18
+ # Live thread count is allowed to exceed the post-boot baseline by
19
+ # this much before the guard treats it as "something is mid-init."
20
+ THREAD_COUNT_TOLERANCE = 1
21
+
22
+ CRASH_SIGNAL_NAMES = %w[ABRT SEGV BUS].freeze
23
+
24
+ # A crash retry shares one timeout budget with the fork attempt that
25
+ # preceded it: WorkerManager only gives the whole dispatch
26
+ # timeout_seconds + 10 before Client#read_response raises
27
+ # DisconnectedError and the warm worker is restarted from scratch --
28
+ # exactly the boot this feature exists to preserve. The floor keeps a
29
+ # nearly-exhausted budget from turning the retry into a guaranteed
30
+ # timeout kill; a few seconds is enough for a fast spec to still land.
31
+ MIN_RETRY_TIMEOUT_SECONDS = 5
32
+
33
+ # The crashed child's stderr carries the macOS crash report -- the only
34
+ # evidence that could ever populate BUILTIN_UNSAFE_GEMS from a real
35
+ # incident. Kept far below Result::MAX_OUTPUT_BYTES because it rides
36
+ # along with a whole second result inside MCP::Response's 1 MiB cap.
37
+ MAX_CRASH_STDERR_BYTES = 4 * 1024
38
+
39
+ def initialize(project_root, project: nil, rails_runtime: nil)
40
+ super
41
+ @spawn_strategy = SpawnStrategy.new(project_root)
42
+ end
43
+
44
+ def run(args, timeout_seconds)
45
+ return fallback_result(args, timeout_seconds, "spawn_fallback") unless guard_passes?
46
+
47
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
48
+ begin
49
+ result = super
50
+ rescue SystemCallError
51
+ # Process.fork itself failed (Errno::EAGAIN/ENOMEM under
52
+ # process-table pressure), so no child was ever produced -- from the
53
+ # caller's side that is indistinguishable from a failed guard, hence
54
+ # the same mode. Opting into macos_fork must never surface an error
55
+ # that plain SpawnStrategy wouldn't have.
56
+ return fallback_result(args, timeout_seconds, "spawn_fallback")
57
+ end
58
+ return result.merge(execution_mode: "fork") unless crashed?(result)
59
+
60
+ retry_after_crash(args, timeout_seconds, started_at, result)
61
+ end
62
+
63
+ private
64
+
65
+ def retry_after_crash(args, timeout_seconds, started_at, crashed)
66
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
67
+ remaining = [timeout_seconds - elapsed, MIN_RETRY_TIMEOUT_SECONDS].max
68
+
69
+ fallback_result(args, remaining, "spawn_after_crash")
70
+ .merge(crashed_fork_stderr: crash_diagnostics(crashed))
71
+ end
72
+
73
+ # Keeps the tail, matching Result#read_bounded: a crash report's tail is
74
+ # where the signal and backtrace land.
75
+ def crash_diagnostics(result)
76
+ text = result[:stderr].to_s
77
+ return text unless text.bytesize > MAX_CRASH_STDERR_BYTES
78
+
79
+ text.byteslice(-MAX_CRASH_STDERR_BYTES, MAX_CRASH_STDERR_BYTES)
80
+ end
81
+
82
+ def fallback_result(args, timeout_seconds, mode)
83
+ @spawn_strategy.run(args, timeout_seconds).merge(execution_mode: mode)
84
+ end
85
+
86
+ def guard_passes?
87
+ thread_count_ok? && !unsafe_gems_loaded?
88
+ end
89
+
90
+ def thread_count_ok?
91
+ baseline = @rails_runtime&.post_boot_thread_count
92
+ return true unless baseline
93
+
94
+ Thread.list.count <= baseline + THREAD_COUNT_TOLERANCE
95
+ end
96
+
97
+ def unsafe_gems_loaded?
98
+ denylist = BUILTIN_UNSAFE_GEMS + configured_unsafe_gems
99
+ return false if denylist.empty?
100
+
101
+ loaded = @rails_runtime&.loaded_gem_names || []
102
+ !(denylist & loaded).empty?
103
+ end
104
+
105
+ def configured_unsafe_gems
106
+ @project&.config&.macos_fork_unsafe_gems || []
107
+ end
108
+
109
+ def crashed?(result)
110
+ return false unless result[:signaled]
111
+
112
+ CRASH_SIGNAL_NAMES.any? { |name| Signal.list[name] == result[:termsig] }
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coatepec
4
+ module Spec
5
+ # Validates rails_spec_run's `paths` selectors against the allowed spec
6
+ # roots before any RSpec process is started, rejecting absolute paths,
7
+ # `..` traversal, symlink escapes, non-`_spec.rb` files, and oversized
8
+ # selector lists.
9
+ class PathPolicy
10
+ MAX_SELECTORS = 100
11
+
12
+ def initialize(project)
13
+ @project = project
14
+ end
15
+
16
+ def validate!(selectors)
17
+ raise Coatepec::Error.new(:invalid_spec_path, "No spec paths given") if selectors.nil? || selectors.empty?
18
+ if selectors.size > MAX_SELECTORS
19
+ raise Coatepec::Error.new(:invalid_spec_path, "At most #{MAX_SELECTORS} spec paths are allowed")
20
+ end
21
+
22
+ selectors.map { |selector| validate_one(selector) }
23
+ end
24
+
25
+ private
26
+
27
+ def validate_one(selector)
28
+ path_part, line_part = selector.to_s.split(":", 2)
29
+ reject_shape!(selector, path_part)
30
+ reject_line_part!(selector, line_part) if line_part
31
+
32
+ real_path = resolve_real_path!(selector, path_part)
33
+ reject_escape!(selector, real_path)
34
+ reject_wrong_kind!(selector, real_path)
35
+
36
+ line_part ? "#{path_part}:#{line_part}" : path_part
37
+ end
38
+
39
+ def resolve_real_path!(selector, path_part)
40
+ full_path = File.expand_path(path_part, @project.root)
41
+ unless File.exist?(full_path)
42
+ raise Coatepec::Error.new(:invalid_spec_path,
43
+ "Spec path does not exist: #{selector}")
44
+ end
45
+
46
+ File.realpath(full_path)
47
+ rescue Coatepec::Error
48
+ raise
49
+ rescue ArgumentError, Errno::EACCES, Errno::ENOENT, Errno::ENOTDIR => e
50
+ raise Coatepec::Error.new(:invalid_spec_path, "Invalid spec path: #{selector} (#{e.class.name})")
51
+ end
52
+
53
+ def reject_shape!(selector, path_part)
54
+ raise Coatepec::Error.new(:invalid_spec_path, "Blank selector") if path_part.to_s.strip.empty?
55
+ if path_part.start_with?("/")
56
+ raise Coatepec::Error.new(:invalid_spec_path,
57
+ "Absolute paths are not allowed: #{selector}")
58
+ end
59
+ return unless path_part.split("/").include?("..")
60
+
61
+ raise Coatepec::Error.new(:invalid_spec_path, "Path traversal is not allowed: #{selector}")
62
+ end
63
+
64
+ def reject_line_part!(selector, line_part)
65
+ return if line_part =~ /\A\d+\z/
66
+
67
+ raise Coatepec::Error.new(:invalid_spec_path, "Line number must be digits only: #{selector}")
68
+ end
69
+
70
+ def reject_escape!(selector, real_path)
71
+ real_root = File.realpath(@project.root)
72
+ return if real_path == real_root || real_path.start_with?("#{real_root}/")
73
+
74
+ raise Coatepec::Error.new(:invalid_spec_path, "Spec path escapes the project root: #{selector}")
75
+ end
76
+
77
+ def reject_wrong_kind!(selector, real_path)
78
+ return if under_allowed_root?(real_path) && (File.directory?(real_path) || real_path.end_with?("_spec.rb"))
79
+
80
+ raise Coatepec::Error.new(:invalid_spec_path,
81
+ "Spec path is not an allowed spec root or _spec.rb file: #{selector}")
82
+ end
83
+
84
+ def under_allowed_root?(real_path)
85
+ @project.spec_root_candidates.any? do |candidate|
86
+ real_candidate = File.realpath(candidate)
87
+ real_path == real_candidate || real_path.start_with?("#{real_candidate}/")
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+
5
+ module Coatepec
6
+ module Spec
7
+ # Shared child-process lifecycle for running an isolated RSpec run: spawns
8
+ # (via a subclass's #start), reaps with a timeout budget (TERM then KILL
9
+ # on overrun), and hands the captured output/JSON to Result. Subclasses
10
+ # (ForkStrategy, SpawnStrategy) only implement how the child is started.
11
+ class ProcessStrategy
12
+ def initialize(project_root, project: nil, rails_runtime: nil)
13
+ @project_root = project_root
14
+ @project = project
15
+ @rails_runtime = rails_runtime
16
+ end
17
+
18
+ def run(args, timeout_seconds)
19
+ out_r, out_w = IO.pipe
20
+ err_r, err_w = IO.pipe
21
+ json_path = Tempfile.create(["coatepec-rspec", ".json"], &:path)
22
+
23
+ pid = start_or_release(args, out_w, err_w, json_path, [out_r, err_r])
24
+ [out_w, err_w].each(&:close)
25
+
26
+ reap(pid, out_r, err_r, timeout_seconds, json_path)
27
+ end
28
+
29
+ private
30
+
31
+ # Subclasses start a process and return its pid; RSpec's own output
32
+ # must be wired to out_w/err_w.
33
+ def start(_full_args, _out_w, _err_w)
34
+ raise NotImplementedError, "#{self.class} must implement #start"
35
+ end
36
+
37
+ # A failed #start (Process.fork can raise Errno::EAGAIN/ENOMEM outright)
38
+ # leaves no child to reap, so the fds and tempfile #reap would have
39
+ # released have to be freed here. The original exception still reaches
40
+ # the caller -- only GuardedForkStrategy intercepts it to fall back.
41
+ def start_or_release(args, out_w, err_w, json_path, read_ends)
42
+ start(args + json_format_args(json_path), out_w, err_w)
43
+ rescue StandardError
44
+ ([out_w, err_w] + read_ends).each { |io| io.close unless io.closed? }
45
+ File.delete(json_path) if File.exist?(json_path)
46
+ raise
47
+ end
48
+
49
+ def json_format_args(json_path)
50
+ ["--format", "progress", "--format", "json", "--out", json_path]
51
+ end
52
+
53
+ def reap(pid, out_r, err_r, timeout_seconds, json_path)
54
+ status = wait_with_timeout(pid, timeout_seconds)
55
+ result = Result.build(pid: pid, status: status, out_r: out_r, err_r: err_r, json_path: json_path)
56
+ [out_r, err_r].each(&:close)
57
+ result
58
+ ensure
59
+ File.delete(json_path) if File.exist?(json_path)
60
+ end
61
+
62
+ def wait_with_timeout(pid, timeout_seconds)
63
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_seconds
64
+ poll_until(pid, deadline) || terminate_and_wait(pid)
65
+ end
66
+
67
+ def poll_until(pid, deadline)
68
+ loop do
69
+ _pid, status = Process.waitpid2(pid, Process::WNOHANG)
70
+ return status if status
71
+ return nil if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
72
+
73
+ sleep 0.05
74
+ end
75
+ end
76
+
77
+ def terminate_and_wait(pid)
78
+ status = terminate(pid)
79
+ return status if status
80
+
81
+ _pid, status = Process.waitpid2(pid)
82
+ status
83
+ rescue Errno::ECHILD
84
+ nil
85
+ end
86
+
87
+ # TERM, wait briefly, then KILL — this needs the multi-step escalation
88
+ # so a hung child gets a graceful chance before the hard kill. If the
89
+ # WNOHANG poll below reaps the child, that reap is the *only* wait
90
+ # this pid will ever satisfy — the status must be returned here
91
+ # rather than discarded, or the caller's follow-up waitpid2 raises
92
+ # Errno::ECHILD.
93
+ def terminate(pid)
94
+ Process.kill("TERM", -pid)
95
+ 3.times do
96
+ sleep 0.2
97
+ _pid, status = Process.waitpid2(pid, Process::WNOHANG)
98
+ return status if status
99
+ end
100
+ Process.kill("KILL", -pid)
101
+ nil
102
+ rescue Errno::ESRCH
103
+ nil
104
+ end
105
+ end
106
+ end
107
+ end