rubik 0.1.6 → 0.2.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.
@@ -20,7 +20,8 @@ module Rubik
20
20
  # the loop thread wakes immediately.
21
21
  # * Wake staleness is handled by park generations: every registration
22
22
  # stamps @gen[fiber]; timer fires and io dispatch only push when the
23
- # generation still matches, so a fiber that timed out and re-parked
23
+ # generation still matches at dispatch, including queued wakeups,
24
+ # so a fiber that timed out and re-parked
24
25
  # elsewhere can never be resumed by a stale source.
25
26
  #
26
27
  # Drain model (waiters, not liveness — Fiber#alive? does not exist on
@@ -48,8 +49,8 @@ module Rubik
48
49
 
49
50
  def initialize
50
51
  @mutex = ::Mutex.new
51
- @runnable = [] # [[fiber, resume_value]] — guarded by @mutex
52
- @parked = {} # fiber => true (block-hook parks) — guarded
52
+ @runnable = [] # [[fiber, resume_value, park_generation]] — guarded by @mutex
53
+ @parked = {} # fiber => generation (block-hook parks) — guarded
53
54
  @waiter_threads = [] # process-wait threads — guarded
54
55
  # Stale timers (fiber re-parked or resumed since registration) must
55
56
  # not gate draining — @gen is bumped on every resume, so any of this
@@ -77,11 +78,11 @@ module Rubik
77
78
 
78
79
  # Queues a fiber to be resumed with value. Safe from foreign threads:
79
80
  # pushes write a self-pipe byte so the loop's IO.select wakes at once.
80
- def push(fiber, value: true)
81
+ def push(fiber, value: true, generation: nil)
81
82
  @mutex.synchronize do
82
83
  return if @closed
83
84
 
84
- @runnable << [fiber, value]
85
+ @runnable << [fiber, value, generation]
85
86
  end
86
87
  kick unless ::Thread.current.equal?(@loop_thread)
87
88
  end
@@ -95,8 +96,8 @@ module Rubik
95
96
  @mutex.synchronize do
96
97
  return if @closed
97
98
 
98
- @parked.delete(fiber)
99
- @runnable << [fiber, value]
99
+ generation = @parked.delete(fiber)
100
+ @runnable << [fiber, value, generation]
100
101
  end
101
102
  kick unless ::Thread.current.equal?(@loop_thread)
102
103
  end
@@ -149,8 +150,8 @@ module Rubik
149
150
  # timeout of 0 still registers a timer (fires immediately) — truthiness
150
151
  # guards would park join(0)/wait(0) polls forever.
151
152
  def park(_blocker, timeout = nil)
152
- @mutex.synchronize { @parked[::Fiber.current] = true }
153
153
  value = park_fiber do |fiber, gen|
154
+ @mutex.synchronize { @parked[fiber] = gen }
154
155
  @timers.add(monotonic + timeout, fiber, :timeout, gen) unless timeout.nil?
155
156
  end
156
157
  value == :timeout ? false : value
@@ -172,49 +173,52 @@ module Rubik
172
173
  value
173
174
  end
174
175
 
175
- # io_read body (one nonblocking attempt per call — the VM re-invokes
176
- # the hook in a loop, mirroring IO::Buffer#read's one-syscall shape).
177
- # Fills the caller's buffer and returns bytes read (0 = EOF).
178
- #
179
- # Two 4.0.6 facts shape this (verified by probe): the integer hook
180
- # args are protocol noise (3.3 passes length; 4.0 passes 0,0 — both
181
- # size the buffer to the requested read, hence buffer.size), and
182
- # EVERY blocking-family read primitive — read_nonblock included — is
183
- # itself routed back through scheduler.io_read once the hook exists.
184
- # The Fiber.blocking wrap is the VM-sanctioned escape (ruby's own test
185
- # scheduler wraps its primitives the same way): it runs the primitive
186
- # immediately, bypassing interception, and read_nonblock(exception:
187
- # false) never blocks the OS thread.
188
- def read_io(io, buffer)
189
- length = buffer.size
190
- return 0 if length.zero?
176
+ # A zero minimum means one nonblocking attempt: return -errno to
177
+ # the VM instead of parking here, so read_nonblock preserves the
178
+ # caller's subsequent IO#wait_readable deadline (e.g. Net::HTTP).
179
+ # Fiber.blocking prevents the primitive from re-entering this hook.
180
+ def read_io(io, buffer, minimum = 0, offset = 0)
181
+ transferred = 0
182
+ capacity = buffer.size - offset
183
+ return 0 if capacity.zero?
191
184
 
192
185
  loop do
193
- chunk = ::Fiber.blocking { io.read_nonblock(length, exception: false) }
186
+ chunk = ::Fiber.blocking { io.read_nonblock(capacity - transferred, exception: false) }
194
187
  case chunk
195
- when :wait_readable then wait_io(io, READABLE, nil)
196
- when :wait_writable then wait_io(io, WRITABLE, nil)
197
- when nil then return 0
188
+ when :wait_readable, :wait_writable
189
+ return transferred.positive? ? transferred : -Errno::EAGAIN::Errno if transferred >= minimum
190
+
191
+ wait_io(io, chunk == :wait_readable ? READABLE : WRITABLE, nil)
192
+ when nil then return transferred
198
193
  else
199
- buffer.copy(::IO::Buffer.for(chunk), 0)
200
- return chunk.bytesize
194
+ buffer.set_string(chunk, offset + transferred)
195
+ transferred += chunk.bytesize
196
+ return transferred if transferred >= minimum || transferred == capacity
201
197
  end
202
198
  end
199
+ rescue SystemCallError => e
200
+ -e.errno
203
201
  end
204
202
 
205
- # io_write body: writes the whole caller-supplied buffer with one
206
- # nonblocking attempt per call, parking on WRITABLE between attempts.
207
- # (4.0.6 passes (0,0) as integers and expects the full buffer out.)
208
- def write_io(io, buffer)
209
- data = buffer.get_string(0, buffer.size)
210
- return 0 if data.empty?
203
+ def write_io(io, buffer, minimum = 0, offset = 0)
204
+ transferred = 0
205
+ capacity = buffer.size - offset
206
+ return 0 if capacity.zero?
211
207
 
212
208
  loop do
209
+ data = buffer.get_string(offset + transferred, capacity - transferred)
213
210
  written = ::Fiber.blocking { io.write_nonblock(data, exception: false) }
214
- return written if written.is_a?(::Integer)
211
+ if written.is_a?(::Integer)
212
+ transferred += written
213
+ return transferred if transferred >= minimum || transferred == capacity
214
+ else
215
+ return transferred.positive? ? transferred : -Errno::EAGAIN::Errno if transferred >= minimum
215
216
 
216
- wait_io(io, WRITABLE, nil)
217
+ wait_io(io, written == :wait_readable ? READABLE : WRITABLE, nil)
218
+ end
217
219
  end
220
+ rescue SystemCallError => e
221
+ -e.errno
218
222
  end
219
223
 
220
224
  # DNS resolution parks the fiber; the DnsPool worker delivers the list
@@ -345,7 +349,9 @@ module Rubik
345
349
 
346
350
  def run_batch
347
351
  while (item = @mutex.synchronize { @runnable.shift })
348
- fiber, value = item
352
+ fiber, value, generation = item
353
+ next if generation && @gen[fiber] != generation
354
+
349
355
  # A resumed fiber is definitionally no longer parked; dropping the
350
356
  # registration here keeps interrupt-style wakes from leaking parks.
351
357
  # Bumping the park generation invalidates every OTHER outstanding
@@ -369,7 +375,9 @@ module Rubik
369
375
  end
370
376
 
371
377
  def select_or_wait
372
- read_set = @io_waiters.keys
378
+ read_set = @io_waiters.each_with_object([]) do |(io, slot), acc|
379
+ acc << io unless slot[:read].empty?
380
+ end
373
381
  write_set = @io_waiters.each_with_object([]) do |(io, slot), acc|
374
382
  acc << io unless slot[:write].empty?
375
383
  end
@@ -407,7 +415,7 @@ module Rubik
407
415
  next if gen != STICKY && @gen[fiber] != gen # stale: fiber moved on after registering
408
416
 
409
417
  drop_io_interest(fiber)
410
- push(fiber, value: value)
418
+ push(fiber, value: value, generation: gen == STICKY ? nil : gen)
411
419
  end
412
420
  end
413
421
 
@@ -420,7 +428,9 @@ module Rubik
420
428
  slot = @io_waiters[io]
421
429
  return unless slot
422
430
 
423
- waiters = slot[:read].merge(slot[:write])
431
+ waiters = {}
432
+ waiters.merge!(slot[:read]) if events.anybits?(READABLE)
433
+ waiters.merge!(slot[:write]) if events.anybits?(WRITABLE)
424
434
  waiters.each do |fiber, gen|
425
435
  if @gen[fiber] != gen
426
436
  # Stale interest (the fiber timed out or moved on): remove it so
@@ -434,7 +444,7 @@ module Rubik
434
444
  # it re-registers with a fresh generation.
435
445
  slot[:read].delete(fiber)
436
446
  slot[:write].delete(fiber)
437
- push(fiber, value: events)
447
+ push(fiber, value: events, generation: gen)
438
448
  end
439
449
  @io_waiters.delete(io) if slot[:read].empty? && slot[:write].empty?
440
450
  end
@@ -460,7 +470,9 @@ module Rubik
460
470
  slot = @io_waiters.delete(io)
461
471
  next unless slot
462
472
 
463
- (slot[:read].keys + slot[:write].keys).each { |fiber| push(fiber, value: 0) }
473
+ slot[:read].merge(slot[:write]).each do |fiber, gen|
474
+ push(fiber, value: 0, generation: gen)
475
+ end
464
476
  pruned += 1
465
477
  end
466
478
  pruned
@@ -5,23 +5,9 @@ require_relative "process_waiter"
5
5
 
6
6
  module Rubik
7
7
  module Concurrency
8
- # Adapter for the VM's Fiber::Scheduler hook protocol — stateless; all
9
- # state lives on the EventLoop. Ruby 3.3 ↔ 4.0 protocol drift is
10
- # concentrated here (probes against 4.0.6, plan §2):
11
- #
12
- # fiber 4.0: fiber(&blk) — scheduler creates and returns the fiber.
13
- # 3.3: fiber(fiber, block) — VM pre-created it.
14
- # io_read integer args differ (3.3 passes length; 4.0 passes 0, 0)
15
- # io_write but the buffer is always sized to the operation, so both
16
- # hooks drive off buffer.size — see EventLoop#read_io.
17
- #
18
- # io_read/io_write are required on 4.0: without them a plain IO#read
19
- # inside a scheduled fiber falls back to a whole-thread BLOCKING read
20
- # (measured: 20 × 0.2 s child pipes serialized into 13 s). The buffer is
21
- # filled/drained with read_nonblock/write_nonblock + IO::Buffer views —
22
- # IO#nonblock= and IO::Buffer#read(io,...) are NOT usable on Windows
23
- # builds (NoMethodError / constant-zero reads, verified), while the
24
- # nonblock(…, exception: false) family works for pipes and sockets alike.
8
+ # Adapter for the VM's Fiber::Scheduler hooks. IO hooks receive a
9
+ # minimum transfer length and a buffer offset. A zero minimum requests
10
+ # one nonblocking attempt; the VM owns subsequent waits and deadlines.
25
11
  class Scheduler
26
12
  attr_reader :loop
27
13
 
@@ -42,15 +28,12 @@ module Rubik
42
28
  @loop.wait_io(io, events, timeout)
43
29
  end
44
30
 
45
- # The integer args differ per protocol (3.3: length; 4.0. offset/length
46
- # both zero) but the buffer is always sized to the operation, so the
47
- # EventLoop drives both hooks off buffer.size — see read_io/write_io.
48
- def io_read(io, buffer, *_protocol_args)
49
- @loop.read_io(io, buffer)
31
+ def io_read(io, buffer, length, offset = 0)
32
+ @loop.read_io(io, buffer, length, offset)
50
33
  end
51
34
 
52
- def io_write(io, buffer, *_protocol_args)
53
- @loop.write_io(io, buffer)
35
+ def io_write(io, buffer, length, offset = 0)
36
+ @loop.write_io(io, buffer, length, offset)
54
37
  end
55
38
 
56
39
  def kernel_sleep(duration = nil)
@@ -21,7 +21,12 @@ module Rubik
21
21
  )
22
22
  return nil unless status.success?
23
23
 
24
- out.split("\x00", -1).reject(&:empty?).map { |p| p.tr("\\", "/") }.uniq
24
+ # PERF: NUL-split with conditional in-place backslash translation and
25
+ # in-place deduplication avoids intermediate array and string allocations.
26
+ res = out.split("\x00", -1).reject(&:empty?)
27
+ res.map! { |p| p.tr("\\", "/") } if out.include?("\\")
28
+ res.uniq!
29
+ res
25
30
  rescue StandardError
26
31
  nil
27
32
  end
@@ -196,8 +196,9 @@ module Rubik
196
196
  }
197
197
  end
198
198
 
199
+ # PERF: integer math avoids Rational allocation from Time#to_r (~5x faster, zero allocation)
199
200
  def mtime_micros(stat)
200
- (stat.mtime.to_r * 1_000_000).to_i
201
+ (stat.mtime.tv_sec * 1_000_000) + stat.mtime.tv_usec
201
202
  end
202
203
 
203
204
  def walk_dir(base_dir, rel_prefix, key_prefix, ignore_patterns, previous, needles)
@@ -325,6 +326,15 @@ module Rubik
325
326
  # depth. subtree_ignored? is a pure function of (dir, needles), so
326
327
  # the memo is exact.
327
328
  dir_verdicts = Hash.new { |h, dir| h[dir] = subtree_ignored?(dir, needles) }
329
+
330
+ # PERF: filter ignore_patterns to exclude prunable subtree patterns that
331
+ # dir_verdicts already checked on ancestor directory segments.
332
+ file_patterns = ignore_patterns.reject do |pat|
333
+ m = PRUNABLE_PATTERN.match(pat)
334
+ m && !m[1].include?("*") && !m[1].include?("?") && !m[1].include?("[")
335
+ end
336
+ file_rules = compile_ignore_regexes(file_patterns)
337
+
328
338
  order_like_walker(rel_paths).each do |rel|
329
339
  # PERF: zero-allocation ancestor check using string slicing instead of Array#split and Array#join
330
340
  ignored_dir = false
@@ -337,7 +347,8 @@ module Rubik
337
347
  idx += 1
338
348
  end
339
349
  next if ignored_dir
340
- next if ignored?(rel, ignore_patterns)
350
+ next if rel == ".rubik" || rel.start_with?(".rubik/") || rel == ".git" || rel.start_with?(".git/")
351
+ next if file_rules.any? { |r| pattern_match?(r, rel) }
341
352
  next unless included?(rel, @custom_includes)
342
353
 
343
354
  full = "#{base_dir}/#{rel}"
@@ -370,8 +381,9 @@ module Rubik
370
381
  # PERF: replacing "/" with NUL byte ("\x00") is equivalent to array component
371
382
  # comparison because NUL (0x00) is strictly less than any valid path byte,
372
383
  # avoiding intermediate Array allocations per path (~8.4x faster, ~62.5% fewer allocations).
384
+ # PERF: in-place sort_by! avoids array re-allocation
373
385
  def order_like_walker(paths)
374
- paths.sort_by { |p| p.tr("/", "\x00") }
386
+ paths.sort_by! { |p| p.tr("/", "\x00") }
375
387
  end
376
388
 
377
389
  def parse_gitignore(dir = @root_dir)
@@ -29,6 +29,7 @@ module Rubik
29
29
  @mutex = Mutex.new
30
30
  @cv = ConditionVariable.new
31
31
  @responses = {}
32
+ @closed = false
32
33
  end
33
34
 
34
35
  def open
@@ -78,6 +79,7 @@ module Rubik
78
79
  end
79
80
 
80
81
  @running = true
82
+ @closed = false
81
83
  @req_id = 0
82
84
  @responses.clear
83
85
 
@@ -118,8 +120,9 @@ module Rubik
118
120
  end
119
121
 
120
122
  def close
121
- return unless @running || @wait_thr&.alive?
123
+ return if @closed
122
124
 
125
+ @closed = true
123
126
  @running = false
124
127
 
125
128
  begin
@@ -128,6 +131,16 @@ module Rubik
128
131
  nil
129
132
  end
130
133
 
134
+ if @wait_thr&.alive?
135
+ Rubik::Platform.kill_tree(@wait_thr.pid)
136
+ begin
137
+ @wait_thr.join(0.5)
138
+ rescue StandardError
139
+ nil
140
+ end
141
+ end
142
+ @wait_thr = nil
143
+
131
144
  [@stdin, @stdout, @stderr].each do |io|
132
145
  io&.close unless io&.closed?
133
146
  rescue StandardError
@@ -142,16 +155,6 @@ module Rubik
142
155
  @reader_thr = nil
143
156
  end
144
157
 
145
- if @wait_thr&.alive?
146
- Rubik::Platform.kill_tree(@wait_thr.pid)
147
- begin
148
- @wait_thr.join(0.5)
149
- rescue StandardError
150
- nil
151
- end
152
- end
153
- @wait_thr = nil
154
-
155
158
  @mutex.synchronize do
156
159
  @responses.clear
157
160
  @cv.broadcast
@@ -21,18 +21,90 @@ module Rubik
21
21
  nil
22
22
  end
23
23
  else
24
+ # On Unix/Linux, collect descendant PIDs so child processes are not
25
+ # orphaned when the parent process is killed.
26
+ descendants = descendant_pids(pid)
27
+ descendants.each do |cpid|
28
+ begin
29
+ ::Process.kill("TERM", cpid)
30
+ rescue StandardError
31
+ nil
32
+ end
33
+ begin
34
+ ::Process.kill("KILL", cpid)
35
+ rescue StandardError
36
+ nil
37
+ end
38
+ end
24
39
  begin
25
40
  ::Process.kill("TERM", pid)
26
- rescue
41
+ rescue StandardError
27
42
  nil
28
43
  end
29
44
  begin
30
45
  ::Process.kill("KILL", pid)
31
- rescue
46
+ rescue StandardError
32
47
  nil
33
48
  end
34
49
  end
35
50
  end
51
+
52
+ # Collect all descendant process IDs recursively (leaves first).
53
+ def descendant_pids(pid)
54
+ return [] unless pid&.positive?
55
+
56
+ collected = []
57
+ queue = [pid]
58
+ until queue.empty?
59
+ curr = queue.shift
60
+ children = direct_child_pids(curr)
61
+ children.each do |cpid|
62
+ next if collected.include?(cpid) || cpid == pid
63
+
64
+ collected << cpid
65
+ queue << cpid
66
+ end
67
+ end
68
+ collected.reverse
69
+ rescue StandardError
70
+ []
71
+ end
72
+
73
+ private
74
+
75
+ def direct_child_pids(pid)
76
+ task_dir = "/proc/#{pid}/task"
77
+ if File.directory?(task_dir)
78
+ pids = []
79
+ Dir.glob("#{task_dir}/*/children").each do |path|
80
+ content = begin
81
+ File.read(path)
82
+ rescue StandardError
83
+ nil
84
+ end
85
+ pids.concat(content.split.map(&:to_i)) if content
86
+ end
87
+ return pids.reject(&:zero?) unless pids.empty?
88
+ end
89
+
90
+ out = begin
91
+ `pgrep -P #{pid} 2>/dev/null`
92
+ rescue StandardError
93
+ nil
94
+ end
95
+ return out.lines.map(&:to_i).reject(&:zero?) if out && !out.empty?
96
+
97
+ out = begin
98
+ `ps --ppid #{pid} -o pid= 2>/dev/null`
99
+ rescue StandardError
100
+ nil
101
+ end
102
+ return out.lines.map(&:to_i).reject(&:zero?) if out && !out.empty?
103
+
104
+ []
105
+ rescue StandardError
106
+ []
107
+ end
36
108
  end
37
109
  end
38
110
  end