carbon_fiber 0.1.2 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c5d7027264dfb1b4d2751704427c0cb7a66f299d64ef09087961efa25e3e87de
4
- data.tar.gz: 51293f770ecd63f98d587b41587accc1e2dfe638ae3b02247a5ec98fccec4ca3
3
+ metadata.gz: 3906713d933ac5b29349ed47160f5e2704060f0a3cdd101474eea9e59cf74dcf
4
+ data.tar.gz: 78da45d46027261788f4c01333ae68cb90ece1992350b7aabb52e59564b32b9b
5
5
  SHA512:
6
- metadata.gz: ac4fcb671a1cc24a5bf60b0bfefeba2c6a84a2f11bfee6036f1c0dc3fdc817a7b8dca09baea35ab5597ff42094a0967aad08f0188b635dbd1cf8da5a2d781a9e
7
- data.tar.gz: d36a685b4dcc9fee84a1c8782418606ce933e1120da281eb4d50627b440301111cec5be41351d918538f6712bb8d5ae17f2e84bf5b78c29fc7938c4c5feb5457
6
+ metadata.gz: cb0ae60a2ef8f3463d437b79cc4fd9d36f092060504eb2bcaea31e17b96cf1e137784cd3b01f1f1950d53fd7cb2737f6cb15cbc0c952fb3156578d3ac7b8b31e
7
+ data.tar.gz: df4857f35926d78a04e75f908f03c423d0d275a4a2bb3a4d7fd03eab3f84f46b16feafffc352b6898860aeda02a9f8f91fa51156afbf8d82ef43056899990200
data/CHANGELOG.md CHANGED
@@ -1,3 +1,25 @@
1
+ ## [Unreleased]
2
+
3
+ - Fix the pure-Ruby fallback loop burning CPU while a fiber waits on a background thread; inside a non-main Ractor that spin starved the thread it was waiting for and hung the loop.
4
+
5
+ ## [0.2.0] - 2026-09-05
6
+
7
+ - Work inside Ractors: the native extension is declared Ractor-safe, so each Ractor can install its own `CarbonFiber::Scheduler` (several at once need Ruby 4.0 or later). See the README for the supported messaging pattern.
8
+ - Resolve hostnames in non-main Ractors through the platform resolver on a background thread, since Resolv cannot run there.
9
+ - Fix fibers parked in `Queue#pop`, `Mutex#lock`, or any `block` without a timeout being garbage collected while waiting; once unblocked this raised `TypeError: wrong argument type false (expected fiber)` or hung the loop.
10
+ - Fix a race between garbage collection and I/O completions arriving while the loop waits with the GVL released.
11
+ - Support the Ruby 4.1 buffered I/O scheduler contract: `io_read`/`io_write` take `(io, buffer, offset, length)` and perform a single transfer; Ruby 3.4 through 4.0 keep the legacy behavior. (Thanks to [Samuel Williams](https://github.com/samuel-williams-shopify)).
12
+ - The native extension now loads on Ruby development builds: a C shim compiled against the target headers provides the `ruby_abi_version` their ABI check requires.
13
+ - Match Ruby 4.1's updated `rb_data_type_t` layout when registering the native selector, and verify the layout against the target Ruby's headers when the extension loads.
14
+ - Under the Ruby 4.1 contract, regular-file reads and writes run on the background I/O path instead of risking blocking the scheduler thread.
15
+ - `Scheduler#io_close` and `Async::Selector#io_close` now take a file descriptor (Integer), matching Ruby's Fiber::Scheduler protocol. (Thanks to [Samuel Williams](https://github.com/samuel-williams-shopify)).
16
+
17
+ ## [0.1.3] - 2026-05-08
18
+
19
+ - Update to Zig 0.16.0; update libxev and zig.rb to the latest versions.
20
+ - Performance improvements. README updated with latest numbers.
21
+ - Improve benchmarking: default to 3 measured runs plus 1 warmup run. Now reporting `±CV%` and the delta column tags rows `(noisy)` when the change is within jitter. Add the ability to benchmark released vs. unreleased carbon_fiber.
22
+
1
23
  ## [0.1.2] - 2026-04-29
2
24
 
3
25
  - Fix the `x86_64-linux` precompiled gem (erroneously shipped an aarch64
data/README.md CHANGED
@@ -50,18 +50,18 @@ end
50
50
 
51
51
  ## Performance
52
52
 
53
- AWS EC2 c7a.2xlarge, 8 dedicated vCPUs, Ubuntu 24.04 LTS, kernel 6.17, Ruby 4.0.2 + YJIT, io_uring. 5-run median.
53
+ AWS EC2 c7a.2xlarge, 8 dedicated vCPUs, Amazon Linux 2023, kernel 6.18.20, Ruby 4.0.2 + YJIT, io_uring. 5-run median.
54
54
 
55
55
  Some benchmarks:
56
56
 
57
57
  | Workload | Carbon Fiber | Async | Itsi | Carbon Fiber vs. Async |
58
58
  |---|---|---|---|---|
59
- | `http_server` | **48.175k req/s** | 37.409k req/s | 29.708k req/s | +29% |
60
- | `http_client_api` | **15.528k req/s** | 13.373k req/s | timeout | +16% |
61
- | `http_client_download` | **6.747k dl/s** | 5.920k dl/s | timeout | +14% |
62
- | `tcp_echo` | **50.392k ops/s** | 38.907k ops/s | 30.660k ops/s | +29% |
63
- | `cascading_timeout` | **4.659k ops/s** | 4.488k ops/s | error | +4% |
64
- | `connection_pool` | **4.989k co/s** | 4.912k co/s | 4.968k co/s | +2% |
59
+ | `http_server` | **49.380k req/s** | 30.823k req/s | 30.864k req/s | +60% |
60
+ | `http_client_api` | **19.500k req/s** | 16.721k req/s | timeout | +17% |
61
+ | `http_client_download` | **8.426k dl/s** | 7.062k dl/s | timeout | +19% |
62
+ | `tcp_echo` | **52.973k ops/s** | 32.330k ops/s | 32.046k ops/s | +64% |
63
+ | `cascading_timeout` | **4.668k ops/s** | 4.414k ops/s | error | +6% |
64
+ | `connection_pool` | **4.967k co/s** | 4.612k co/s | 4.954k co/s | +8% |
65
65
 
66
66
  Wins on most workloads against Async, Itsi, fiber_scheduler, io-event, and libev. [See detailed benchmarks →](#benchmarks)
67
67
 
@@ -240,6 +240,47 @@ end
240
240
 
241
241
  ---
242
242
 
243
+ ### Inside Ractors
244
+
245
+ A fiber scheduler belongs to a thread, and every Ractor runs its own threads, so each Ractor installs its own `CarbonFiber::Scheduler`. Construct it inside the Ractor that runs it; never share one between Ractors. Running several such Ractors at once needs Ruby 4.0 or later; Ruby 3.4 aborts or hangs under that load, with the pure-Ruby selector as well. On Windows a garbage collection inside a non-main Ractor pauses for several seconds while the main Ractor waits, so Ractor workloads there are slow. Every hook works there as on the main Ractor, including DNS: `address_resolve` uses Resolv on the main Ractor and the platform resolver on a background thread elsewhere, because Resolv keeps state that Ractor isolation forbids.
246
+
247
+ `Ractor.receive` (and `Ractor::Port#receive`) block the calling thread without going through the scheduler, so a worker must not wait for messages on the thread that runs its event loop: every fiber in the Ractor would stall until the next message. Receive on a dedicated thread instead and hand messages to a scheduled fiber through a `Thread::Queue`. A push from the receiving thread wakes the parked fiber through the scheduler's cross-thread `unblock` path, the same path background operations use.
248
+
249
+ ```ruby
250
+ worker = Ractor.new do
251
+ scheduler = CarbonFiber::Scheduler.new
252
+ Fiber.set_scheduler(scheduler)
253
+
254
+ jobs = Thread::Queue.new
255
+ receiver = Thread.new do
256
+ while (message = Ractor.receive) != :stop
257
+ jobs.push(message)
258
+ end
259
+ jobs.close
260
+ end
261
+
262
+ results = []
263
+ Fiber.schedule do
264
+ loop do
265
+ job = jobs.pop or break # a fresh binding per job for the fiber below
266
+ Fiber.schedule { results << handle(job) } # any I/O the scheduler intercepts
267
+ end
268
+ end
269
+
270
+ Fiber.set_scheduler(nil) # runs the loop until the queue closes and every job is done
271
+ receiver.join
272
+ results
273
+ end
274
+
275
+ inputs.each { |job| worker.send(job) }
276
+ worker.send(:stop)
277
+ worker.value # Ractor#take on Ruby 3.4
278
+ ```
279
+
280
+ A pipe works too: the receiving thread writes a byte per message and the dispatching fiber reads it before popping the queue, so the wake-up arrives through `io_wait`. The queue alone is simpler and is what `spec/carbon_fiber/ractor_spec.rb` exercises.
281
+
282
+ ---
283
+
243
284
  ## How It Works
244
285
 
245
286
  The scheduler has two layers:
@@ -256,7 +297,7 @@ If the native extension can't be loaded (on Windows, for example), a pure-Ruby f
256
297
 
257
298
  ## Benchmarks
258
299
 
259
- AWS EC2 c7a.2xlarge, 8 dedicated vCPUs, Ubuntu 24.04 LTS, kernel 6.17, Ruby 4.0.2 + YJIT, io_uring. 5-run median.
300
+ AWS EC2 c7a.2xlarge, 8 dedicated vCPUs, Amazon Linux 2023, kernel 6.18.20, Ruby 4.0.2 + YJIT, io_uring. 5-run median.
260
301
 
261
302
  ### Ruby Fiber Schedulers (leading ones): Carbon Fiber vs. Async vs. Itsi
262
303
 
@@ -264,14 +305,14 @@ Measuring pure Ruby Fiber Scheduler performance (`Fiber.set_scheduler`).
264
305
 
265
306
  | Workload | Unit | Carbon Fiber | Async | Itsi | Carbon Fiber vs. Async |
266
307
  |---|---|---|---|---|---|
267
- | `http_client_api` | req/s | **15,528** | 13,373 | timeout | +16% |
268
- | `http_client_download` | dl/s | **6,747** | 5,920 | timeout | +14% |
269
- | `http_server` | req/s | **48,175** | 37,409 | 29,708 | +29% |
270
- | `tcp_echo` | ops/s | **50,392** | 38,907 | 30,660 | +29% |
271
- | `connection_pool` | co/s | **4,989** | 4,912 | 4,968 | +2% |
272
- | `fan_out_gather` | cyc/s | 2,024 | 2,046 | **2,104** | −1% |
273
- | `db_query_mix` | qry/s | 1,660 | 1,652 | **1,662** | +0.5% |
274
- | `cascading_timeout` | ops/s | **4,659** | 4,488 | error | +4% |
308
+ | `http_client_api` | req/s | **19,500** | 16,721 | timeout | +17% |
309
+ | `http_client_download` | dl/s | **8,426** | 7,062 | timeout | +19% |
310
+ | `http_server` | req/s | **49,380** | 30,823 | 30,864 | +60% |
311
+ | `tcp_echo` | ops/s | **52,973** | 32,330 | 32,046 | +64% |
312
+ | `connection_pool` | co/s | **4,967** | 4,612 | 4,954 | +8% |
313
+ | `fan_out_gather` | cyc/s | 2,022 | 1,923 | **2,094** | +5% |
314
+ | `db_query_mix` | qry/s | 1,660 | 1,623 | **1,662** | +2% |
315
+ | `cascading_timeout` | ops/s | **4,668** | 4,414 | error | +6% |
275
316
 
276
317
  Enabling YJIT turned out to be very beneficial for Async as well—numbers here are with `--yjit` on both sides.
277
318
 
@@ -281,12 +322,12 @@ Swapped the io-event selector for Carbon Fiber's native backend. Same Async code
281
322
 
282
323
  | Workload | Unit | Stock Async | Carbon Fiber | Delta |
283
324
  |---|---|---|---|---|
284
- | `http_client_api` | req/s | 13,375 | **14,331** | +7.1% |
285
- | `http_client_download` | dl/s | 3,893 | **3,956** | +1.6% |
286
- | `task_churn` | task/s | **87,883** | 85,027 | −3.3% |
287
- | `condition_signal` | sig/s | 337,282 | **361,089** | +7.1% |
288
- | `cascading_timeout` | ops/s | 4,497 | **4,511** | +0.3% |
289
- | `tcp_throughput` | ops/s | 42,292 | **51,930** | +22.8% |
325
+ | `http_client_api` | req/s | 17,119 | **17,323** | +1.2% |
326
+ | `http_client_download` | dl/s | 2,263 | **2,474** | +9.3% |
327
+ | `task_churn` | task/s | 119,477 | **120,985** | +1.3% |
328
+ | `condition_signal` | sig/s | 44,887 | **46,921** | +4.5% |
329
+ | `cascading_timeout` | ops/s | 4,414 | **4,472** | +1.3% |
330
+ | `tcp_throughput` | ops/s | 32,606 | **48,214** | +47.9% |
290
331
 
291
332
  ### Examples of how to run benchmarks
292
333
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  # Please note that this code is heavily AI-assisted.
4
4
 
5
+ require_relative "io_contract"
5
6
  require_relative "native"
6
7
 
7
8
  module CarbonFiber
@@ -26,8 +27,12 @@ module CarbonFiber
26
27
  attr_reader :loop
27
28
 
28
29
  # @param loop [Fiber] the Async event loop fiber
29
- def initialize(loop)
30
- super
30
+ # @param io_contract_v4 [Boolean] use the Ruby 4.1+ single-transfer
31
+ # contract in the native I/O paths (defaults to what the running
32
+ # Ruby speaks; override only for testing)
33
+ def initialize(loop, io_contract_v4: CarbonFiber.io_contract_v4?)
34
+ super(loop)
35
+ self.io_contract_v4 = io_contract_v4
31
36
  @loop = loop
32
37
  @idle_duration = 0.0
33
38
 
@@ -117,37 +122,84 @@ module CarbonFiber
117
122
  EAGAIN = -Errno::EAGAIN::Errno
118
123
  EWOULDBLOCK = -Errno::EWOULDBLOCK::Errno
119
124
 
125
+ # Legacy contract (Ruby 3.4 through 4.0): length before offset,
126
+ # minimum-progress semantics.
120
127
  # @param fiber [Fiber]
121
128
  # @param io [IO]
122
129
  # @param buffer [IO::Buffer]
123
130
  # @param length [Integer]
124
131
  # @param offset [Integer]
125
132
  # @return [Integer] bytes read, or negative errno
126
- def io_read(fiber, io, buffer, length, offset = 0)
133
+ def io_read_v3(fiber, io, buffer, length, offset = 0)
127
134
  result = native_io_read(io.fileno, buffer, length, offset)
128
135
  return result unless result.nil?
129
136
 
130
137
  ruby_io_read(fiber, io, buffer, length, offset)
131
138
  end
132
139
 
140
+ # Ruby 4.1+ contract: offset before length, single transfer. One
141
+ # nonblocking attempt; short results and -EAGAIN are returned
142
+ # directly, and the caller composes retries via io_wait.
143
+ # @param fiber [Fiber]
144
+ # @param io [IO]
145
+ # @param buffer [IO::Buffer]
146
+ # @param offset [Integer]
147
+ # @param length [Integer] maximum bytes for this transfer
148
+ # @return [Integer] bytes read, or negative errno
149
+ def io_read_v4(fiber, io, buffer, offset, length)
150
+ return 0 if length.zero?
151
+
152
+ result = native_io_read(io.fileno, buffer, length, offset)
153
+ return result unless result.nil?
154
+
155
+ ruby_io_read_v4(io, buffer, offset, length)
156
+ end
157
+
158
+ # Legacy contract (Ruby 3.4 through 4.0): length before offset,
159
+ # minimum-progress semantics.
133
160
  # @param fiber [Fiber]
134
161
  # @param io [IO]
135
162
  # @param buffer [IO::Buffer]
136
163
  # @param length [Integer]
137
164
  # @param offset [Integer]
138
165
  # @return [Integer] bytes written, or negative errno
139
- def io_write(fiber, io, buffer, length, offset = 0)
166
+ def io_write_v3(fiber, io, buffer, length, offset = 0)
140
167
  result = native_io_write(io.fileno, buffer, length, offset)
141
168
  return result unless result.nil?
142
169
 
143
170
  ruby_io_write(fiber, io, buffer, length, offset)
144
171
  end
145
172
 
146
- # Cancel pending waiters and close the descriptor.
173
+ # Ruby 4.1+ contract: offset before length, single transfer.
174
+ # @param fiber [Fiber]
147
175
  # @param io [IO]
148
- def io_close(io)
149
- fd = io.respond_to?(:fileno) ? io.fileno : io.to_i
150
- super(fd, IOError.new("stream closed while waiting"))
176
+ # @param buffer [IO::Buffer]
177
+ # @param offset [Integer]
178
+ # @param length [Integer] maximum bytes for this transfer
179
+ # @return [Integer] bytes written, or negative errno
180
+ def io_write_v4(fiber, io, buffer, offset, length)
181
+ return 0 if length.zero?
182
+
183
+ result = native_io_write(io.fileno, buffer, length, offset)
184
+ return result unless result.nil?
185
+
186
+ ruby_io_write_v4(io, buffer, offset, length)
187
+ end
188
+
189
+ # The public hook names follow the contract of the running Ruby;
190
+ # both generations stay defined for direct testing.
191
+ if CarbonFiber.io_contract_v4?
192
+ alias_method :io_read, :io_read_v4
193
+ alias_method :io_write, :io_write_v4
194
+ else
195
+ alias_method :io_read, :io_read_v3
196
+ alias_method :io_write, :io_write_v3
197
+ end
198
+
199
+ # Cancel pending waiters on the descriptor.
200
+ # @param descriptor [Integer]
201
+ def io_close(descriptor)
202
+ super(descriptor, IOError.new("stream closed while waiting"))
151
203
  end
152
204
 
153
205
  # Wait for a child process on a background thread.
@@ -230,6 +282,22 @@ module CarbonFiber
230
282
  total
231
283
  end
232
284
 
285
+ # Ruby-level single-transfer io_read/io_write for non-socket fds under
286
+ # the Ruby 4.1+ contract: one nonblocking attempt, -EAGAIN and short
287
+ # results returned directly. No io_wait, no minimum-progress loop;
288
+ # Ruby's own read loop composes retries.
289
+ def ruby_io_read_v4(io, buffer, offset, length)
290
+ IO::Event::Selector.nonblock(io) do
291
+ Fiber.blocking { buffer.read(io, offset, length) }
292
+ end
293
+ end
294
+
295
+ def ruby_io_write_v4(io, buffer, offset, length)
296
+ IO::Event::Selector.nonblock(io) do
297
+ Fiber.blocking { buffer.write(io, offset, length) }
298
+ end
299
+ end
300
+
233
301
  def fallback_io_wait(io, events)
234
302
  Thread.new do
235
303
  Thread.current.report_on_exception = false
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CarbonFiber
4
+ # Ruby 4.1 (ruby/ruby#18483) reordered the buffered scheduler hooks to
5
+ # +io_read(io, buffer, offset, length)+ and made them single-transfer:
6
+ # one nonblocking attempt, short results returned directly, -EAGAIN
7
+ # instead of waiting, and zero length returns 0 without I/O.
8
+ # +IO::Buffer::VERSION >= 3+ signals the new contract; Ruby 3.4 through
9
+ # 4.0 define no such constant.
10
+ #
11
+ # @return [Boolean] whether this Ruby uses the v4 scheduler I/O contract
12
+ def self.io_contract_v4?
13
+ defined?(IO::Buffer::VERSION) ? IO::Buffer::VERSION.to_i >= 3 : false
14
+ end
15
+ end
@@ -33,6 +33,10 @@ module CarbonFiber
33
33
  true
34
34
  end
35
35
 
36
+ # Accepted for interface parity with the native selector; the fallback
37
+ # has no native I/O paths, so the contract generation changes nothing.
38
+ attr_writer :io_contract_v4
39
+
36
40
  # @return [Boolean] whether there is pending work
37
41
  def pending?
38
42
  @mutex.synchronize { @ready.any? || @timers.any? || @read_waits.any? }
@@ -100,7 +104,7 @@ module CarbonFiber
100
104
  # Run one event loop iteration.
101
105
  def select(timeout = nil)
102
106
  flush_ready
103
- return 0 unless pending?
107
+ return 0 unless pending? || parked?
104
108
 
105
109
  deadline = next_wait_deadline(timeout)
106
110
 
@@ -124,6 +128,35 @@ module CarbonFiber
124
128
  flush_ready
125
129
  end
126
130
 
131
+ # True while a fiber is parked in block() or do_io_wait. Nothing in the
132
+ # ready list, timers, or read waits refers to it, so pending? is false,
133
+ # yet select must sleep on the condition variable rather than return:
134
+ # the wake-up comes from another thread (a background operation's
135
+ # resume, an unblock, or wakeup), and returning at once makes
136
+ # Scheduler#run spin with the GVL held. On the main Ractor the timer
137
+ # thread preempts that spin; inside a non-main Ractor Ruby's M:N
138
+ # scheduler does not, and the thread that would wake the fiber never
139
+ # runs.
140
+ def parked?
141
+ @mutex.synchronize { @blocked_fibers.any? }
142
+ end
143
+
144
+ # Mirrors `Selector#kernel_sleep` on the native side so
145
+ # `Scheduler#kernel_sleep` can delegate to `@selector.kernel_sleep`
146
+ # in both paths. Branches on the duration: nil parks the fiber on
147
+ # the loop without a timer, non-positive yields, positive parks on
148
+ # a native timer for `duration` seconds.
149
+ def kernel_sleep(duration = nil)
150
+ if duration.nil?
151
+ transfer
152
+ elsif duration <= 0
153
+ self.yield
154
+ else
155
+ block(Fiber.current, duration)
156
+ end
157
+ true
158
+ end
159
+
127
160
  # Suspend the current fiber until unblocked or timed out.
128
161
  def block(fiber, timeout = nil)
129
162
  token = nil
@@ -2,9 +2,9 @@
2
2
 
3
3
  # Please note that this code is heavily AI-assisted.
4
4
 
5
- require "resolv"
6
5
  require "socket"
7
6
  require "timeout"
7
+ require_relative "io_contract"
8
8
  require_relative "native"
9
9
 
10
10
  # High-performance Ruby Fiber Scheduler backed by Zig and libxev.
@@ -43,10 +43,20 @@ module CarbonFiber
43
43
  class Scheduler
44
44
  # @param root_fiber [Fiber] the event loop fiber (defaults to current)
45
45
  # @param selector [Class] native selector class to instantiate
46
- def initialize(root_fiber = Fiber.current, selector: CarbonFiber::Native::Selector)
46
+ # @param io_contract_v4 [Boolean] use the Ruby 4.1+ single-transfer
47
+ # contract in the native I/O paths (defaults to what the running
48
+ # Ruby speaks; override only for testing)
49
+ def initialize(root_fiber = Fiber.current, selector: CarbonFiber::Native::Selector,
50
+ io_contract_v4: CarbonFiber.io_contract_v4?)
47
51
  @root_fiber = root_fiber
48
52
  @scheduler_thread = Thread.current
53
+ @main_ractor = !defined?(Ractor) || Ractor.current == Ractor.main
54
+ # Resolv keeps state Ractor isolation forbids, and only the main
55
+ # Ractor resolves through it (see #address_resolve), so load it here
56
+ # rather than at require time; a worker Ractor never loads it.
57
+ require "resolv" if @main_ractor
49
58
  @selector = selector.new(root_fiber)
59
+ @selector.io_contract_v4 = io_contract_v4
50
60
  @active_fibers = 0
51
61
  @background_count = 0
52
62
  @closed = false
@@ -169,15 +179,7 @@ module CarbonFiber
169
179
  # Intercept +Kernel#sleep+. Parks the fiber on a native timer.
170
180
  # @param duration [Float, nil] seconds to sleep; nil sleeps forever
171
181
  def kernel_sleep(duration = nil)
172
- if duration.nil?
173
- transfer
174
- elsif duration <= 0
175
- self.yield
176
- else
177
- block(nil, duration)
178
- end
179
-
180
- true
182
+ @selector.kernel_sleep(duration)
181
183
  end
182
184
 
183
185
  # Wait for I/O readiness on a file descriptor.
@@ -197,14 +199,15 @@ module CarbonFiber
197
199
  await_background_operation { io_select_readiness(io, events, timeout) }
198
200
  end
199
201
 
200
- # Read from an IO into a buffer via the native selector.
202
+ # Read from an IO into a buffer via the native selector (legacy contract,
203
+ # Ruby 3.4 through 4.0: length before offset, minimum-progress semantics).
201
204
  # Falls back to a background thread for non-socket descriptors.
202
205
  # @param io [IO]
203
206
  # @param buffer [IO::Buffer]
204
207
  # @param length [Integer]
205
208
  # @param offset [Integer]
206
209
  # @return [Integer] bytes read, or negative errno
207
- def io_read(io, buffer, length, offset = 0)
210
+ def io_read_v3(io, buffer, length, offset = 0)
208
211
  # Native io_read_object extracts the descriptor in Zig, skipping a
209
212
  # `respond_to?(:fileno)` + `io.fileno` method-send pair per call.
210
213
  native_result = @selector.io_read_object(io, buffer, length, offset)
@@ -219,14 +222,39 @@ module CarbonFiber
219
222
  end
220
223
  end
221
224
 
222
- # Write from a buffer to an IO via the native selector.
225
+ # Read from an IO into a buffer via the native selector (Ruby 4.1+
226
+ # contract: offset before length, single transfer). One nonblocking
227
+ # attempt; short results and -EAGAIN are returned directly, and Ruby's
228
+ # own read loop composes retries via io_wait.
229
+ # @param io [IO]
230
+ # @param buffer [IO::Buffer]
231
+ # @param offset [Integer]
232
+ # @param length [Integer] maximum bytes for this transfer
233
+ # @return [Integer] bytes read, or negative errno
234
+ def io_read_v4(io, buffer, offset, length)
235
+ return 0 if length.zero?
236
+
237
+ native_result = @selector.io_read_object(io, buffer, length, offset)
238
+ return native_result unless native_result.nil?
239
+
240
+ await_background_operation do
241
+ Fiber.blocking { buffer.read(io, offset, length) }
242
+ end
243
+ rescue NoMethodError, TypeError
244
+ await_background_operation do
245
+ Fiber.blocking { buffer.read(io, offset, length) }
246
+ end
247
+ end
248
+
249
+ # Write from a buffer to an IO via the native selector (legacy contract,
250
+ # Ruby 3.4 through 4.0: length before offset, minimum-progress semantics).
223
251
  # Falls back to a background thread for non-socket descriptors.
224
252
  # @param io [IO]
225
253
  # @param buffer [IO::Buffer]
226
254
  # @param length [Integer]
227
255
  # @param offset [Integer]
228
256
  # @return [Integer] bytes written, or negative errno
229
- def io_write(io, buffer, length, offset = 0)
257
+ def io_write_v3(io, buffer, length, offset = 0)
230
258
  native_result = @selector.io_write_object(io, buffer, length, offset)
231
259
  return native_result unless native_result.nil?
232
260
 
@@ -239,6 +267,38 @@ module CarbonFiber
239
267
  end
240
268
  end
241
269
 
270
+ # Write from a buffer to an IO via the native selector (Ruby 4.1+
271
+ # contract: offset before length, single transfer).
272
+ # @param io [IO]
273
+ # @param buffer [IO::Buffer]
274
+ # @param offset [Integer]
275
+ # @param length [Integer] maximum bytes for this transfer
276
+ # @return [Integer] bytes written, or negative errno
277
+ def io_write_v4(io, buffer, offset, length)
278
+ return 0 if length.zero?
279
+
280
+ native_result = @selector.io_write_object(io, buffer, length, offset)
281
+ return native_result unless native_result.nil?
282
+
283
+ await_background_operation do
284
+ Fiber.blocking { buffer.write(io, offset, length) }
285
+ end
286
+ rescue NoMethodError, TypeError
287
+ await_background_operation do
288
+ Fiber.blocking { buffer.write(io, offset, length) }
289
+ end
290
+ end
291
+
292
+ # The public hook names follow the contract of the running Ruby;
293
+ # both generations stay defined for direct testing.
294
+ if CarbonFiber.io_contract_v4?
295
+ alias_method :io_read, :io_read_v4
296
+ alias_method :io_write, :io_write_v4
297
+ else
298
+ alias_method :io_read, :io_read_v3
299
+ alias_method :io_write, :io_write_v3
300
+ end
301
+
242
302
  # Blocking IO.select on a background thread.
243
303
  def io_select(...)
244
304
  await_background_operation do
@@ -246,15 +306,14 @@ module CarbonFiber
246
306
  end
247
307
  end
248
308
 
249
- # Cancel pending waiters on an IO and close the descriptor.
250
- # @param io [IO]
251
- def io_close(io)
252
- descriptor = io.respond_to?(:to_i) ? io.to_i : io
309
+ # Cancel pending waiters on a descriptor and close it.
310
+ # @param descriptor [Integer]
311
+ def io_close(descriptor)
253
312
  @selector.io_close(descriptor, IOError.new("stream closed while waiting"))
254
313
 
255
314
  Fiber.blocking do
256
- target = io.is_a?(IO) ? io : IO.for_fd(descriptor.to_i)
257
- target.close unless target.closed?
315
+ io = IO.for_fd(descriptor)
316
+ io.close unless io.closed?
258
317
  end
259
318
 
260
319
  true
@@ -278,14 +337,28 @@ module CarbonFiber
278
337
  end
279
338
  end
280
339
 
281
- # Resolve a hostname to addresses via Resolv.
340
+ # Resolve a hostname to addresses.
341
+ #
342
+ # The main Ractor uses Resolv: it speaks DNS over sockets the scheduler
343
+ # already multiplexes, so lookups never block the loop. Resolv keeps
344
+ # class-level state that Ractor isolation forbids, so a scheduler running
345
+ # in another Ractor asks the platform resolver instead. That call blocks
346
+ # its thread, and from a scheduled fiber it would re-enter this very
347
+ # hook, so it runs on a background thread like process_wait does.
348
+ # Unknown hosts yield an empty list on both paths.
282
349
  # @param hostname [String]
283
350
  # @return [Array<String>]
284
351
  def address_resolve(hostname)
285
352
  if hostname.include?("%")
286
353
  hostname = hostname.split("%", 2).first
287
354
  end
288
- Resolv.getaddresses(hostname)
355
+ return Resolv.getaddresses(hostname) if @main_ractor
356
+
357
+ await_background_operation do
358
+ Addrinfo.getaddrinfo(hostname, nil, nil, :STREAM).map(&:ip_address)
359
+ rescue SocketError
360
+ []
361
+ end
289
362
  end
290
363
 
291
364
  # Run an arbitrary callable on a background thread.
@@ -2,5 +2,5 @@
2
2
 
3
3
  module CarbonFiber
4
4
  # @return [String] the current gem version
5
- VERSION = "0.1.2"
5
+ VERSION = "0.2.0"
6
6
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: carbon_fiber
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yaroslav Markin
@@ -121,6 +121,7 @@ files:
121
121
  - README.md
122
122
  - lib/carbon_fiber.rb
123
123
  - lib/carbon_fiber/async.rb
124
+ - lib/carbon_fiber/io_contract.rb
124
125
  - lib/carbon_fiber/native.rb
125
126
  - lib/carbon_fiber/native/fallback.rb
126
127
  - lib/carbon_fiber/scheduler.rb
@@ -148,7 +149,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
148
149
  - !ruby/object:Gem::Version
149
150
  version: '0'
150
151
  requirements: []
151
- rubygems_version: 4.0.6
152
+ rubygems_version: 4.0.16
152
153
  specification_version: 4
153
154
  summary: High-performance Ruby Fiber Scheduler backed by Zig with libxev. Pure Ruby
154
155
  and gem async.