io-event 1.14.5 → 1.16.2

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: f6141a80c8923fb24a4073f61f45a2e2f8065e4f2b415108caa39229efe37d0b
4
- data.tar.gz: 298a552f835530283020945d5005912263d0dc3042271fbc5eeb84d3f00b8bda
3
+ metadata.gz: 8cc6abcf010ce881e4623a5736241660d1ebf6a84883767d761616ddcb23bf4e
4
+ data.tar.gz: 4a438756e87e8feaefaafb40014f36c2b240fbbd837d4581c657eb310cb9b95a
5
5
  SHA512:
6
- metadata.gz: 64566ccf2e42e38dde477984ae4c511d2c673b1869e266f49ecda467ddcccef28167a345dcca11126e05e0345ebe58b550981e1c135ca982a8ad7b56d5748418
7
- data.tar.gz: c56c85168138319c4c527843484bfbbd7a3b685e72bf546aa32e6c01d6476ccc32e3f073aa1062d8699e916eba1d78de7fe263caff00db376c522c220ddc1aa1
6
+ metadata.gz: b910e009ff697f179290d916e5914621d25a5478e0f09ac806563a0c3808b30fc28ca652042608e60625830468f37088943559c590112dfadb0d3a48d3b54688
7
+ data.tar.gz: c7501e4ef87e5c9744d666e43494b60b2b3c490d9e9cb55b75a87f7de199e1421b49ba0ce5e176cbaf230d6f3b8f838a5745cff81bac6f826328e7665de7a9e9
checksums.yaml.gz.sig CHANGED
Binary file
data/ext/io/event/array.h CHANGED
@@ -5,8 +5,6 @@
5
5
 
6
6
  #include <ruby.h>
7
7
  #include <stdlib.h>
8
- #include <errno.h>
9
- #include <assert.h>
10
8
 
11
9
  static const size_t IO_EVENT_ARRAY_MAXIMUM_COUNT = SIZE_MAX / sizeof(void*);
12
10
  static const size_t IO_EVENT_ARRAY_DEFAULT_COUNT = 128;
@@ -28,26 +26,18 @@ struct IO_Event_Array {
28
26
  void (*element_free)(void*);
29
27
  };
30
28
 
31
- inline static int IO_Event_Array_initialize(struct IO_Event_Array *array, size_t count, size_t element_size)
29
+ // Initialise an empty array. Raises `NoMemoryError` if Ruby's allocator cannot satisfy the request.
30
+ inline static void IO_Event_Array_initialize(struct IO_Event_Array *array, size_t count, size_t element_size)
32
31
  {
33
32
  array->limit = 0;
34
33
  array->element_size = element_size;
35
34
 
36
35
  if (count) {
37
- array->base = (void**)calloc(count, sizeof(void*));
38
-
39
- if (array->base == NULL) {
40
- return -1;
41
- }
42
-
36
+ array->base = (void**)xcalloc(count, sizeof(void*));
43
37
  array->count = count;
44
-
45
- return 1;
46
38
  } else {
47
39
  array->base = NULL;
48
40
  array->count = 0;
49
-
50
- return 0;
51
41
  }
52
42
  }
53
43
 
@@ -72,24 +62,24 @@ inline static void IO_Event_Array_free(struct IO_Event_Array *array)
72
62
  if (element) {
73
63
  array->element_free(element);
74
64
 
75
- free(element);
65
+ xfree(element);
76
66
  }
77
67
  }
78
68
 
79
- free(base);
69
+ xfree(base);
80
70
  }
81
71
  }
82
72
 
83
- inline static int IO_Event_Array_resize(struct IO_Event_Array *array, size_t count)
73
+ // Grow the array so it can hold at least `count` slots. Raises `RangeError` if `count` exceeds the per-array maximum, or `NoMemoryError` if Ruby's allocator cannot satisfy the request. On success the array's existing contents are preserved and any newly added slots are zero-initialised.
74
+ inline static void IO_Event_Array_resize(struct IO_Event_Array *array, size_t count)
84
75
  {
85
76
  if (count <= array->count) {
86
77
  // Already big enough:
87
- return 0;
78
+ return;
88
79
  }
89
80
 
90
81
  if (count > IO_EVENT_ARRAY_MAXIMUM_COUNT) {
91
- errno = ENOMEM;
92
- return -1;
82
+ rb_raise(rb_eRangeError, "Array size exceeds maximum count!");
93
83
  }
94
84
 
95
85
  size_t new_count = array->count;
@@ -107,31 +97,24 @@ inline static int IO_Event_Array_resize(struct IO_Event_Array *array, size_t cou
107
97
  new_count *= 2;
108
98
  }
109
99
 
110
- void **new_base = (void**)realloc(array->base, new_count * sizeof(void*));
111
-
112
- if (new_base == NULL) {
113
- return -1;
114
- }
100
+ // `xrealloc2` checks `new_count * sizeof(void*)` for overflow and raises `NoMemoryError` on allocation failure, so no NULL check is required.
101
+ void **new_base = (void**)xrealloc2(array->base, new_count, sizeof(void*));
115
102
 
116
103
  // Zero out the new memory:
117
104
  memset(new_base + array->count, 0, (new_count - array->count) * sizeof(void*));
118
105
 
119
106
  array->base = (void**)new_base;
120
107
  array->count = new_count;
121
-
122
- // Resizing sucessful:
123
- return 1;
124
108
  }
125
109
 
110
+ // Look up the element at the given index, allocating it lazily on first access. Raises if the array cannot be grown or the element cannot be allocated.
126
111
  inline static void* IO_Event_Array_lookup(struct IO_Event_Array *array, size_t index)
127
112
  {
128
113
  size_t count = index + 1;
129
114
 
130
- // Resize the array if necessary:
115
+ // Resize the array if necessary (may raise):
131
116
  if (count > array->count) {
132
- if (IO_Event_Array_resize(array, count) == -1) {
133
- return NULL;
134
- }
117
+ IO_Event_Array_resize(array, count);
135
118
  }
136
119
 
137
120
  // Get the element:
@@ -139,8 +122,8 @@ inline static void* IO_Event_Array_lookup(struct IO_Event_Array *array, size_t i
139
122
 
140
123
  // Allocate the element if it doesn't exist:
141
124
  if (*element == NULL) {
142
- *element = malloc(array->element_size);
143
- assert(*element);
125
+ // Ruby's allocator triggers GC on memory pressure and raises `NoMemoryError` on failure, so no NULL check is required.
126
+ *element = xmalloc(array->element_size);
144
127
 
145
128
  if (array->element_initialize) {
146
129
  array->element_initialize(*element);
@@ -166,7 +149,7 @@ inline static void IO_Event_Array_truncate(struct IO_Event_Array *array, size_t
166
149
  void **element = array->base + i;
167
150
  if (*element) {
168
151
  array->element_free(*element);
169
- free(*element);
152
+ xfree(*element);
170
153
  *element = NULL;
171
154
  }
172
155
  }
@@ -175,13 +175,8 @@ static const rb_data_type_t IO_Event_Selector_EPoll_Type = {
175
175
  inline static
176
176
  struct IO_Event_Selector_EPoll_Descriptor * IO_Event_Selector_EPoll_Descriptor_lookup(struct IO_Event_Selector_EPoll *selector, int descriptor)
177
177
  {
178
- struct IO_Event_Selector_EPoll_Descriptor *epoll_descriptor = IO_Event_Array_lookup(&selector->descriptors, descriptor);
179
-
180
- if (!epoll_descriptor) {
181
- rb_sys_fail("IO_Event_Selector_EPoll_Descriptor_lookup:IO_Event_Array_lookup");
182
- }
183
-
184
- return epoll_descriptor;
178
+ // `IO_Event_Array_lookup` raises on allocation failure, so the returned pointer is always non-NULL.
179
+ return IO_Event_Array_lookup(&selector->descriptors, descriptor);
185
180
  }
186
181
 
187
182
  static inline
@@ -324,10 +319,7 @@ VALUE IO_Event_Selector_EPoll_allocate(VALUE self) {
324
319
 
325
320
  selector->descriptors.element_initialize = IO_Event_Selector_EPoll_Descriptor_initialize;
326
321
  selector->descriptors.element_free = IO_Event_Selector_EPoll_Descriptor_free;
327
- int result = IO_Event_Array_initialize(&selector->descriptors, IO_EVENT_ARRAY_DEFAULT_COUNT, sizeof(struct IO_Event_Selector_EPoll_Descriptor));
328
- if (result < 0) {
329
- rb_sys_fail("IO_Event_Selector_EPoll_allocate:IO_Event_Array_initialize");
330
- }
322
+ IO_Event_Array_initialize(&selector->descriptors, IO_EVENT_ARRAY_DEFAULT_COUNT, sizeof(struct IO_Event_Selector_EPoll_Descriptor));
331
323
 
332
324
  return instance;
333
325
  }
@@ -615,6 +607,11 @@ VALUE io_read_loop(VALUE _arguments) {
615
607
  size_t offset = arguments->offset;
616
608
  size_t total = 0;
617
609
 
610
+ // Ensure offset is within the bounds of the buffer to avoid size_t underflow and out-of-bounds pointer arithmetic on (char *)base + offset.
611
+ if (offset > size) {
612
+ return rb_fiber_scheduler_io_result(-1, EINVAL);
613
+ }
614
+
618
615
  size_t maximum_size = size - offset;
619
616
  while (maximum_size) {
620
617
  ssize_t result = read(arguments->descriptor, (char*)base+offset, maximum_size);
@@ -713,6 +710,11 @@ VALUE io_write_loop(VALUE _arguments) {
713
710
  rb_raise(rb_eRuntimeError, "Length exceeds size of buffer!");
714
711
  }
715
712
 
713
+ // Ensure offset is within the bounds of the buffer to avoid size_t underflow and out-of-bounds pointer arithmetic on (char *)base + offset.
714
+ if (offset > size) {
715
+ return rb_fiber_scheduler_io_result(-1, EINVAL);
716
+ }
717
+
716
718
  size_t maximum_size = size - offset;
717
719
  while (maximum_size) {
718
720
  ssize_t result = write(arguments->descriptor, (char*)base+offset, maximum_size);
@@ -174,13 +174,8 @@ static const rb_data_type_t IO_Event_Selector_KQueue_Type = {
174
174
  inline static
175
175
  struct IO_Event_Selector_KQueue_Descriptor * IO_Event_Selector_KQueue_Descriptor_lookup(struct IO_Event_Selector_KQueue *selector, uintptr_t descriptor)
176
176
  {
177
- struct IO_Event_Selector_KQueue_Descriptor *kqueue_descriptor = IO_Event_Array_lookup(&selector->descriptors, descriptor);
178
-
179
- if (!kqueue_descriptor) {
180
- rb_sys_fail("IO_Event_Selector_KQueue_Descriptor_lookup:IO_Event_Array_lookup");
181
- }
182
-
183
- return kqueue_descriptor;
177
+ // `IO_Event_Array_lookup` raises on allocation failure, so the returned pointer is always non-NULL.
178
+ return IO_Event_Array_lookup(&selector->descriptors, descriptor);
184
179
  }
185
180
 
186
181
  inline static
@@ -299,10 +294,7 @@ VALUE IO_Event_Selector_KQueue_allocate(VALUE self) {
299
294
  selector->descriptors.element_initialize = IO_Event_Selector_KQueue_Descriptor_initialize;
300
295
  selector->descriptors.element_free = IO_Event_Selector_KQueue_Descriptor_free;
301
296
 
302
- int result = IO_Event_Array_initialize(&selector->descriptors, IO_EVENT_ARRAY_DEFAULT_COUNT, sizeof(struct IO_Event_Selector_KQueue_Descriptor));
303
- if (result < 0) {
304
- rb_sys_fail("IO_Event_Selector_KQueue_allocate:IO_Event_Array_initialize");
305
- }
297
+ IO_Event_Array_initialize(&selector->descriptors, IO_EVENT_ARRAY_DEFAULT_COUNT, sizeof(struct IO_Event_Selector_KQueue_Descriptor));
306
298
 
307
299
  return instance;
308
300
  }
@@ -605,6 +597,11 @@ VALUE io_read_loop(VALUE _arguments) {
605
597
 
606
598
  if (DEBUG_IO_READ) fprintf(stderr, "io_read_loop(fd=%d, length=%zu)\n", arguments->descriptor, length);
607
599
 
600
+ // Ensure offset is within the bounds of the buffer to avoid size_t underflow and out-of-bounds pointer arithmetic on (char *)base + offset.
601
+ if (offset > size) {
602
+ return rb_fiber_scheduler_io_result(-1, EINVAL);
603
+ }
604
+
608
605
  size_t maximum_size = size - offset;
609
606
  while (maximum_size) {
610
607
  if (DEBUG_IO_READ) fprintf(stderr, "read(%d, +%ld, %ld)\n", arguments->descriptor, offset, maximum_size);
@@ -713,6 +710,11 @@ VALUE io_write_loop(VALUE _arguments) {
713
710
 
714
711
  if (DEBUG_IO_WRITE) fprintf(stderr, "io_write_loop(fd=%d, length=%zu)\n", arguments->descriptor, length);
715
712
 
713
+ // Ensure offset is within the bounds of the buffer to avoid size_t underflow and out-of-bounds pointer arithmetic on (char *)base + offset.
714
+ if (offset > size) {
715
+ return rb_fiber_scheduler_io_result(-1, EINVAL);
716
+ }
717
+
716
718
  size_t maximum_size = size - offset;
717
719
  while (maximum_size) {
718
720
  if (DEBUG_IO_WRITE) fprintf(stderr, "write(%d, +%ld, %ld, length=%zu)\n", arguments->descriptor, offset, maximum_size, length);
@@ -106,8 +106,19 @@ VALUE IO_Event_Selector_loop_resume(struct IO_Event_Selector *backend, VALUE fib
106
106
 
107
107
  VALUE IO_Event_Selector_loop_yield(struct IO_Event_Selector *backend)
108
108
  {
109
- // TODO Why is this assertion failing in async?
110
- // RUBY_ASSERT(backend->loop != IO_Event_Fiber_current());
109
+ // Under normal operation, a user fiber yields back to the event loop fiber.
110
+ // However, in some cases (e.g. blocking IO called from within the scheduler
111
+ // fiber itself), the current fiber may already be the loop fiber. In that case,
112
+ // transferring to ourselves would be a no-op in Ruby, but it signals a misuse:
113
+ // the event loop fiber should never need to yield to itself, as nothing else
114
+ // would be running to resume it. We return immediately rather than self-transferring.
115
+ if (backend->loop == IO_Event_Fiber_current()) {
116
+ // Uncomment to investigate the callsite that triggers this condition:
117
+ // rb_warning("IO_Event_Selector_loop_yield: current fiber is the loop fiber");
118
+ // rb_funcall(rb_mKernel, rb_intern("puts"), 1, rb_funcall(rb_cThread, rb_intern("current"), 0));
119
+ return Qnil;
120
+ }
121
+
111
122
  return IO_Event_Fiber_transfer(backend->loop, 0, NULL);
112
123
  }
113
124
 
@@ -235,8 +246,8 @@ VALUE IO_Event_Selector_raise(struct IO_Event_Selector *backend, int argc, VALUE
235
246
 
236
247
  void IO_Event_Selector_ready_push(struct IO_Event_Selector *backend, VALUE fiber)
237
248
  {
238
- struct IO_Event_Selector_Queue *waiting = malloc(sizeof(struct IO_Event_Selector_Queue));
239
- assert(waiting);
249
+ // Ruby's allocator triggers GC on memory pressure and raises `NoMemoryError` on failure, so no NULL check is required.
250
+ struct IO_Event_Selector_Queue *waiting = xmalloc(sizeof(struct IO_Event_Selector_Queue));
240
251
 
241
252
  waiting->head = NULL;
242
253
  waiting->tail = NULL;
@@ -257,7 +268,7 @@ void IO_Event_Selector_ready_pop(struct IO_Event_Selector *backend, struct IO_Ev
257
268
  if (ready->flags & IO_EVENT_SELECTOR_QUEUE_INTERNAL) {
258
269
  // This means that the fiber was added to the ready queue by the selector itself, and we need to transfer control to it, but before we do that, we need to remove it from the queue, as there is no expectation that returning from `transfer` will remove it.
259
270
  queue_pop(backend, ready);
260
- free(ready);
271
+ xfree(ready);
261
272
  } else if (ready->flags & IO_EVENT_SELECTOR_QUEUE_FIBER) {
262
273
  // This means the fiber added itself to the ready queue, and we need to transfer control back to it. Transferring control back to the fiber will call `queue_pop` and remove it from the queue.
263
274
  } else {
@@ -8,9 +8,12 @@
8
8
 
9
9
  #include <liburing.h>
10
10
  #include <poll.h>
11
+ #include <stdbool.h>
11
12
  #include <stdint.h>
12
13
  #include <time.h>
13
14
 
15
+ #include "../interrupt.h"
16
+
14
17
  #include "pidfd.c"
15
18
 
16
19
  #include <linux/version.h>
@@ -29,13 +32,24 @@ struct IO_Event_Selector_URing
29
32
  {
30
33
  struct IO_Event_Selector backend;
31
34
  struct io_uring ring;
32
- size_t pending;
33
35
 
34
36
  // Flag indicating whether the selector is currently blocked in a system call.
35
37
  // Set to 1 when blocked in io_uring_wait_cqe_timeout() without GVL, 0 otherwise.
36
- // Used by wakeup() to determine if an interrupt signal is needed.
37
38
  int blocked;
38
39
 
40
+ // Interrupt used to wake the selector from another thread without touching the ring's SQ.
41
+ // This allows IORING_SETUP_SINGLE_ISSUER: only the owner thread ever submits SQEs.
42
+ // Uses eventfd on Linux, pipe fallback elsewhere.
43
+ struct IO_Event_Interrupt interrupt;
44
+
45
+ // Whether an async read on interrupt is currently pending in the ring.
46
+ // The read is re-submitted before each blocking wait when not registered.
47
+ int wakeup_registered;
48
+
49
+ // Buffer for the pending async read on the interrupt descriptor.
50
+ // Must remain valid for the lifetime of the in-flight SQE.
51
+ uint64_t wakeup_value;
52
+
39
53
  struct timespec idle_duration;
40
54
 
41
55
  struct IO_Event_Array completions;
@@ -101,6 +115,12 @@ void IO_Event_Selector_URing_Type_compact(void *_selector)
101
115
  static
102
116
  void close_internal(struct IO_Event_Selector_URing *selector)
103
117
  {
118
+ if (selector->interrupt.descriptor >= 0) {
119
+ IO_Event_Interrupt_close(&selector->interrupt);
120
+ selector->interrupt.descriptor = -1;
121
+ selector->wakeup_registered = 0;
122
+ }
123
+
104
124
  if (selector->ring.ring_fd >= 0) {
105
125
  io_uring_queue_exit(&selector->ring);
106
126
  selector->ring.ring_fd = -1;
@@ -218,17 +238,15 @@ VALUE IO_Event_Selector_URing_allocate(VALUE self) {
218
238
  IO_Event_Selector_initialize(&selector->backend, self, Qnil);
219
239
  selector->ring.ring_fd = -1;
220
240
 
221
- selector->pending = 0;
222
241
  selector->blocked = 0;
242
+ selector->interrupt.descriptor = -1;
243
+ selector->wakeup_registered = 0;
223
244
 
224
245
  IO_Event_List_initialize(&selector->free_list);
225
246
 
226
247
  selector->completions.element_initialize = IO_Event_Selector_URing_Completion_initialize;
227
248
  selector->completions.element_free = IO_Event_Selector_URing_Completion_free;
228
- int result = IO_Event_Array_initialize(&selector->completions, IO_EVENT_ARRAY_DEFAULT_COUNT, sizeof(struct IO_Event_Selector_URing_Completion));
229
- if (result < 0) {
230
- rb_sys_fail("IO_Event_Selector_URing_allocate:IO_Event_Array_initialize");
231
- }
249
+ IO_Event_Array_initialize(&selector->completions, IO_EVENT_ARRAY_DEFAULT_COUNT, sizeof(struct IO_Event_Selector_URing_Completion));
232
250
 
233
251
  return instance;
234
252
  }
@@ -240,7 +258,42 @@ VALUE IO_Event_Selector_URing_initialize(VALUE self, VALUE loop) {
240
258
  TypedData_Get_Struct(self, struct IO_Event_Selector_URing, &IO_Event_Selector_URing_Type, selector);
241
259
 
242
260
  IO_Event_Selector_initialize(&selector->backend, self, loop);
243
- int result = io_uring_queue_init(URING_ENTRIES, &selector->ring, 0);
261
+
262
+ unsigned int flags = 0;
263
+ // IORING_SETUP_SINGLE_ISSUER (kernel 6.0+): only the owner thread submits SQEs.
264
+ // Safe here because wakeup() uses eventfd (no ring access from other threads).
265
+ #ifdef IORING_SETUP_SINGLE_ISSUER
266
+ flags |= IORING_SETUP_SINGLE_ISSUER;
267
+ #endif
268
+ // IORING_SETUP_DEFER_TASKRUN (kernel 6.1+, requires SINGLE_ISSUER): defer io_uring
269
+ // task work to the application thread rather than a kernel thread, reducing
270
+ // cross-CPU signaling overhead.
271
+ #ifdef IORING_SETUP_DEFER_TASKRUN
272
+ flags |= IORING_SETUP_DEFER_TASKRUN;
273
+ #endif
274
+ // IORING_SETUP_TASKRUN_FLAG (kernel 5.19+, always available alongside
275
+ // DEFER_TASKRUN): the kernel surfaces IORING_SQ_TASKRUN in sq.flags whenever
276
+ // task work is pending, so select() can skip the io_uring_get_events()
277
+ // syscall when there is nothing deferred to flush.
278
+ #ifdef IORING_SETUP_TASKRUN_FLAG
279
+ flags |= IORING_SETUP_TASKRUN_FLAG;
280
+ #endif
281
+ // IORING_SETUP_SUBMIT_ALL (kernel 5.18+): keep processing the rest of the SQE
282
+ // batch even when one fails, reducing the frequency of short submits.
283
+ #ifdef IORING_SETUP_SUBMIT_ALL
284
+ flags |= IORING_SETUP_SUBMIT_ALL;
285
+ #endif
286
+
287
+ int result = io_uring_queue_init(URING_ENTRIES, &selector->ring, flags);
288
+
289
+ #ifdef IORING_SETUP_SUBMIT_ALL
290
+ if (result == -EINVAL) {
291
+ // IORING_SETUP_SUBMIT_ALL was added in Linux 5.18; retry without it.
292
+ if (DEBUG) fprintf(stderr, "IO_Event_Selector_URing_initialize: no IORING_SETUP_SUBMIT_ALL\n");
293
+ flags &= ~IORING_SETUP_SUBMIT_ALL;
294
+ result = io_uring_queue_init(URING_ENTRIES, &selector->ring, flags);
295
+ }
296
+ #endif
244
297
 
245
298
  if (result < 0) {
246
299
  rb_syserr_fail(-result, "IO_Event_Selector_URing_initialize:io_uring_queue_init");
@@ -248,6 +301,16 @@ VALUE IO_Event_Selector_URing_initialize(VALUE self, VALUE loop) {
248
301
 
249
302
  rb_update_max_fd(selector->ring.ring_fd);
250
303
 
304
+ // Interrupt for cross-thread wakeup: another thread calls signal(); the owner
305
+ // thread submits an async read before each blocking wait so the ring wakes up
306
+ // without the waking thread ever touching the SQ.
307
+ IO_Event_Interrupt_open(&selector->interrupt);
308
+ if (selector->interrupt.descriptor < 0) {
309
+ io_uring_queue_exit(&selector->ring);
310
+ selector->ring.ring_fd = -1;
311
+ rb_sys_fail("IO_Event_Selector_URing_initialize:IO_Event_Interrupt_open");
312
+ }
313
+
251
314
  return self;
252
315
  }
253
316
 
@@ -353,60 +416,55 @@ void IO_Event_Selector_URing_dump_completion_queue(struct IO_Event_Selector_URin
353
416
  }
354
417
  }
355
418
 
356
- // Flush the submission queue if pending operations are present.
419
+ // Flush the submission queue, optionally yielding if unsuccessful.
357
420
  static
358
- int io_uring_submit_flush(struct IO_Event_Selector_URing *selector) {
359
- if (selector->pending) {
360
- if (DEBUG) fprintf(stderr, "io_uring_submit_flush(pending=%ld)\n", selector->pending);
361
-
362
- // Try to submit:
421
+ int io_uring_submit_all(struct IO_Event_Selector_URing *selector, bool yield) {
422
+ struct io_uring *ring = &selector->ring;
423
+
424
+ while (io_uring_sq_ready(ring) > 0) {
363
425
  int result = io_uring_submit(&selector->ring);
364
426
 
365
- if (result >= 0) {
366
- // If it was submitted, reset pending count:
367
- selector->pending = 0;
368
- } else if (result != -EBUSY && result != -EAGAIN) {
369
- rb_syserr_fail(-result, "io_uring_submit_flush:io_uring_submit");
427
+ if (result == -EBUSY || result == -EAGAIN) {
428
+ if (yield) IO_Event_Selector_yield(&selector->backend);
429
+ } else if (result < 0) {
430
+ rb_syserr_fail(-result, "io_uring_submit_all:io_uring_submit");
431
+ return result;
370
432
  }
371
-
372
- return result;
373
433
  }
374
-
434
+
435
+ if (DEBUG) IO_Event_Selector_URing_dump_completion_queue(selector);
436
+ return 0;
437
+ }
438
+
439
+ // Flush the submission queue if pending operations are present.
440
+ static
441
+ int io_uring_submit_flush(struct IO_Event_Selector_URing *selector) {
375
442
  if (DEBUG) {
376
- IO_Event_Selector_URing_dump_completion_queue(selector);
443
+ unsigned pending = io_uring_sq_ready(&selector->ring);
444
+ fprintf(stderr, "io_uring_submit_flush(pending=%u)\n", pending);
377
445
  }
378
-
379
- return 0;
446
+
447
+ return io_uring_submit_all(selector, false);
380
448
  }
381
449
 
382
450
  // Immediately flush the submission queue, yielding to the event loop if it was not successful.
383
451
  static
384
452
  int io_uring_submit_now(struct IO_Event_Selector_URing *selector) {
385
- if (DEBUG) fprintf(stderr, "io_uring_submit_now(pending=%ld)\n", selector->pending);
386
-
387
- while (true) {
388
- int result = io_uring_submit(&selector->ring);
389
-
390
- if (result >= 0) {
391
- selector->pending = 0;
392
- if (DEBUG) IO_Event_Selector_URing_dump_completion_queue(selector);
393
- return result;
394
- }
395
-
396
- if (result == -EBUSY || result == -EAGAIN) {
397
- IO_Event_Selector_yield(&selector->backend);
398
- } else {
399
- rb_syserr_fail(-result, "io_uring_submit_now:io_uring_submit");
400
- }
453
+ if (DEBUG) {
454
+ unsigned pending = io_uring_sq_ready(&selector->ring);
455
+ fprintf(stderr, "io_uring_submit_now(pending=%u)\n", pending);
401
456
  }
457
+
458
+ return io_uring_submit_all(selector, true);
402
459
  }
403
460
 
404
461
  // Submit a pending operation. This does not submit the operation immediately, but instead defers it to the next call to `io_uring_submit_flush` or `io_uring_submit_now`. This is useful for operations that are not urgent, but should be used with care as it can lead to a deadlock if the submission queue is not flushed.
405
462
  static
406
463
  void io_uring_submit_pending(struct IO_Event_Selector_URing *selector) {
407
- selector->pending += 1;
408
-
409
- if (DEBUG) fprintf(stderr, "io_uring_submit_pending(ring=%p, pending=%ld)\n", &selector->ring, selector->pending);
464
+ if (DEBUG) {
465
+ unsigned pending = io_uring_sq_ready(&selector->ring);
466
+ fprintf(stderr, "io_uring_submit_pending(ring=%p, pending=%u)\n", &selector->ring, pending);
467
+ }
410
468
  }
411
469
 
412
470
  struct io_uring_sqe * io_get_sqe(struct IO_Event_Selector_URing *selector) {
@@ -418,7 +476,7 @@ struct io_uring_sqe * io_get_sqe(struct IO_Event_Selector_URing *selector) {
418
476
 
419
477
  sqe = io_uring_get_sqe(&selector->ring);
420
478
  }
421
-
479
+
422
480
  return sqe;
423
481
  }
424
482
 
@@ -710,6 +768,11 @@ VALUE IO_Event_Selector_URing_io_read(VALUE self, VALUE fiber, VALUE io, VALUE b
710
768
  size_t total = 0;
711
769
  off_t from = io_seekable(descriptor);
712
770
 
771
+ // Ensure offset is within the bounds of the buffer to avoid size_t underflow and out-of-bounds pointer arithmetic on (char *)base + offset.
772
+ if (offset > size) {
773
+ return rb_fiber_scheduler_io_result(-1, EINVAL);
774
+ }
775
+
713
776
  size_t maximum_size = size - offset;
714
777
 
715
778
  // Are we performing a non-blocking read?
@@ -775,6 +838,11 @@ VALUE IO_Event_Selector_URing_io_pread(VALUE self, VALUE fiber, VALUE io, VALUE
775
838
  size_t total = 0;
776
839
  off_t from = NUM2OFFT(_from);
777
840
 
841
+ // Ensure offset is within the bounds of the buffer to avoid size_t underflow and out-of-bounds pointer arithmetic on (char *)base + offset.
842
+ if (offset > size) {
843
+ return rb_fiber_scheduler_io_result(-1, EINVAL);
844
+ }
845
+
778
846
  size_t maximum_size = size - offset;
779
847
  while (maximum_size) {
780
848
  int result = io_read(selector, fiber, descriptor, (char*)base+offset, maximum_size, from);
@@ -892,6 +960,11 @@ VALUE IO_Event_Selector_URing_io_write(VALUE self, VALUE fiber, VALUE io, VALUE
892
960
  rb_raise(rb_eRuntimeError, "Length exceeds size of buffer!");
893
961
  }
894
962
 
963
+ // Ensure offset is within the bounds of the buffer to avoid size_t underflow and out-of-bounds pointer arithmetic on (char *)base + offset.
964
+ if (offset > size) {
965
+ return rb_fiber_scheduler_io_result(-1, EINVAL);
966
+ }
967
+
895
968
  size_t maximum_size = size - offset;
896
969
  while (maximum_size) {
897
970
  int result = io_write(selector, fiber, descriptor, (char*)base+offset, maximum_size, from);
@@ -947,6 +1020,11 @@ VALUE IO_Event_Selector_URing_io_pwrite(VALUE self, VALUE fiber, VALUE io, VALUE
947
1020
  rb_raise(rb_eRuntimeError, "Length exceeds size of buffer!");
948
1021
  }
949
1022
 
1023
+ // Ensure offset is within the bounds of the buffer to avoid size_t underflow and out-of-bounds pointer arithmetic on (char *)base + offset.
1024
+ if (offset > size) {
1025
+ return rb_fiber_scheduler_io_result(-1, EINVAL);
1026
+ }
1027
+
950
1028
  size_t maximum_size = size - offset;
951
1029
  while (maximum_size) {
952
1030
  int result = io_write(selector, fiber, descriptor, (char*)base+offset, maximum_size, from);
@@ -977,12 +1055,13 @@ VALUE IO_Event_Selector_URing_io_pwrite(VALUE self, VALUE fiber, VALUE io, VALUE
977
1055
 
978
1056
  static const int ASYNC_CLOSE = 1;
979
1057
 
980
- VALUE IO_Event_Selector_URing_io_close(VALUE self, VALUE io) {
1058
+ VALUE IO_Event_Selector_URing_io_close(VALUE self, VALUE _descriptor) {
981
1059
  struct IO_Event_Selector_URing *selector = NULL;
982
1060
  TypedData_Get_Struct(self, struct IO_Event_Selector_URing, &IO_Event_Selector_URing_Type, selector);
983
1061
 
984
- int descriptor = IO_Event_Selector_io_descriptor(io);
985
-
1062
+ // Ruby's fiber scheduler `io_close` hook is invoked with a raw integer file descriptor (Ruby 4.0+); it does not pass the `IO` object.
1063
+ int descriptor = RB_NUM2INT(_descriptor);
1064
+
986
1065
  if (ASYNC_CLOSE) {
987
1066
  struct io_uring_sqe *sqe = io_get_sqe(selector);
988
1067
  io_uring_prep_close(sqe, descriptor);
@@ -995,8 +1074,8 @@ VALUE IO_Event_Selector_URing_io_close(VALUE self, VALUE io) {
995
1074
  } else {
996
1075
  close(descriptor);
997
1076
  }
998
-
999
- // We don't wait for the result of close since it has no use in pratice:
1077
+
1078
+ // We don't wait for the result of close since it has no use in practice:
1000
1079
  return Qtrue;
1001
1080
  }
1002
1081
 
@@ -1051,11 +1130,24 @@ void * select_internal(void *_arguments) {
1051
1130
 
1052
1131
  static
1053
1132
  int select_internal_without_gvl(struct select_arguments *arguments) {
1054
- io_uring_submit_flush(arguments->selector);
1133
+ struct IO_Event_Selector_URing *selector = arguments->selector;
1134
+
1135
+ // Submit an async read on the wakeup eventfd before releasing the GVL.
1136
+ // When wakeup() writes to the fd the read completes, consuming the counter
1137
+ // atomically — no separate poll + drain step required.
1138
+ // The address of the interrupt struct serves as a unique sentinel in user_data.
1139
+ if (!selector->wakeup_registered) {
1140
+ struct io_uring_sqe *sqe = io_get_sqe(selector);
1141
+ io_uring_prep_read(sqe, IO_Event_Interrupt_descriptor(&selector->interrupt), &selector->wakeup_value, sizeof(selector->wakeup_value), 0);
1142
+ io_uring_sqe_set_data(sqe, &selector->interrupt);
1143
+ selector->wakeup_registered = 1;
1144
+ }
1145
+
1146
+ io_uring_submit_flush(selector);
1055
1147
 
1056
- arguments->selector->blocked = 1;
1148
+ selector->blocked = 1;
1057
1149
  rb_thread_call_without_gvl(select_internal, (void *)arguments, RUBY_UBF_IO, 0);
1058
- arguments->selector->blocked = 0;
1150
+ selector->blocked = 0;
1059
1151
 
1060
1152
  if (arguments->result == -ETIME) {
1061
1153
  arguments->result = 0;
@@ -1094,6 +1186,14 @@ unsigned select_process_completions(struct IO_Event_Selector_URing *selector) {
1094
1186
  continue;
1095
1187
  }
1096
1188
 
1189
+ // Interrupt read completion — the read already consumed the counter.
1190
+ // Clear the flag so the next blocking wait re-submits the read.
1191
+ if (io_uring_cqe_get_data(cqe) == &selector->interrupt) {
1192
+ selector->wakeup_registered = 0;
1193
+ io_uring_cq_advance(ring, 1);
1194
+ continue;
1195
+ }
1196
+
1097
1197
  struct IO_Event_Selector_URing_Completion *completion = (void*)cqe->user_data;
1098
1198
  struct IO_Event_Selector_URing_Waiting *waiting = completion->waiting;
1099
1199
 
@@ -1136,6 +1236,25 @@ VALUE IO_Event_Selector_URing_select(VALUE self, VALUE duration) {
1136
1236
  // Flush any pending events:
1137
1237
  io_uring_submit_flush(selector);
1138
1238
 
1239
+ #ifdef IORING_SETUP_DEFER_TASKRUN
1240
+ // With DEFER_TASKRUN the kernel holds completions as "deferred task work"
1241
+ // rather than placing them directly into the CQ. We need to flush that work
1242
+ // into the CQ so the non-blocking select_process_completions below can see
1243
+ // it. With TASKRUN_FLAG enabled the kernel sets IORING_SQ_TASKRUN in
1244
+ // sq.flags whenever task work is pending; a relaxed atomic load is enough
1245
+ // to check, and we only pay for an io_uring_enter syscall (via
1246
+ // io_uring_get_events) when there is actually deferred work to flush.
1247
+ if (selector->ring.flags & IORING_SETUP_DEFER_TASKRUN) {
1248
+ #ifdef IORING_SETUP_TASKRUN_FLAG
1249
+ unsigned sq_flags = __atomic_load_n(selector->ring.sq.kflags, __ATOMIC_RELAXED);
1250
+ if (sq_flags & IORING_SQ_TASKRUN)
1251
+ #endif
1252
+ {
1253
+ io_uring_get_events(&selector->ring);
1254
+ }
1255
+ }
1256
+ #endif
1257
+
1139
1258
  int ready = IO_Event_Selector_ready_flush(&selector->backend);
1140
1259
 
1141
1260
  int result = select_process_completions(selector);
@@ -1179,25 +1298,10 @@ VALUE IO_Event_Selector_URing_wakeup(VALUE self) {
1179
1298
  struct IO_Event_Selector_URing *selector = NULL;
1180
1299
  TypedData_Get_Struct(self, struct IO_Event_Selector_URing, &IO_Event_Selector_URing_Type, selector);
1181
1300
 
1182
- // If we are blocking, we can schedule a nop event to wake up the selector:
1301
+ // Wake the selector by signalling the interrupt. This is safe from any thread
1302
+ // and never touches the ring's SQ, which is required for IORING_SETUP_SINGLE_ISSUER.
1183
1303
  if (selector->blocked) {
1184
- struct io_uring_sqe *sqe = NULL;
1185
-
1186
- while (true) {
1187
- sqe = io_uring_get_sqe(&selector->ring);
1188
- if (sqe) break;
1189
-
1190
- rb_thread_schedule();
1191
-
1192
- // It's possible we became unblocked already, so we can assume the selector has already cycled at least once:
1193
- if (!selector->blocked) return Qfalse;
1194
- }
1195
-
1196
- io_uring_prep_nop(sqe);
1197
- // If you don't set this line, the SQE will eventually be recycled and have valid user selector which can cause odd behaviour:
1198
- io_uring_sqe_set_data(sqe, NULL);
1199
- io_uring_submit(&selector->ring);
1200
-
1304
+ IO_Event_Interrupt_signal(&selector->interrupt);
1201
1305
  return Qtrue;
1202
1306
  }
1203
1307
 
@@ -1208,7 +1312,28 @@ VALUE IO_Event_Selector_URing_wakeup(VALUE self) {
1208
1312
 
1209
1313
  static int IO_Event_Selector_URing_supported_p(void) {
1210
1314
  struct io_uring ring;
1211
- int result = io_uring_queue_init(32, &ring, 0);
1315
+
1316
+ unsigned int flags = 0;
1317
+ #ifdef IORING_SETUP_SINGLE_ISSUER
1318
+ flags |= IORING_SETUP_SINGLE_ISSUER;
1319
+ #endif
1320
+ #ifdef IORING_SETUP_DEFER_TASKRUN
1321
+ flags |= IORING_SETUP_DEFER_TASKRUN;
1322
+ #endif
1323
+ #ifdef IORING_SETUP_TASKRUN_FLAG
1324
+ flags |= IORING_SETUP_TASKRUN_FLAG;
1325
+ #endif
1326
+ #ifdef IORING_SETUP_SUBMIT_ALL
1327
+ flags |= IORING_SETUP_SUBMIT_ALL;
1328
+ #endif
1329
+ int result = io_uring_queue_init(32, &ring, flags);
1330
+
1331
+ #ifdef IORING_SETUP_SUBMIT_ALL
1332
+ if (result == -EINVAL) {
1333
+ flags &= ~IORING_SETUP_SUBMIT_ALL;
1334
+ result = io_uring_queue_init(32, &ring, flags);
1335
+ }
1336
+ #endif
1212
1337
 
1213
1338
  if (result < 0) {
1214
1339
  rb_warn("io_uring_queue_init() was available at compile time but failed at run time: %s\n", strerror(-result));
@@ -91,11 +91,20 @@ static void worker_pool_mark(void *ptr)
91
91
  struct IO_Event_WorkerPool *pool = (struct IO_Event_WorkerPool *)ptr;
92
92
  struct IO_Event_WorkerPool_Worker *worker = pool->workers;
93
93
  while (worker) {
94
- struct IO_Event_WorkerPool_Worker *next = worker->next;
95
94
  // We need to mark the thread even though its marked through the VM's ractors because we call `join`
96
95
  // on them after their completion. They could be freed by then.
97
- rb_gc_mark(worker->thread); // thread objects are currently pinned anyway
98
- worker = next;
96
+ rb_gc_mark_movable(worker->thread);
97
+ worker = worker->next;
98
+ }
99
+ }
100
+
101
+ static void worker_pool_compact(void *ptr)
102
+ {
103
+ struct IO_Event_WorkerPool *pool = (struct IO_Event_WorkerPool *)ptr;
104
+ struct IO_Event_WorkerPool_Worker *worker = pool->workers;
105
+ while (worker) {
106
+ worker->thread = rb_gc_location(worker->thread);
107
+ worker = worker->next;
99
108
  }
100
109
  }
101
110
 
@@ -107,8 +116,8 @@ static size_t worker_pool_size(const void *ptr) {
107
116
  // Ruby TypedData structures
108
117
  static const rb_data_type_t IO_Event_WorkerPool_type = {
109
118
  "IO::Event::WorkerPool",
110
- {worker_pool_mark, worker_pool_free, worker_pool_size,},
111
- 0, 0, RUBY_TYPED_FREE_IMMEDIATELY
119
+ {worker_pool_mark, worker_pool_free, worker_pool_size, worker_pool_compact},
120
+ 0, 0, RUBY_TYPED_FREE_IMMEDIATELY | RUBY_TYPED_WB_PROTECTED
112
121
  };
113
122
 
114
123
  // Helper function to enqueue work (must be called with mutex held)
@@ -10,6 +10,17 @@ module IO::Event
10
10
  #
11
11
  # You can enable this in the default selector by setting the `IO_EVENT_DEBUG_SELECTOR` environment variable. In addition, you can log all selector operations to a file by setting the `IO_EVENT_DEBUG_SELECTOR_LOG` environment variable. This is useful for debugging and understanding the behavior of the event loop.
12
12
  class Selector
13
+ # Forwarders for optional selector hooks that not every backing selector implements (e.g. `io_close` is only provided by `URing`). Each method here is mixed into the wrapper's singleton class only when the wrapped selector actually defines a method of the same name, so feature detection via `respond_to?` continues to reflect the real backend.
14
+ module Forwarders
15
+ # Close a file descriptor, forwarded to the underlying selector. Ruby invokes this hook with a raw integer descriptor (Ruby 4.0+).
16
+ #
17
+ # @parameter descriptor [Integer] The raw file descriptor being closed.
18
+ def io_close(descriptor)
19
+ log("Closing file descriptor #{descriptor}")
20
+ @selector.io_close(descriptor)
21
+ end
22
+ end
23
+
13
24
  # Wrap the given selector with debugging.
14
25
  #
15
26
  # @parameter selector [Selector] The selector to wrap.
@@ -40,6 +51,20 @@ module IO::Event
40
51
  end
41
52
 
42
53
  @log = log
54
+
55
+ install_optional_forwarders(selector)
56
+ end
57
+
58
+ private def install_optional_forwarders(selector)
59
+ forwarders = nil
60
+
61
+ Forwarders.instance_methods(false).each do |name|
62
+ next unless selector.class.method_defined?(name)
63
+ forwarders ||= Module.new
64
+ forwarders.define_method(name, Forwarders.instance_method(name))
65
+ end
66
+
67
+ singleton_class.include(forwarders) if forwarders
43
68
  end
44
69
 
45
70
  # The idle duration of the underlying selector.
@@ -10,6 +10,8 @@ class IO
10
10
  # of its contents to determine priority.
11
11
  # See <https://en.wikipedia.org/wiki/Binary_heap> for explanations of the main methods.
12
12
  class PriorityHeap
13
+ HEAPIFY_INSERT_RATIO = 2
14
+
13
15
  # Initializes the heap.
14
16
  def initialize
15
17
  # The heap is represented with an array containing a binary tree. See
@@ -79,18 +81,34 @@ class IO
79
81
  return self
80
82
  end
81
83
 
82
- # Add multiple elements to the heap efficiently in O(n) time.
83
- # This is more efficient than calling push multiple times (O(n log n)).
84
+ # Add multiple elements to the heap efficiently.
84
85
  #
85
86
  # @parameter elements [Array] The elements to add to the heap.
86
87
  # @returns [self] Returns self for method chaining.
87
88
  def concat(elements)
88
89
  return self if elements.empty?
89
90
 
90
- # Add all elements to the array without maintaining heap property - O(n)
91
- @contents.concat(elements)
91
+ # Rebuilding the whole heap is `O(n + m)`, where `n` is the existing heap size and `m` is the appended batch size. Incremental `push` is `O(m log(n))`, but is often closer to `O(m)` when appended elements are later than the existing entries and do not bubble far. Prefer `heapify` only when building from empty or when the batch dominates the existing heap.
92
+ if @contents.empty? || elements.size > @contents.size * HEAPIFY_INSERT_RATIO
93
+ @contents.concat(elements)
94
+ heapify!
95
+ else
96
+ elements.each{|element| push(element)}
97
+ end
98
+
99
+ return self
100
+ end
101
+
102
+ # Mutate the heap contents directly, then rebuild the heap property.
103
+ #
104
+ # This supports batched operations that can be completed with a single `O(n)` heapify instead of multiple `O(log n)` heap operations.
105
+ #
106
+ # @yields {|contents| ...} The heap contents array.
107
+ # @returns [self] Returns self for method chaining.
108
+ def heapify
109
+ yield @contents
92
110
 
93
- # Rebuild the heap property for the entire array - O(n)
111
+ # The block may arbitrarily append, delete or reorder contents, so repair the invariant with one `O(n)` bottom-up heapify pass.
94
112
  heapify!
95
113
 
96
114
  return self
@@ -159,7 +159,7 @@ module IO::Event
159
159
  def io_wait(fiber, io, events)
160
160
  waiter = @waiting[io] = Waiter.new(fiber, events, @waiting[io])
161
161
 
162
- @loop.transfer
162
+ @loop.transfer || false
163
163
  ensure
164
164
  waiter&.invalidate
165
165
  end
@@ -188,6 +188,11 @@ module IO::Event
188
188
  # @parameter length [Integer] The minimum number of bytes to read.
189
189
  # @parameter offset [Integer] The offset into the buffer to read to.
190
190
  def io_read(fiber, io, buffer, length, offset = 0)
191
+ # Ensure offset is within the bounds of the buffer to avoid ArgumentError
192
+ if offset > buffer.size
193
+ return -Errno::EINVAL::Errno
194
+ end
195
+
191
196
  total = 0
192
197
 
193
198
  Selector.nonblock(io) do
@@ -218,6 +223,11 @@ module IO::Event
218
223
  # @parameter length [Integer] The minimum number of bytes to write.
219
224
  # @parameter offset [Integer] The offset into the buffer to write from.
220
225
  def io_write(fiber, io, buffer, length, offset = 0)
226
+ # Ensure offset is within the bounds of the buffer to avoid ArgumentError
227
+ if offset > buffer.size
228
+ return -Errno::EINVAL::Errno
229
+ end
230
+
221
231
  total = 0
222
232
 
223
233
  Selector.nonblock(io) do
@@ -284,6 +294,7 @@ module IO::Event
284
294
 
285
295
  @waiting.delete_if do |io, waiter|
286
296
  if io.closed?
297
+ # When an IO is closed, we silently drop it. Ruby 4's `rb_thread_io_close_interrupt` will take care of interrupting any fibers waiting on the closed IO, so we don't need to do anything here.
287
298
  true
288
299
  else
289
300
  waiter.each do |fiber, events|
@@ -328,7 +339,12 @@ module IO::Event
328
339
  end
329
340
 
330
341
  if error
331
- # Requeue the error into the pending exception queue:
342
+ if error.is_a?(IOError) || error.is_a?(Errno::EBADF)
343
+ # This can happen if an IO is closed while we're blocked in ::IO.select. Ruby 4's `rb_thread_io_close_interrupt` will take care of interrupting any fibers waiting on the closed IO, so we don't need to do anything here, except try again:
344
+ return 0
345
+ end
346
+
347
+ # For all other errors (e.g. thread interrupts), re-queue on the scheduler thread:
332
348
  Thread.current.raise(error)
333
349
  return 0
334
350
  end
@@ -336,15 +352,17 @@ module IO::Event
336
352
  ready = Hash.new(0).compare_by_identity
337
353
 
338
354
  readable&.each do |io|
339
- ready[io] |= IO::READABLE
355
+ # Skip any IO that was closed/reused after IO.select returned - its fd number
356
+ # may now belong to a different file, so resuming the waiter would be wrong:
357
+ ready[io] |= IO::READABLE unless io.closed?
340
358
  end
341
359
 
342
360
  writable&.each do |io|
343
- ready[io] |= IO::WRITABLE
361
+ ready[io] |= IO::WRITABLE unless io.closed?
344
362
  end
345
363
 
346
364
  priority&.each do |io|
347
- ready[io] |= IO::PRIORITY
365
+ ready[io] |= IO::PRIORITY unless io.closed?
348
366
  end
349
367
 
350
368
  ready.each do |io, events|
@@ -9,6 +9,8 @@ class IO
9
9
  module Event
10
10
  # An efficient sorted set of timers.
11
11
  class Timers
12
+ COMPACT_MINIMUM_COUNT = 128
13
+
12
14
  # A handle to a scheduled timer.
13
15
  class Handle
14
16
  # Initialize the handle with the given time and block.
@@ -16,6 +18,7 @@ class IO
16
18
  # @parameter time [Float] The time at which the block should be called.
17
19
  # @parameter block [Proc] The block to call.
18
20
  def initialize(time, block)
21
+ @timers = nil
19
22
  @time = time
20
23
  @block = block
21
24
  end
@@ -26,6 +29,16 @@ class IO
26
29
  # @attribute [Proc | Nil] The block to call when the timer fires.
27
30
  attr :block
28
31
 
32
+ # Mark the timer as inserted into the heap.
33
+ def schedule!(timers)
34
+ @timers = timers
35
+ end
36
+
37
+ # Mark the timer as removed from the heap.
38
+ def removed!
39
+ @timers = nil
40
+ end
41
+
29
42
  # Compare the handle with another handle.
30
43
  #
31
44
  # @parameter other [Handle] The other handle to compare with.
@@ -49,7 +62,14 @@ class IO
49
62
 
50
63
  # Cancel the timer.
51
64
  def cancel!
65
+ return if @block.nil?
66
+
52
67
  @block = nil
68
+
69
+ if timers = @timers
70
+ @timers = nil
71
+ timers.cancelled!(self)
72
+ end
53
73
  end
54
74
 
55
75
  # @returns [Boolean] Whether the timer has been cancelled.
@@ -62,6 +82,7 @@ class IO
62
82
  def initialize
63
83
  @heap = PriorityHeap.new
64
84
  @scheduled = []
85
+ @cancelled = 0
65
86
  end
66
87
 
67
88
  # @returns [Integer] The number of timers in the heap.
@@ -101,6 +122,8 @@ class IO
101
122
  while handle = @heap.peek
102
123
  if handle.cancelled?
103
124
  @heap.pop
125
+ handle.removed!
126
+ @cancelled -= 1 if @cancelled > 0
104
127
  else
105
128
  return handle.time - now
106
129
  end
@@ -123,9 +146,12 @@ class IO
123
146
  while handle = @heap.peek
124
147
  if handle.cancelled?
125
148
  @heap.pop
149
+ handle.removed!
150
+ @cancelled -= 1 if @cancelled > 0
126
151
  elsif handle.time <= now
127
152
  # Remove the earliest timer from the heap:
128
153
  @heap.pop
154
+ handle.removed!
129
155
 
130
156
  # Call the block:
131
157
  handle.call(now)
@@ -137,11 +163,53 @@ class IO
137
163
 
138
164
  # Flush all scheduled timers into the heap.
139
165
  #
140
- # This is a small optimization which assumes that most timers (timeouts) will be cancelled.
166
+ # Scheduling appends to `@scheduled` and cancellation is `O(1)`. We pay the cost of filtering and heap repair here, where we can batch work and choose between incremental insertion and one `heapify` pass.
141
167
  protected def flush!
142
- while handle = @scheduled.pop
143
- @heap.push(handle) unless handle.cancelled?
168
+ # Once cancelled handles are both numerous and a large fraction of the heap, rebuild the heap. This is `O(n + m)`, but it removes retained cancelled handles and appends live scheduled handles in the same `heapify` pass instead of paying for separate filtering and insertion.
169
+ if @cancelled >= COMPACT_MINIMUM_COUNT && @cancelled * 2 > @heap.size
170
+ @heap.heapify do |contents|
171
+ contents.delete_if do |handle|
172
+ if handle.cancelled?
173
+ handle.removed!
174
+ true
175
+ end
176
+ end
177
+
178
+ @scheduled.each do |handle|
179
+ unless handle.cancelled?
180
+ handle.schedule!(self)
181
+ contents << handle
182
+ end
183
+ end
184
+ end
185
+
186
+ @cancelled = 0
187
+ else
188
+ # If we are not compacting the heap, filter scheduled handles in place before insertion. This keeps cancelled scheduled handles out of the heap without adding cancellation-time heap deletion.
189
+ @scheduled.delete_if do |handle|
190
+ if handle.cancelled?
191
+ true
192
+ else
193
+ handle.schedule!(self)
194
+ false
195
+ end
196
+ end
197
+
198
+ # Small heaps can become entirely cancelled before reaching the compaction threshold. Clear those immediately so `size` does not retain cancelled handles indefinitely.
199
+ if @cancelled == @heap.size && @scheduled.empty?
200
+ @heap.clear!
201
+ @cancelled = 0
202
+ else
203
+ @heap.concat(@scheduled)
204
+ end
144
205
  end
206
+
207
+ @scheduled.clear
208
+ end
209
+
210
+ # Track cancelled timers that are still retained in the heap.
211
+ def cancelled!(handle)
212
+ @cancelled += 1
145
213
  end
146
214
  end
147
215
  end
@@ -7,6 +7,6 @@
7
7
  class IO
8
8
  # @namespace
9
9
  module Event
10
- VERSION = "1.14.5"
10
+ VERSION = "1.16.2"
11
11
  end
12
12
  end
data/license.md CHANGED
@@ -16,6 +16,9 @@ Copyright, 2025, by Luke Gruber.
16
16
  Copyright, 2026, by William T. Nelson.
17
17
  Copyright, 2026, by Stan Hu.
18
18
  Copyright, 2026, by John Hawthorn.
19
+ Copyright, 2026, by Italo Brandão.
20
+ Copyright, 2026, by Fletcher Dares.
21
+ Copyright, 2026, by Tavian Barnes.
19
22
 
20
23
  Permission is hereby granted, free of charge, to any person obtaining a copy
21
24
  of this software and associated documentation files (the "Software"), to deal
data/readme.md CHANGED
@@ -18,46 +18,51 @@ Please see the [project documentation](https://socketry.github.io/io-event/) for
18
18
 
19
19
  Please see the [project releases](https://socketry.github.io/io-event/releases/index) for all releases.
20
20
 
21
- ### v1.14.4
21
+ ### v1.16.2
22
22
 
23
- - Allow `epoll_pwait2` to be disabled via `--disable-epoll_pwait2`.
23
+ - Improve timer heap performance by batching scheduled timer insertion, compacting cancelled timers during flush, and avoiding unnecessary heap rebuilds for small incremental inserts.
24
24
 
25
- ### v1.14.3
25
+ ### v1.16.1
26
26
 
27
- - Fix several implementation bugs that could cause deadlocks on blocking writes.
27
+ - Ensure the pure Ruby `Select` selector returns `false`, not `nil`, when `io_wait` resumes without any ready events.
28
28
 
29
- ### v1.14.0
29
+ ### v1.16.0
30
30
 
31
- - [Enhanced `IO::Event::PriorityHeap` with deletion and bulk insertion methods](https://socketry.github.io/io-event/releases/index#enhanced-io::event::priorityheap-with-deletion-and-bulk-insertion-methods)
31
+ - Use `eventfd` for `URing` cross-thread wakeup, and enable `IORING_SETUP_SINGLE_ISSUER`, `IORING_SETUP_DEFER_TASKRUN`, and `IORING_SETUP_TASKRUN_FLAG`. The waking thread now signals via `eventfd` rather than submitting a `NOP` SQE, which unlocks the single-issuer optimisation, defers task work to the application thread, and lets `select()` skip the `io_uring_get_events()` syscall when no task work is pending.
32
+ - Add support for the `io_close` fiber-scheduler hook (Ruby 4.0+). The `URing` selector performs the close asynchronously via the ring; the `Debug::Selector` and `TestScheduler` wrappers forward to the underlying selector when supported.
33
+ - Improve `WorkerPool` GC compaction support and add proper write barriers, fixing potential use-after-free under compacting GC.
34
+ - Keep blocked scheduler fibers alive during GC by registering them as roots in `TestScheduler#block`, preventing premature collection and the resulting use-after-free crash on resume.
35
+ - Use Ruby's `xmalloc` / `xcalloc` / `xrealloc2` / `xfree` for all internal selector allocations (the per-fiber ready-queue entries in `IO_Event_Selector_ready_push`, and both the backing array and per-element allocations in `IO_Event_Array`). Previously a raw `malloc` paired with a debug-build-only `assert(...)` would silently dereference `NULL` and crash in release builds under memory pressure; the Ruby allocators trigger a GC sweep on pressure and raise `NoMemoryError` / `RangeError` on real failure, so the `-1` return-code paths through `IO_Event_Array_initialize` / `_resize` / `_lookup` and their callers in `epoll.c` / `kqueue.c` / `uring.c` are removed in favour of straight exception propagation.
36
+ - Correctly handle short `io_uring_submit()` results in the `URing` selector. `io_uring_submit()` returns the number of SQEs actually accepted by the kernel and can be short (SQE prep errors, `ENOMEM`, transient `EAGAIN`); the old accounting reset `pending = 0` on any success and silently lost track of unsubmitted SQEs.
37
+ - Enable `IORING_SETUP_SUBMIT_ALL` (kernel 5.18+) on the `URing` selector so the kernel keeps processing the rest of an SQE batch past individual errors, reducing the frequency of short submits in practice.
32
38
 
33
- ### v1.11.2
39
+ ### v1.15.1
34
40
 
35
- - Fix Windows build.
41
+ - Simplify closed-IO handling in the `Select` selector: rely on Ruby 4's `rb_thread_io_close_interrupt` to wake fibers waiting on a descriptor that's been closed, removing a custom error-recovery path that could mis-attribute `IOError` / `Errno::EBADF` to the wrong waiter.
36
42
 
37
- ### v1.11.1
43
+ ### v1.15.0
38
44
 
39
- - Fix `read_nonblock` when using the `URing` selector, which was not handling zero-length reads correctly. This allows reading available data without blocking.
45
+ - Add bounds checks, in the unlikely event of a user providing an invalid offset that exceeds the buffer size. This prevents potential memory corruption and ensures safe operation when using buffered IO methods.
40
46
 
41
- ### v1.11.0
47
+ ### v1.14.4
42
48
 
43
- - [Introduce `IO::Event::WorkerPool` for off-loading blocking operations.](https://socketry.github.io/io-event/releases/index#introduce-io::event::workerpool-for-off-loading-blocking-operations.)
49
+ - Allow `epoll_pwait2` to be disabled via `--disable-epoll_pwait2`.
44
50
 
45
- ### v1.10.2
51
+ ### v1.14.3
46
52
 
47
- - Improved consistency of handling closed IO when invoking `#select`.
53
+ - Fix several implementation bugs that could cause deadlocks on blocking writes.
48
54
 
49
- ### v1.10.0
55
+ ### v1.14.0
50
56
 
51
- - `IO::Event::Profiler` is moved to dedicated gem: [fiber-profiler](https://github.com/socketry/fiber-profiler).
52
- - Perform runtime checks for native selectors to ensure they are supported in the current environment. While compile-time checks determine availability, restrictions like seccomp and SELinux may still prevent them from working.
57
+ - [Enhanced `IO::Event::PriorityHeap` with deletion and bulk insertion methods](https://socketry.github.io/io-event/releases/index#enhanced-io::event::priorityheap-with-deletion-and-bulk-insertion-methods)
53
58
 
54
- ### v1.9.0
59
+ ### v1.11.2
55
60
 
56
- - Improved `IO::Event::Profiler` for detecting stalls.
61
+ - Fix Windows build.
57
62
 
58
- ### v1.8.0
63
+ ### v1.11.1
59
64
 
60
- - Detecting fibers that are stalling the event loop.
65
+ - Fix `read_nonblock` when using the `URing` selector, which was not handling zero-length reads correctly. This allows reading available data without blocking.
61
66
 
62
67
  ## Contributing
63
68
 
data/releases.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Releases
2
2
 
3
+ ## v1.16.2
4
+
5
+ - Improve timer heap performance by batching scheduled timer insertion, compacting cancelled timers during flush, and avoiding unnecessary heap rebuilds for small incremental inserts.
6
+
7
+ ## v1.16.1
8
+
9
+ - Ensure the pure Ruby `Select` selector returns `false`, not `nil`, when `io_wait` resumes without any ready events.
10
+
11
+ ## v1.16.0
12
+
13
+ - Use `eventfd` for `URing` cross-thread wakeup, and enable `IORING_SETUP_SINGLE_ISSUER`, `IORING_SETUP_DEFER_TASKRUN`, and `IORING_SETUP_TASKRUN_FLAG`. The waking thread now signals via `eventfd` rather than submitting a `NOP` SQE, which unlocks the single-issuer optimisation, defers task work to the application thread, and lets `select()` skip the `io_uring_get_events()` syscall when no task work is pending.
14
+ - Add support for the `io_close` fiber-scheduler hook (Ruby 4.0+). The `URing` selector performs the close asynchronously via the ring; the `Debug::Selector` and `TestScheduler` wrappers forward to the underlying selector when supported.
15
+ - Improve `WorkerPool` GC compaction support and add proper write barriers, fixing potential use-after-free under compacting GC.
16
+ - Keep blocked scheduler fibers alive during GC by registering them as roots in `TestScheduler#block`, preventing premature collection and the resulting use-after-free crash on resume.
17
+ - Use Ruby's `xmalloc` / `xcalloc` / `xrealloc2` / `xfree` for all internal selector allocations (the per-fiber ready-queue entries in `IO_Event_Selector_ready_push`, and both the backing array and per-element allocations in `IO_Event_Array`). Previously a raw `malloc` paired with a debug-build-only `assert(...)` would silently dereference `NULL` and crash in release builds under memory pressure; the Ruby allocators trigger a GC sweep on pressure and raise `NoMemoryError` / `RangeError` on real failure, so the `-1` return-code paths through `IO_Event_Array_initialize` / `_resize` / `_lookup` and their callers in `epoll.c` / `kqueue.c` / `uring.c` are removed in favour of straight exception propagation.
18
+ - Correctly handle short `io_uring_submit()` results in the `URing` selector. `io_uring_submit()` returns the number of SQEs actually accepted by the kernel and can be short (SQE prep errors, `ENOMEM`, transient `EAGAIN`); the old accounting reset `pending = 0` on any success and silently lost track of unsubmitted SQEs.
19
+ - Enable `IORING_SETUP_SUBMIT_ALL` (kernel 5.18+) on the `URing` selector so the kernel keeps processing the rest of an SQE batch past individual errors, reducing the frequency of short submits in practice.
20
+
21
+ ## v1.15.1
22
+
23
+ - Simplify closed-IO handling in the `Select` selector: rely on Ruby 4's `rb_thread_io_close_interrupt` to wake fibers waiting on a descriptor that's been closed, removing a custom error-recovery path that could mis-attribute `IOError` / `Errno::EBADF` to the wrong waiter.
24
+
25
+ ## v1.15.0
26
+
27
+ - Add bounds checks, in the unlikely event of a user providing an invalid offset that exceeds the buffer size. This prevents potential memory corruption and ensures safe operation when using buffered IO methods.
28
+
3
29
  ## v1.14.4
4
30
 
5
31
  - Allow `epoll_pwait2` to be disabled via `--disable-epoll_pwait2`.
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: io-event
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.14.5
4
+ version: 1.16.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Williams
@@ -11,9 +11,12 @@ authors:
11
11
  - Benoit Daloze
12
12
  - Bruno Sutic
13
13
  - Shizuo Fujita
14
+ - Tavian Barnes
14
15
  - Alex Matchneer
15
16
  - Anthony Ross
16
17
  - Delton Ding
18
+ - Fletcher Dares
19
+ - Italo Brandão
17
20
  - John Hawthorn
18
21
  - Luke Gruber
19
22
  - Pavel Rosický
@@ -120,7 +123,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
120
123
  - !ruby/object:Gem::Version
121
124
  version: '0'
122
125
  requirements: []
123
- rubygems_version: 4.0.6
126
+ rubygems_version: 4.0.10
124
127
  specification_version: 4
125
128
  summary: An event loop.
126
129
  test_files: []
metadata.gz.sig CHANGED
Binary file