selective-ruby-core 0.2.15 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 53768795f806a29d383b6b7a42863d6fcc765564f2a7069520fc02c0f5af050c
4
- data.tar.gz: d2cc6e6ffab4347039987478d049beba77cbe46bac9842eeb3075074ff6d598a
3
+ metadata.gz: b1a2419b9f116139a88f893db32c30d4b20ede42f5e8d67e29efa7338ad25746
4
+ data.tar.gz: 4f74720e2576103b12a264a8d111c6b473e8abb9a59cf8f92a9a63fa3f6ea7e4
5
5
  SHA512:
6
- metadata.gz: 0cec03160e36250d44556d597670c7235399d1b8b61abf21eeca12e95a2a57053b95885fbf2fd385e5a956575c1bb6a7c5ab2d52193546572cbd6f3a5ae5d881
7
- data.tar.gz: 4093d37cc9ea427e3d469e7cff565ce79a24ddbb3ef71ea03fd49c2085ca1319aa6253d37c3e010a7c183099c18195b188a300d74964c5098dcf3eaddd7361cc
6
+ metadata.gz: dd99fe2d8968bb63a4ac93443648fb1aaa16c081bf1ffdf09f5aaef8696137ab1135814eac6bbdc8ef0159fb72741075ec000e96112dd68f835146731e8d98a4
7
+ data.tar.gz: 141d0a456bdc91ba810c14328d28dd2380028936834f5d403dda87f7be95ceea7014d0d119bca41dea7c06f81490b04700c11999a5cd18921f1eaed011fb12e6
@@ -7,10 +7,12 @@ module Selective
7
7
  module Core
8
8
  class Controller
9
9
  include Helper
10
+
10
11
  @@selective_suppress_reporting = false
11
12
  @@report_at_finish = {}
12
13
 
13
14
  TERMINATION_REPORT_GRACE = 0.25
15
+ CONNECT_TIMEOUT = 30
14
16
 
15
17
  REQUIRED_CONFIGURATION = {
16
18
  "host" => "SELECTIVE_HOST",
@@ -29,6 +31,7 @@ module Selective
29
31
  @log = log
30
32
  @runner = runner_class.new(runner_args, method(:test_case_callback))
31
33
  @retries = 0
34
+ @pending_commands = []
32
35
  end
33
36
 
34
37
  # A lost connection is retried by looping here, not by calling start
@@ -104,19 +107,25 @@ module Selective
104
107
 
105
108
  def run_main_loop
106
109
  loop do
107
- message = pipe.read
108
- response = JSON.parse(message, symbolize_names: true)
110
+ response = next_command
109
111
 
110
112
  logger.info("Received Command: #{response}")
111
113
  break if handle_command(response) == :break
112
114
  end
113
115
  end
114
116
 
117
+ def next_command
118
+ return @pending_commands.shift if @pending_commands.any?
119
+
120
+ JSON.parse(pipe.read, symbolize_names: true)
121
+ end
122
+
115
123
  # One connection: true when the run finished on it, false when it was
116
124
  # lost and the caller should reconnect.
117
125
  def connect_and_run(reconnect:)
118
- @pipe = NamedPipe.new("/tmp/#{runner_id}_2", "/tmp/#{runner_id}_1")
119
- @transport_pid = spawn_transport_process(reconnect: reconnect)
126
+ ensure_transport_supports_stdio!
127
+ @pipe = StdioTransport.open(transport_binary, transport_url(reconnect: reconnect))
128
+ @transport_pid = @pipe.pid
120
129
 
121
130
  handle_termination_signals(transport_pid)
122
131
  wait_for_connectivity
@@ -133,7 +142,7 @@ module Selective
133
142
  with_error_handling { raise "Too many retries" } if retries > 10
134
143
 
135
144
  puts("Retrying in #{retries} seconds...") if debug?
136
- sleep(retries > 4 ? 4 : retries)
145
+ sleep([retries, 4].min)
137
146
  kill_transport
138
147
 
139
148
  pipe.reset!
@@ -159,7 +168,7 @@ module Selective
159
168
  run_id = build_env["run_id"]
160
169
  run_attempt = build_env["run_attempt"]
161
170
 
162
- metadata = build_env.reject { |k,v| %w(host runner_id api_key run_id run_attempt).include?(k) }
171
+ metadata = build_env.reject { |k, v| %w[host runner_id api_key run_id run_attempt].include?(k) }
163
172
 
164
173
  {
165
174
  "api_key" => api_key,
@@ -198,43 +207,53 @@ module Selective
198
207
 
199
208
  with_error_handling do
200
209
  raise "Missing required environment variables: #{missing.join(", ")}" unless missing.empty?
201
- raise "Invalid host: #{env['host']}" unless env['host'].match?(/^wss?:\/\//)
210
+ raise "Invalid host: #{env["host"]}" unless env["host"].match?(/^wss?:\/\//)
202
211
  end
203
212
  end
204
213
 
205
- def spawn_transport_process(reconnect: false)
206
- transport_path = File.join(ROOT_GEM_PATH, "lib", "bin", "transport")
207
- get_transport_path = File.join(ROOT_GEM_PATH, "bin", "get_transport")
214
+ def ensure_transport_binary!
215
+ explicit = ENV["SELECTIVE_TRANSPORT"].to_s
216
+ path = explicit.empty? ? File.join(ROOT_GEM_PATH, "lib", "bin", "transport") : explicit
217
+ return path if File.exist?(path)
208
218
 
209
- if !File.exist?(transport_path)
219
+ get_transport_path = File.join(ROOT_GEM_PATH, "bin", "get_transport")
220
+ if explicit.empty? && File.exist?(get_transport_path)
210
221
  # The get_transport script is not released with the gem, so this
211
222
  # code is intended for development/CI purposes.
212
- if File.exist?(get_transport_path)
213
- output, status = Open3.capture2e(get_transport_path)
214
- if !status.success?
215
- puts <<~TEXT
216
- Failed to download transport binary.
217
-
218
- #{output}
219
- TEXT
220
- end
221
- else
222
- with_error_handling do
223
- raise "Selective transport binary not found. Please contact support or compile it manually. See: https://github.com/selectiveci/transport"
224
- end
223
+ output, status = Open3.capture2e(get_transport_path)
224
+ unless status.success?
225
+ puts <<~TEXT
226
+ Failed to download transport binary.
227
+
228
+ #{output}
229
+ TEXT
230
+ end
231
+ else
232
+ with_error_handling do
233
+ raise "Selective transport binary not found. Please contact support or compile it manually. See: https://github.com/selectiveci/transport"
225
234
  end
226
235
  end
227
236
 
228
- Process.spawn(transport_path, transport_url(reconnect: reconnect), runner_id).tap do |pid|
229
- Process.detach(pid)
230
- end
237
+ path
238
+ end
239
+
240
+ def transport_binary
241
+ @transport_binary ||= ensure_transport_binary!
242
+ end
243
+
244
+ def ensure_transport_supports_stdio!
245
+ help = Open3.capture2e(transport_binary, "--help").first.scrub
246
+ return if help.include?("--stdio")
247
+
248
+ with_error_handling { raise "#{transport_binary} does not support --stdio. selective-ruby-core needs transport 0.2.0 or newer. Run bin/get_transport.\n#{help}" }
231
249
  end
232
250
 
233
251
  def wait_for_connectivity
234
252
  @connectivity = false
253
+ @pending_commands = []
235
254
 
236
255
  Thread.new do
237
- sleep(30)
256
+ Kernel.sleep(CONNECT_TIMEOUT)
238
257
  unless @connectivity
239
258
  puts "Transport process failed to start. Exiting..."
240
259
  kill_transport
@@ -244,14 +263,13 @@ module Selective
244
263
 
245
264
  loop do
246
265
  message = pipe.read
247
-
248
- # The message is nil until the transport opens the pipe
249
- # for writing. So, we must handle that here.
250
- next sleep(0.1) if message.nil?
251
-
252
266
  response = JSON.parse(message, symbolize_names: true)
253
- @connectivity = true if response[:command] == "connected"
254
- break
267
+ if response[:command] == "connected"
268
+ @connectivity = true
269
+ break
270
+ end
271
+
272
+ @pending_commands << response
255
273
  end
256
274
  end
257
275
 
@@ -278,22 +296,19 @@ module Selective
278
296
 
279
297
  write({type: "terminating", data: {signal: signal}})
280
298
  sleep(TERMINATION_REPORT_GRACE)
281
- rescue ConnectionLostError, IOError, SystemCallError
282
- # Nothing to report to.
299
+ rescue ConnectionLostError, IOError, SystemCallError, ThreadError
300
+ nil
283
301
  end
284
302
 
285
303
  def kill_transport(signal: "TERM")
286
304
  begin
287
305
  pipe.write "exit"
288
306
 
289
- # Give up to 5 seconds for graceful exit
290
- # before killing it below
291
- 1..5.times do
307
+ 100.times do
292
308
  Process.getpgid(transport_pid)
293
-
294
- sleep(1)
309
+ Kernel.sleep(0.05)
295
310
  end
296
- rescue ConnectionLostError, IOError
311
+ rescue ConnectionLostError, IOError, ThreadError
297
312
  # If the pipe is close, move straight to killing
298
313
  # it forcefully.
299
314
  end
@@ -308,8 +323,8 @@ module Selective
308
323
  def handle_command(data)
309
324
  if respond_to? "handle_#{data[:command]}", true
310
325
  send("handle_#{data[:command]}", data)
311
- else
312
- raise "Unknown command received: #{data[:command]}" if debug?
326
+ elsif debug?
327
+ raise "Unknown command received: #{data[:command]}"
313
328
  end
314
329
  end
315
330
 
@@ -428,10 +443,8 @@ module Selective
428
443
  end
429
444
 
430
445
  def modified_test_files(diff)
431
- @modified_test_files ||= begin
432
- diff.filter do |f|
433
- f.match?(/^#{runner.base_test_path}/)
434
- end
446
+ @modified_test_files ||= diff.filter do |f|
447
+ f.match?(/^#{runner.base_test_path}/)
435
448
  end
436
449
  end
437
450
 
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Selective
6
+ module Ruby
7
+ module Core
8
+ class StdioTransport
9
+ SHUTDOWN_GRACE = 1
10
+
11
+ attr_reader :pid, :wait_thread
12
+
13
+ def self.open(binary, url)
14
+ stdin, stdout, wait_thread = Open3.popen2(
15
+ {"SELECTIVE_TRANSPORT_URL" => url},
16
+ binary,
17
+ "--stdio",
18
+ pgroup: true
19
+ )
20
+ new(stdin, stdout, wait_thread)
21
+ end
22
+
23
+ def initialize(stdin, stdout, wait_thread)
24
+ @stdin = stdin
25
+ @stdout = stdout
26
+ @wait_thread = wait_thread
27
+ @pid = wait_thread.pid
28
+ @queue = Queue.new
29
+ @outbox = Queue.new
30
+ @reader = Thread.new { read_stdout }
31
+ @writer = Thread.new { write_stdin }
32
+ end
33
+
34
+ def write(message)
35
+ raise ConnectionLostError if @lost || !@wait_thread.alive?
36
+
37
+ @outbox << "#{message}\n"
38
+ rescue ClosedQueueError
39
+ raise ConnectionLostError
40
+ end
41
+
42
+ def read
43
+ @queue.pop || raise(ConnectionLostError)
44
+ end
45
+
46
+ def shutdown
47
+ @outbox.close
48
+ @writer.join(SHUTDOWN_GRACE)
49
+ @stdin.close
50
+ %w[TERM KILL].each do |signal|
51
+ break if @wait_thread.join(SHUTDOWN_GRACE)
52
+
53
+ signal_child(signal)
54
+ end
55
+ @wait_thread.join
56
+ @stdout.close
57
+ @reader.join(SHUTDOWN_GRACE)
58
+ @queue.close
59
+ end
60
+
61
+ alias_method :reset!, :shutdown
62
+ alias_method :delete_pipes, :shutdown
63
+
64
+ private
65
+
66
+ def read_stdout
67
+ while (line = @stdout.gets)
68
+ @queue << line.chomp
69
+ end
70
+ rescue IOError, ClosedQueueError
71
+ nil
72
+ ensure
73
+ @queue.close
74
+ end
75
+
76
+ def write_stdin
77
+ while (frame = @outbox.pop)
78
+ @stdin.write(frame)
79
+ end
80
+ rescue Errno::EPIPE, IOError
81
+ @lost = true
82
+ end
83
+
84
+ def signal_child(signal)
85
+ Process.kill(signal, @pid)
86
+ rescue Errno::ESRCH
87
+ nil
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -88,13 +88,24 @@ module Selective
88
88
  file.flush
89
89
 
90
90
  env = CLEAN_ENV.merge("SELECTIVE_TEST_MAP_UPLOAD_KEY" => @api_key.to_s)
91
- out, err, status = Open3.capture3(env, RbConfig.ruby, "-e", CHILD_SCRIPT, @uri.to_s, file.path)
91
+ out, err, status = run_child(env, file.path)
92
92
  raise "HTTP #{out.strip}: #{err.strip}" unless status.success?
93
93
 
94
94
  true
95
95
  end
96
96
  end
97
97
 
98
+ def run_child(env, path)
99
+ Open3.popen3(env, RbConfig.ruby, "-e", CHILD_SCRIPT, @uri.to_s, path) do |stdin, stdout, stderr, child|
100
+ stdin.close
101
+ readers = [stdout, stderr].map { |io| Thread.new { io.read } }
102
+ [*readers.map(&:value), child.value]
103
+ ensure
104
+ Process.kill("KILL", child.pid) if child.alive?
105
+ readers&.each(&:join)
106
+ end
107
+ end
108
+
98
109
  # The runner is configured with the WebSocket host (ws:// or wss://);
99
110
  # the upload goes to the same server over HTTP(S).
100
111
  def build_uri(host, **params)
@@ -3,7 +3,7 @@
3
3
  module Selective
4
4
  module Ruby
5
5
  module Core
6
- VERSION = "0.2.15"
6
+ VERSION = "0.3.0"
7
7
  end
8
8
  end
9
9
  end
metadata CHANGED
@@ -1,15 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: selective-ruby-core
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.15
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Benjamin Wood
8
8
  - Nate Vick
9
- autorequire:
10
9
  bindir: exe
11
10
  cert_chain: []
12
- date: 2026-10-23 00:00:00.000000000 Z
11
+ date: 1980-01-02 00:00:00.000000000 Z
13
12
  dependencies:
14
13
  - !ruby/object:Gem::Dependency
15
14
  name: logger
@@ -63,14 +62,13 @@ files:
63
62
  - lib/selective/ruby/core/controller.rb
64
63
  - lib/selective/ruby/core/file_correlator.rb
65
64
  - lib/selective/ruby/core/helper.rb
66
- - lib/selective/ruby/core/named_pipe.rb
65
+ - lib/selective/ruby/core/stdio_transport.rb
67
66
  - lib/selective/ruby/core/test_map.rb
68
67
  - lib/selective/ruby/core/test_map/recorder.rb
69
68
  - lib/selective/ruby/core/test_map/tracer.rb
70
69
  - lib/selective/ruby/core/test_map/tree.rb
71
70
  - lib/selective/ruby/core/test_map/uploader.rb
72
71
  - lib/selective/ruby/core/version.rb
73
- - selective-ruby-core.gemspec
74
72
  homepage: https://www.selective.ci
75
73
  licenses:
76
74
  - MIT
@@ -78,7 +76,6 @@ metadata:
78
76
  homepage_uri: https://www.selective.ci
79
77
  source_code_uri: http://github.com/selectiveci/selective-ruby-core
80
78
  changelog_uri: https://github.com/selectiveci/selective-ruby-core/blob/main/CHANGELOG.md
81
- post_install_message:
82
79
  rdoc_options: []
83
80
  require_paths:
84
81
  - lib
@@ -93,8 +90,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
93
90
  - !ruby/object:Gem::Version
94
91
  version: '0'
95
92
  requirements: []
96
- rubygems_version: 3.5.22
97
- signing_key:
93
+ rubygems_version: 4.0.20
98
94
  specification_version: 4
99
95
  summary: Selective Ruby Client Core
100
96
  test_files: []
@@ -1,101 +0,0 @@
1
- module Selective
2
- module Ruby
3
- module Core
4
- class NamedPipe
5
- attr_reader :read_pipe_path, :write_pipe_path
6
-
7
- def initialize(read_pipe_path, write_pipe_path, skip_reset: false)
8
- @read_pipe_path = read_pipe_path
9
- @write_pipe_path = write_pipe_path
10
-
11
- delete_pipes unless skip_reset
12
- initialize_pipes
13
- end
14
-
15
- def initialize_pipes
16
- create_pipes
17
-
18
- # Open the read and write pipes in separate threads
19
- Thread.new do
20
- @read_pipe = File.open(read_pipe_path, "r")
21
- end
22
- Thread.new do
23
- @write_pipe = File.open(write_pipe_path, "w")
24
- end
25
- end
26
-
27
- def write(message)
28
- return unless write_pipe
29
-
30
- chunk_size = 1024 # 1KB chunks
31
- offset = 0
32
- begin
33
- while offset < message.bytesize
34
- chunk = message.byteslice(offset, chunk_size)
35
-
36
- write_pipe.write(chunk)
37
- write_pipe.flush
38
-
39
- offset += chunk_size
40
- end
41
-
42
- write_pipe.write("\n")
43
- write_pipe.flush
44
- rescue Errno::EPIPE
45
- raise ConnectionLostError
46
- end
47
- end
48
-
49
- def read
50
- return unless read_pipe
51
- begin
52
- message = read_pipe.gets.chomp
53
- rescue NoMethodError => e
54
- if e.name == :chomp
55
- raise ConnectionLostError
56
- else
57
- raise e
58
- end
59
- end
60
- message
61
- end
62
-
63
- def reset!
64
- delete_pipes
65
- initialize_pipes
66
- end
67
-
68
- def delete_pipes
69
- # Close the pipes before deleting them
70
- read_pipe&.close
71
- write_pipe&.close
72
-
73
- # Allow threads to close before deleting pipes
74
- sleep(0.1)
75
-
76
- delete_pipe(read_pipe_path)
77
- delete_pipe(write_pipe_path)
78
- rescue Errno::EPIPE
79
- # Noop
80
- end
81
-
82
- private
83
-
84
- attr_reader :read_pipe, :write_pipe
85
-
86
- def create_pipes
87
- create_pipe(read_pipe_path)
88
- create_pipe(write_pipe_path)
89
- end
90
-
91
- def create_pipe(path)
92
- system("mkfifo #{path}") unless File.exist?(path)
93
- end
94
-
95
- def delete_pipe(path)
96
- File.delete(path) if File.exist?(path)
97
- end
98
- end
99
- end
100
- end
101
- end
@@ -1,53 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "lib/selective/ruby/core/version"
4
-
5
- Gem::Specification.new do |spec|
6
- spec.name = "selective-ruby-core"
7
- spec.version = Selective::Ruby::Core::VERSION
8
- spec.authors = ["Benjamin Wood", "Nate Vick"]
9
- spec.email = ["ben@hint.io", "nate@hint.io"]
10
- spec.license = "MIT"
11
-
12
- # Currently supported release platforms are:
13
- # Linux: x86_64-linux, aarch64-linux
14
- # MacOS: x86_64-darwin, arm64-darwin
15
- if (platform = ENV["RELEASE_PLATFORM"])
16
- spec.platform = platform
17
- end
18
-
19
- spec.summary = "Selective Ruby Client Core"
20
- spec.description = "Selective is an intelligent test runner for your current CI provider. Get real-time test results, intelligent ordering based on code changes, shorter run times, automatic flake detection, the ability to re-enqueue failed tests, and more."
21
- spec.homepage = "https://www.selective.ci"
22
- spec.required_ruby_version = ">= 2.7.0"
23
-
24
- spec.metadata["homepage_uri"] = spec.homepage
25
- spec.metadata["source_code_uri"] = "http://github.com/selectiveci/selective-ruby-core"
26
- spec.metadata["changelog_uri"] = "https://github.com/selectiveci/selective-ruby-core/blob/main/CHANGELOG.md"
27
-
28
- # Specify which files should be added to the gem when it is released.
29
- # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
30
- spec.files = Dir.chdir(__dir__) do
31
- `git ls-files -z`.split("\x0").reject do |f|
32
- (File.expand_path(f) == __FILE__) || f.start_with?(*%w[bin/ test/ spec/ features/ log/ .git .circleci appveyor .rspec .ruby-version .standard.yml README.md CHANGELOG.md CODE_OF_CONDUCT.md Gemfile])
33
- end
34
- end
35
-
36
- # Add the transport binary
37
- unless ENV["RELEASE_PLATFORM"].nil?
38
- spec.files << "lib/bin/transport"
39
- end
40
-
41
- # The optional test map tracer. extconf.rb writes a no-op Makefile when it
42
- # can't build (no compiler, unsupported Ruby), so installation never fails
43
- # because of it; the runner then reports it can't record.
44
- spec.extensions = ["ext/selective_tracer/extconf.rb"]
45
-
46
- spec.bindir = "exe"
47
- spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
48
- spec.require_paths = ["lib"]
49
-
50
- # Uncomment to register a new dependency of your gem
51
- spec.add_dependency("logger", "~> 1.6")
52
- spec.add_dependency("zeitwerk", "~> 2.6.12")
53
- end