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.
- checksums.yaml +7 -0
- data/.rspec +4 -0
- data/.rubocop.yml +28 -0
- data/CHANGELOG.md +45 -0
- data/LICENSE.txt +21 -0
- data/README.md +164 -0
- data/Rakefile +24 -0
- data/exe/coatepec +7 -0
- data/exe/coatepec-worker +12 -0
- data/lib/coatepec/cli.rb +40 -0
- data/lib/coatepec/errors.rb +15 -0
- data/lib/coatepec/introspection/model.rb +140 -0
- data/lib/coatepec/introspection/routes.rb +61 -0
- data/lib/coatepec/introspection/safe_options.rb +46 -0
- data/lib/coatepec/mcp/response.rb +40 -0
- data/lib/coatepec/mcp/tools.rb +139 -0
- data/lib/coatepec/mcp.rb +22 -0
- data/lib/coatepec/project.rb +30 -0
- data/lib/coatepec/project_config.rb +44 -0
- data/lib/coatepec/protocol.rb +30 -0
- data/lib/coatepec/spec/fork_strategy.rb +39 -0
- data/lib/coatepec/spec/guarded_fork_strategy.rb +116 -0
- data/lib/coatepec/spec/path_policy.rb +92 -0
- data/lib/coatepec/spec/process_strategy.rb +107 -0
- data/lib/coatepec/spec/result.rb +79 -0
- data/lib/coatepec/spec/runner.rb +62 -0
- data/lib/coatepec/spec/spawn_strategy.rb +19 -0
- data/lib/coatepec/version.rb +5 -0
- data/lib/coatepec/worker/change_detector.rb +44 -0
- data/lib/coatepec/worker/client.rb +101 -0
- data/lib/coatepec/worker/rails_runtime.rb +140 -0
- data/lib/coatepec/worker/server.rb +65 -0
- data/lib/coatepec/worker_manager.rb +87 -0
- data/lib/coatepec.rb +28 -0
- data/sig/coatepec.rbs +4 -0
- metadata +116 -0
|
@@ -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
|
data/lib/coatepec/mcp.rb
ADDED
|
@@ -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
|