rubyx-py 0.1.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.
@@ -0,0 +1,1422 @@
1
+ use crate::pipe_notify::PipeNotify;
2
+ use crate::ruby_helpers::runtime_error;
3
+ use crate::stream::StreamItem;
4
+ use crossbeam_channel::Receiver;
5
+ use magnus::value::ReprValue;
6
+ use magnus::{Error, Module, Ruby, Value};
7
+ use std::ffi::c_void;
8
+ use std::os::fd::RawFd;
9
+ use std::sync::atomic::AtomicBool;
10
+ use std::sync::Arc;
11
+
12
+ extern "C" {
13
+ /// Release the GVL, call `func(data1)`, then reacquire the GVL.
14
+ ///
15
+ /// While `func` runs, other Ruby threads can execute. Inside `func`,
16
+ /// you must NOT call any Ruby C API or access any Ruby VALUE.
17
+ ///
18
+ /// If `ubf` is provided, Ruby may call it from another thread to
19
+ /// interrupt `func` (e.g., on Thread#kill or signal delivery).
20
+ ///
21
+ /// # Safety
22
+ ///
23
+ /// - `func` must not touch Ruby objects (GVL is not held).
24
+ /// - `data1` must remain valid for the duration of `func`.
25
+ /// - `data2` must remain valid for the duration of `ubf` (if provided).
26
+ fn rb_thread_call_without_gvl(
27
+ func: unsafe extern "C" fn(*mut c_void) -> *mut c_void,
28
+ data1: *mut c_void,
29
+ ubf: Option<unsafe extern "C" fn(*mut c_void)>,
30
+ data2: *mut c_void,
31
+ ) -> *mut c_void;
32
+
33
+ /// Check for pending Ruby interrupts (Thread#kill, signals, etc.).
34
+ ///
35
+ /// Must be called WITH the GVL held. If an interrupt is pending,
36
+ /// this raises a Ruby exception (longjmp). Call this immediately
37
+ /// after `rb_thread_call_without_gvl` returns to deliver any
38
+ /// interrupts that arrived while the GVL was released.
39
+ fn rb_thread_check_ints();
40
+
41
+ }
42
+
43
+ fn create_ruby_io(ruby: &Ruby, fd: RawFd) -> Result<Value, Error> {
44
+ let io_class: Value = ruby.class_object().const_get("IO")?;
45
+ io_class.funcall("for_fd", (fd, magnus::kwargs!("autoclose" => false)))
46
+ }
47
+
48
+ fn has_fiber_scheduler(ruby: &Ruby) -> bool {
49
+ let fiber_class = ruby
50
+ .class_object()
51
+ .const_get("Fiber")
52
+ .unwrap_or_else(|_| ruby.qnil().as_value());
53
+ let scheduler = fiber_class
54
+ .funcall("scheduler", ())
55
+ .unwrap_or_else(|_| ruby.qnil().as_value());
56
+ !scheduler.is_nil()
57
+ }
58
+
59
+ unsafe extern "C" fn ubf_cancel(data: *mut c_void) {
60
+ let cancel = &*(data as *const AtomicBool);
61
+ cancel.store(true, std::sync::atomic::Ordering::Relaxed);
62
+ }
63
+
64
+ unsafe extern "C" fn recv_without_gvl_cb(args: *mut c_void) -> *mut c_void {
65
+ let args = &mut *(args as *mut RecvArgs);
66
+ loop {
67
+ match args.receiver.try_recv() {
68
+ Ok(item) => {
69
+ args.result = Some(Ok(item));
70
+ return std::ptr::null_mut();
71
+ }
72
+ Err(crossbeam_channel::TryRecvError::Empty) => {
73
+ // Check the cancel flag relaxed already
74
+ if args.cancel.load(std::sync::atomic::Ordering::Relaxed) {
75
+ args.result = None;
76
+ return std::ptr::null_mut();
77
+ }
78
+
79
+ // Wait for timeout
80
+ match args
81
+ .receiver
82
+ .recv_timeout(std::time::Duration::from_millis(50))
83
+ {
84
+ Ok(item) => {
85
+ args.result = Some(Ok(item));
86
+ return std::ptr::null_mut();
87
+ }
88
+ Err(crossbeam_channel::RecvTimeoutError::Timeout) => {
89
+ // continue to check cancel
90
+ continue;
91
+ }
92
+ Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
93
+ args.result = Some(Err(crossbeam_channel::RecvError));
94
+ return std::ptr::null_mut();
95
+ }
96
+ }
97
+ }
98
+ Err(crossbeam_channel::TryRecvError::Disconnected) => {
99
+ args.result = Some(Err(crossbeam_channel::RecvError));
100
+ return std::ptr::null_mut();
101
+ }
102
+ }
103
+ }
104
+ }
105
+
106
+ struct RecvArgs {
107
+ receiver: Receiver<StreamItem>,
108
+ result: Option<Result<StreamItem, crossbeam_channel::RecvError>>,
109
+ cancel: Arc<AtomicBool>,
110
+ }
111
+ #[magnus::wrap(class = "Rubyx::NonBlockingStream", free_immediately)]
112
+ pub(crate) struct NonBlockingStream {
113
+ receiver: Receiver<StreamItem>,
114
+ pipe: Arc<PipeNotify>,
115
+ #[allow(dead_code)]
116
+ cancel: Arc<AtomicBool>,
117
+ }
118
+ impl NonBlockingStream {
119
+ pub(crate) fn new(receiver: Receiver<StreamItem>, pipe: Arc<PipeNotify>) -> Self {
120
+ NonBlockingStream {
121
+ receiver,
122
+ pipe,
123
+ cancel: Arc::new(AtomicBool::new(false)),
124
+ }
125
+ }
126
+
127
+ fn next_gvl_release(&self) -> Option<Result<Value, Error>> {
128
+ let cancel = Arc::new(AtomicBool::new(false));
129
+ let mut args = RecvArgs {
130
+ receiver: self.receiver.clone(),
131
+ result: None,
132
+ cancel: cancel.clone(),
133
+ };
134
+
135
+ unsafe {
136
+ rb_thread_call_without_gvl(
137
+ recv_without_gvl_cb,
138
+ &mut args as *mut RecvArgs as *mut c_void,
139
+ Some(ubf_cancel),
140
+ Arc::as_ptr(&cancel) as *mut c_void,
141
+ );
142
+ rb_thread_check_ints();
143
+ }
144
+ match args.result? {
145
+ Ok(StreamItem::Value(v)) => Some(v.try_into()),
146
+ Ok(StreamItem::Error(e)) => Some(Err(Error::new(runtime_error(), e))),
147
+ Ok(StreamItem::End) | Err(_) => None,
148
+ }
149
+ }
150
+
151
+ fn each_fiber_aware(&self, ruby: &Ruby) -> Result<(), Error> {
152
+ let read_io = create_ruby_io(ruby, self.pipe.read_fd())?;
153
+ let select_arr = ruby.ary_new_from_values(&[read_io]);
154
+ loop {
155
+ // IO.select([read_io])
156
+ let io = ruby
157
+ .class_object()
158
+ .const_get("IO")
159
+ .unwrap_or_else(|_| ruby.qnil().as_value());
160
+ let nil = ruby.qnil().as_value();
161
+ let _: Value = io.funcall("select", (select_arr, nil, nil))?;
162
+
163
+ self.pipe.drain();
164
+ loop {
165
+ match self.receiver.try_recv() {
166
+ Ok(StreamItem::Value(v)) => {
167
+ let val: Value = v.try_into()?;
168
+ let _: Value = ruby.yield_value(val)?;
169
+ }
170
+ Ok(StreamItem::Error(e)) => {
171
+ return Err(Error::new(ruby.exception_runtime_error(), e));
172
+ }
173
+ Ok(StreamItem::End) => return Ok(()),
174
+ Err(crossbeam_channel::TryRecvError::Empty) => break,
175
+ Err(crossbeam_channel::TryRecvError::Disconnected) => return Ok(()),
176
+ }
177
+ }
178
+ }
179
+ }
180
+
181
+ pub fn each(&self) -> Result<(), Error> {
182
+ let ruby = Ruby::get().expect("called from Ruby thread");
183
+ if has_fiber_scheduler(&ruby) {
184
+ self.each_fiber_aware(&ruby)
185
+ } else {
186
+ loop {
187
+ match self.next_gvl_release() {
188
+ Some(Ok(value)) => {
189
+ let _: Value = ruby.yield_value(value)?;
190
+ }
191
+ Some(Err(e)) => return Err(e),
192
+ None => return Ok(()),
193
+ }
194
+ }
195
+ }
196
+ }
197
+ }
198
+
199
+ #[cfg(test)]
200
+ mod tests {
201
+ use super::*;
202
+ use crate::stream::SendableValue;
203
+ use crate::test_helpers::with_ruby_python;
204
+ use crossbeam_channel::{bounded, unbounded};
205
+ use magnus::value::ReprValue;
206
+ use magnus::RArray;
207
+ use magnus::TryConvert;
208
+ use serial_test::serial;
209
+ use std::sync::atomic::Ordering;
210
+ use std::time::{Duration, Instant};
211
+
212
+ // ========== Step 3: recv_without_gvl_cb (pure Rust, no Ruby needed) ==========
213
+
214
+ #[test]
215
+ fn test_recv_cb_delivers_value() {
216
+ let (tx, rx) = unbounded();
217
+ tx.send(StreamItem::Value(SendableValue::Integer(42)))
218
+ .unwrap();
219
+
220
+ let cancel = Arc::new(AtomicBool::new(false));
221
+ let mut args = RecvArgs {
222
+ receiver: rx,
223
+ result: None,
224
+ cancel,
225
+ };
226
+
227
+ unsafe {
228
+ recv_without_gvl_cb(&mut args as *mut RecvArgs as *mut c_void);
229
+ }
230
+
231
+ match args.result.unwrap().unwrap() {
232
+ StreamItem::Value(SendableValue::Integer(n)) => assert_eq!(n, 42),
233
+ _ => panic!("expected Integer(42)"),
234
+ }
235
+ }
236
+
237
+ #[test]
238
+ fn test_recv_cb_delivers_end() {
239
+ let (tx, rx) = unbounded();
240
+ tx.send(StreamItem::End).unwrap();
241
+
242
+ let cancel = Arc::new(AtomicBool::new(false));
243
+ let mut args = RecvArgs {
244
+ receiver: rx,
245
+ result: None,
246
+ cancel,
247
+ };
248
+
249
+ unsafe {
250
+ recv_without_gvl_cb(&mut args as *mut RecvArgs as *mut c_void);
251
+ }
252
+
253
+ assert!(matches!(args.result.unwrap().unwrap(), StreamItem::End));
254
+ }
255
+
256
+ #[test]
257
+ fn test_recv_cb_delivers_error() {
258
+ let (tx, rx) = unbounded();
259
+ tx.send(StreamItem::Error("boom".to_string())).unwrap();
260
+
261
+ let cancel = Arc::new(AtomicBool::new(false));
262
+ let mut args = RecvArgs {
263
+ receiver: rx,
264
+ result: None,
265
+ cancel,
266
+ };
267
+
268
+ unsafe {
269
+ recv_without_gvl_cb(&mut args as *mut RecvArgs as *mut c_void);
270
+ }
271
+
272
+ match args.result.unwrap().unwrap() {
273
+ StreamItem::Error(msg) => assert_eq!(msg, "boom"),
274
+ _ => panic!("expected Error"),
275
+ }
276
+ }
277
+
278
+ #[test]
279
+ fn test_recv_cb_handles_disconnect() {
280
+ let (tx, rx) = bounded::<StreamItem>(16);
281
+ drop(tx);
282
+
283
+ let cancel = Arc::new(AtomicBool::new(false));
284
+ let mut args = RecvArgs {
285
+ receiver: rx,
286
+ result: None,
287
+ cancel,
288
+ };
289
+
290
+ unsafe {
291
+ recv_without_gvl_cb(&mut args as *mut RecvArgs as *mut c_void);
292
+ }
293
+
294
+ assert!(args.result.unwrap().is_err());
295
+ }
296
+
297
+ #[test]
298
+ fn test_recv_cb_multiple_values_in_order() {
299
+ let (tx, rx) = unbounded();
300
+ for i in 0..5 {
301
+ tx.send(StreamItem::Value(SendableValue::Integer(i)))
302
+ .unwrap();
303
+ }
304
+ tx.send(StreamItem::End).unwrap();
305
+
306
+ let cancel = Arc::new(AtomicBool::new(false));
307
+
308
+ for expected in 0..5 {
309
+ let mut args = RecvArgs {
310
+ receiver: rx.clone(),
311
+ result: None,
312
+ cancel: cancel.clone(),
313
+ };
314
+ unsafe {
315
+ recv_without_gvl_cb(&mut args as *mut RecvArgs as *mut c_void);
316
+ }
317
+ match args.result.unwrap().unwrap() {
318
+ StreamItem::Value(SendableValue::Integer(n)) => assert_eq!(n, expected),
319
+ _ => panic!("expected Integer({expected})"),
320
+ }
321
+ }
322
+
323
+ // Next should be End
324
+ let mut args = RecvArgs {
325
+ receiver: rx,
326
+ result: None,
327
+ cancel,
328
+ };
329
+ unsafe {
330
+ recv_without_gvl_cb(&mut args as *mut RecvArgs as *mut c_void);
331
+ }
332
+ assert!(matches!(args.result.unwrap().unwrap(), StreamItem::End));
333
+ }
334
+
335
+ // ========== Step 4: ubf_cancel and cancel flag ==========
336
+
337
+ #[test]
338
+ fn test_ubf_cancel_sets_flag() {
339
+ let cancel = Arc::new(AtomicBool::new(false));
340
+ assert!(!cancel.load(Ordering::Relaxed));
341
+
342
+ unsafe {
343
+ ubf_cancel(Arc::as_ptr(&cancel) as *mut c_void);
344
+ }
345
+
346
+ assert!(cancel.load(Ordering::Relaxed));
347
+ }
348
+
349
+ #[test]
350
+ fn test_recv_cb_respects_cancel_flag() {
351
+ // Empty channel — recv would block forever without cancel
352
+ let (_tx, rx) = bounded::<StreamItem>(16);
353
+
354
+ let cancel = Arc::new(AtomicBool::new(false));
355
+ let cancel_clone = cancel.clone();
356
+
357
+ let mut args = RecvArgs {
358
+ receiver: rx,
359
+ result: None,
360
+ cancel,
361
+ };
362
+
363
+ // Set cancel from another thread after a short delay
364
+ std::thread::spawn(move || {
365
+ std::thread::sleep(Duration::from_millis(30));
366
+ cancel_clone.store(true, Ordering::Relaxed);
367
+ });
368
+
369
+ let start = Instant::now();
370
+ unsafe {
371
+ recv_without_gvl_cb(&mut args as *mut RecvArgs as *mut c_void);
372
+ }
373
+ let elapsed = start.elapsed();
374
+
375
+ // Should return None (cancelled), not block forever
376
+ assert!(args.result.is_none(), "expected None on cancel");
377
+ // Should return within ~100ms (50ms timeout + margin)
378
+ assert!(
379
+ elapsed < Duration::from_millis(200),
380
+ "cancel took {elapsed:?}, expected < 200ms"
381
+ );
382
+ }
383
+
384
+ #[test]
385
+ fn test_recv_cb_cancel_flag_already_set() {
386
+ // Cancel flag set before calling — should return immediately
387
+ let (_tx, rx) = bounded::<StreamItem>(16);
388
+
389
+ let cancel = Arc::new(AtomicBool::new(true)); // pre-set
390
+ let mut args = RecvArgs {
391
+ receiver: rx,
392
+ result: None,
393
+ cancel,
394
+ };
395
+
396
+ let start = Instant::now();
397
+ unsafe {
398
+ recv_without_gvl_cb(&mut args as *mut RecvArgs as *mut c_void);
399
+ }
400
+ let elapsed = start.elapsed();
401
+
402
+ assert!(args.result.is_none(), "expected None on pre-set cancel");
403
+ assert!(
404
+ elapsed < Duration::from_millis(10),
405
+ "pre-set cancel took {elapsed:?}, expected near-instant"
406
+ );
407
+ }
408
+
409
+ // ========== Helper: construct NonBlockingStream for tests ==========
410
+
411
+ fn make_stream(rx: Receiver<StreamItem>) -> NonBlockingStream {
412
+ let pipe = Arc::new(PipeNotify::new().unwrap());
413
+ NonBlockingStream {
414
+ receiver: rx,
415
+ pipe,
416
+ cancel: Arc::new(AtomicBool::new(false)),
417
+ }
418
+ }
419
+
420
+ #[allow(dead_code)]
421
+ fn make_stream_with_pipe(rx: Receiver<StreamItem>, pipe: Arc<PipeNotify>) -> NonBlockingStream {
422
+ NonBlockingStream {
423
+ receiver: rx,
424
+ pipe,
425
+ cancel: Arc::new(AtomicBool::new(false)),
426
+ }
427
+ }
428
+
429
+ // ========== Step 3+4: next_gvl_release integration (needs Ruby) ==========
430
+
431
+ #[test]
432
+ #[serial]
433
+ fn test_next_gvl_release_delivers_value() {
434
+ with_ruby_python(|_ruby, _api| {
435
+ let (tx, rx) = unbounded();
436
+ tx.send(StreamItem::Value(SendableValue::Integer(42)))
437
+ .unwrap();
438
+
439
+ let stream = make_stream(rx);
440
+ let val = stream.next_gvl_release().unwrap().unwrap();
441
+ assert_eq!(i64::try_convert(val).unwrap(), 42);
442
+ });
443
+ }
444
+
445
+ #[test]
446
+ #[serial]
447
+ fn test_next_gvl_release_multiple_values_in_order() {
448
+ with_ruby_python(|_ruby, _api| {
449
+ let (tx, rx) = unbounded();
450
+ for i in 0..5 {
451
+ tx.send(StreamItem::Value(SendableValue::Integer(i)))
452
+ .unwrap();
453
+ }
454
+ tx.send(StreamItem::End).unwrap();
455
+
456
+ let stream = make_stream(rx);
457
+ for expected in 0..5 {
458
+ let val = stream.next_gvl_release().unwrap().unwrap();
459
+ assert_eq!(i64::try_convert(val).unwrap(), expected);
460
+ }
461
+ assert!(stream.next_gvl_release().is_none());
462
+ });
463
+ }
464
+
465
+ #[test]
466
+ #[serial]
467
+ fn test_next_gvl_release_returns_none_on_end() {
468
+ with_ruby_python(|_ruby, _api| {
469
+ let (tx, rx) = unbounded();
470
+ tx.send(StreamItem::End).unwrap();
471
+
472
+ let stream = make_stream(rx);
473
+ assert!(stream.next_gvl_release().is_none());
474
+ });
475
+ }
476
+
477
+ #[test]
478
+ #[serial]
479
+ fn test_next_gvl_release_returns_none_on_disconnect() {
480
+ with_ruby_python(|_ruby, _api| {
481
+ let (tx, rx) = bounded::<StreamItem>(16);
482
+ drop(tx);
483
+
484
+ let stream = make_stream(rx);
485
+ assert!(stream.next_gvl_release().is_none());
486
+ });
487
+ }
488
+
489
+ #[test]
490
+ #[serial]
491
+ fn test_next_gvl_release_propagates_error() {
492
+ with_ruby_python(|_ruby, _api| {
493
+ let (tx, rx) = unbounded();
494
+ tx.send(StreamItem::Error("something broke".to_string()))
495
+ .unwrap();
496
+
497
+ let stream = make_stream(rx);
498
+ let err = stream.next_gvl_release().unwrap().unwrap_err();
499
+ assert!(err.to_string().contains("something broke"));
500
+ });
501
+ }
502
+
503
+ #[test]
504
+ #[serial]
505
+ fn test_next_gvl_release_all_sendable_types() {
506
+ with_ruby_python(|_ruby, _api| {
507
+ let (tx, rx) = unbounded();
508
+ tx.send(StreamItem::Value(SendableValue::Nil)).unwrap();
509
+ tx.send(StreamItem::Value(SendableValue::Integer(99)))
510
+ .unwrap();
511
+ tx.send(StreamItem::Value(SendableValue::Float(2.5)))
512
+ .unwrap();
513
+ tx.send(StreamItem::Value(SendableValue::Str("hello".to_string())))
514
+ .unwrap();
515
+ tx.send(StreamItem::Value(SendableValue::Bool(true)))
516
+ .unwrap();
517
+ tx.send(StreamItem::End).unwrap();
518
+
519
+ let stream = make_stream(rx);
520
+
521
+ let v = stream.next_gvl_release().unwrap().unwrap();
522
+ assert!(v.is_nil());
523
+
524
+ let v = stream.next_gvl_release().unwrap().unwrap();
525
+ assert_eq!(i64::try_convert(v).unwrap(), 99);
526
+
527
+ let v = stream.next_gvl_release().unwrap().unwrap();
528
+ assert!((f64::try_convert(v).unwrap() - 2.5).abs() < 1e-9);
529
+
530
+ let v = stream.next_gvl_release().unwrap().unwrap();
531
+ assert_eq!(String::try_convert(v).unwrap(), "hello");
532
+
533
+ let v = stream.next_gvl_release().unwrap().unwrap();
534
+ assert!(bool::try_convert(v).unwrap());
535
+
536
+ assert!(stream.next_gvl_release().is_none());
537
+ });
538
+ }
539
+
540
+ #[test]
541
+ #[serial]
542
+ fn test_next_gvl_release_with_delayed_producer() {
543
+ with_ruby_python(|_ruby, _api| {
544
+ let (tx, rx) = unbounded();
545
+
546
+ // Producer sends after a delay — recv must wait without deadlocking
547
+ std::thread::spawn(move || {
548
+ std::thread::sleep(Duration::from_millis(100));
549
+ tx.send(StreamItem::Value(SendableValue::Integer(7)))
550
+ .unwrap();
551
+ tx.send(StreamItem::End).unwrap();
552
+ });
553
+
554
+ let stream = make_stream(rx);
555
+
556
+ let start = Instant::now();
557
+ let val = stream.next_gvl_release().unwrap().unwrap();
558
+ let elapsed = start.elapsed();
559
+
560
+ assert_eq!(i64::try_convert(val).unwrap(), 7);
561
+ assert!(
562
+ elapsed >= Duration::from_millis(50),
563
+ "should have waited for producer, took {elapsed:?}"
564
+ );
565
+ assert!(stream.next_gvl_release().is_none());
566
+ });
567
+ }
568
+
569
+ // ========== Step 6: create_ruby_io ==========
570
+
571
+ #[test]
572
+ #[serial]
573
+ fn test_create_ruby_io_returns_io_object() {
574
+ with_ruby_python(|ruby, _api| {
575
+ let pipe = PipeNotify::new().unwrap();
576
+ let io = create_ruby_io(ruby, pipe.read_fd()).unwrap();
577
+
578
+ // Verify it's an instance of IO
579
+ let io_class: Value = ruby.class_object().const_get("IO").unwrap();
580
+ let is_io: bool = io.funcall("is_a?", (io_class,)).unwrap();
581
+ assert!(is_io, "create_ruby_io should return an IO instance");
582
+ });
583
+ }
584
+
585
+ #[test]
586
+ #[serial]
587
+ fn test_create_ruby_io_fileno_matches_pipe_fd() {
588
+ with_ruby_python(|ruby, _api| {
589
+ let pipe = PipeNotify::new().unwrap();
590
+ let expected_fd = pipe.read_fd();
591
+ let io = create_ruby_io(ruby, expected_fd).unwrap();
592
+
593
+ let fileno: i32 = io.funcall("fileno", ()).unwrap();
594
+ assert_eq!(
595
+ fileno, expected_fd,
596
+ "IO#fileno should match the pipe's read_fd"
597
+ );
598
+ });
599
+ }
600
+
601
+ #[test]
602
+ #[serial]
603
+ fn test_create_ruby_io_autoclose_is_false() {
604
+ with_ruby_python(|ruby, _api| {
605
+ let pipe = PipeNotify::new().unwrap();
606
+ let io = create_ruby_io(ruby, pipe.read_fd()).unwrap();
607
+
608
+ let autoclose: bool = io.funcall("autoclose?", ()).unwrap();
609
+ assert!(
610
+ !autoclose,
611
+ "autoclose must be false to prevent double-close with PipeNotify::drop"
612
+ );
613
+ });
614
+ }
615
+
616
+ #[test]
617
+ #[serial]
618
+ fn test_create_ruby_io_fd_survives_gc() {
619
+ // Verifies that Ruby GC does NOT close the fd because autoclose: false.
620
+ // After GC, the fd should still be valid (PipeNotify owns it).
621
+ with_ruby_python(|ruby, _api| {
622
+ let pipe = PipeNotify::new().unwrap();
623
+ let fd = pipe.read_fd();
624
+
625
+ {
626
+ let _io = create_ruby_io(ruby, fd).unwrap();
627
+ // io goes out of Rust scope here — Ruby may GC it
628
+ }
629
+
630
+ // Force Ruby GC
631
+ let _: Value = ruby.eval("GC.start").unwrap();
632
+
633
+ // fd should still be valid because autoclose: false
634
+ // fcntl(fd, F_GETFD) returns -1 with EBADF for closed fds
635
+ let ret = unsafe { libc::fcntl(fd, libc::F_GETFD) };
636
+ assert_ne!(
637
+ ret, -1,
638
+ "fd should still be open after Ruby GC (autoclose: false)"
639
+ );
640
+ });
641
+ }
642
+
643
+ #[test]
644
+ #[serial]
645
+ fn test_create_ruby_io_pipe_drop_closes_fd() {
646
+ // Verifies PipeNotify::drop is the one that closes the fd
647
+ with_ruby_python(|ruby, _api| {
648
+ let pipe = PipeNotify::new().unwrap();
649
+ let fd = pipe.read_fd();
650
+ let _io = create_ruby_io(ruby, fd).unwrap();
651
+
652
+ // Drop the pipe — should close the fd
653
+ drop(pipe);
654
+
655
+ let ret = unsafe { libc::fcntl(fd, libc::F_GETFD) };
656
+ assert_eq!(ret, -1, "fd should be closed after PipeNotify::drop");
657
+ });
658
+ }
659
+
660
+ // ========== Step 6: IO.select + pipe integration ==========
661
+
662
+ #[test]
663
+ #[serial]
664
+ fn test_io_select_wakes_on_pipe_notify() {
665
+ // IO.select should return when the pipe becomes readable after notify()
666
+ with_ruby_python(|ruby, _api| {
667
+ let pipe = Arc::new(PipeNotify::new().unwrap());
668
+ let io = create_ruby_io(ruby, pipe.read_fd()).unwrap();
669
+ let select_arr = ruby.ary_new_from_values(&[io]);
670
+ let nil = ruby.qnil().as_value();
671
+ let io_class: Value = ruby.class_object().const_get("IO").unwrap();
672
+
673
+ // Notify from another thread after a short delay
674
+ let pipe_clone = pipe.clone();
675
+ std::thread::spawn(move || {
676
+ std::thread::sleep(Duration::from_millis(50));
677
+ pipe_clone.notify();
678
+ });
679
+
680
+ let start = Instant::now();
681
+ // IO.select([read_io], nil, nil) — blocks until readable
682
+ let result: Value = io_class.funcall("select", (select_arr, nil, nil)).unwrap();
683
+ let elapsed = start.elapsed();
684
+
685
+ assert!(
686
+ !result.is_nil(),
687
+ "IO.select should return non-nil on readable"
688
+ );
689
+ assert!(
690
+ elapsed >= Duration::from_millis(30),
691
+ "IO.select should have waited for notify, took {elapsed:?}"
692
+ );
693
+ assert!(
694
+ elapsed < Duration::from_secs(2),
695
+ "IO.select should not hang — took {elapsed:?}"
696
+ );
697
+
698
+ pipe.drain();
699
+ });
700
+ }
701
+
702
+ #[test]
703
+ #[serial]
704
+ fn test_io_select_returns_nil_on_timeout_without_notify() {
705
+ // IO.select with timeout returns nil if pipe is not notified
706
+ with_ruby_python(|ruby, _api| {
707
+ let pipe = PipeNotify::new().unwrap();
708
+ let io = create_ruby_io(ruby, pipe.read_fd()).unwrap();
709
+ let select_arr = ruby.ary_new_from_values(&[io]);
710
+ let nil = ruby.qnil().as_value();
711
+ let io_class: Value = ruby.class_object().const_get("IO").unwrap();
712
+
713
+ let start = Instant::now();
714
+ // IO.select([read_io], nil, nil, 0.05) — 50ms timeout
715
+ let result: Value = io_class
716
+ .funcall("select", (select_arr, nil, nil, 0.05))
717
+ .unwrap();
718
+ let elapsed = start.elapsed();
719
+
720
+ assert!(result.is_nil(), "IO.select should return nil on timeout");
721
+ assert!(
722
+ elapsed >= Duration::from_millis(30),
723
+ "IO.select should have waited for timeout, took {elapsed:?}"
724
+ );
725
+ });
726
+ }
727
+
728
+ #[test]
729
+ #[serial]
730
+ fn test_io_select_immediate_if_already_notified() {
731
+ // If pipe was notified before IO.select, it should return immediately
732
+ with_ruby_python(|ruby, _api| {
733
+ let pipe = PipeNotify::new().unwrap();
734
+ pipe.notify(); // Notify BEFORE select
735
+ let io = create_ruby_io(ruby, pipe.read_fd()).unwrap();
736
+ let select_arr = ruby.ary_new_from_values(&[io]);
737
+ let nil = ruby.qnil().as_value();
738
+ let io_class: Value = ruby.class_object().const_get("IO").unwrap();
739
+
740
+ let start = Instant::now();
741
+ let result: Value = io_class
742
+ .funcall("select", (select_arr, nil, nil, 1.0))
743
+ .unwrap();
744
+ let elapsed = start.elapsed();
745
+
746
+ assert!(
747
+ !result.is_nil(),
748
+ "IO.select should return non-nil when already readable"
749
+ );
750
+ assert!(
751
+ elapsed < Duration::from_millis(50),
752
+ "IO.select should return immediately when pipe is already readable, took {elapsed:?}"
753
+ );
754
+
755
+ pipe.drain();
756
+ });
757
+ }
758
+
759
+ #[test]
760
+ #[serial]
761
+ fn test_io_select_multiple_wake_cycles() {
762
+ // Multiple notify → select → drain cycles work correctly
763
+ with_ruby_python(|ruby, _api| {
764
+ let pipe = PipeNotify::new().unwrap();
765
+ let io = create_ruby_io(ruby, pipe.read_fd()).unwrap();
766
+ let select_arr = ruby.ary_new_from_values(&[io]);
767
+ let nil = ruby.qnil().as_value();
768
+ let io_class: Value = ruby.class_object().const_get("IO").unwrap();
769
+
770
+ for _ in 0..5 {
771
+ pipe.notify();
772
+ let result: Value = io_class
773
+ .funcall("select", (select_arr, nil, nil, 1.0))
774
+ .unwrap();
775
+ assert!(!result.is_nil(), "IO.select should wake on each notify");
776
+ pipe.drain();
777
+ }
778
+
779
+ // After all drains, pipe should be empty — select with timeout returns nil
780
+ let result: Value = io_class
781
+ .funcall("select", (select_arr, nil, nil, 0.01))
782
+ .unwrap();
783
+ assert!(
784
+ result.is_nil(),
785
+ "pipe should be empty after all drain cycles"
786
+ );
787
+ });
788
+ }
789
+
790
+ #[test]
791
+ #[serial]
792
+ fn test_io_select_with_channel_drain_pattern() {
793
+ // Test the full pipe + channel coordination pattern used by each_fiber_aware:
794
+ // producer sends item + notifies pipe, consumer does IO.select + drain + try_recv
795
+ with_ruby_python(|ruby, _api| {
796
+ let pipe = Arc::new(PipeNotify::new().unwrap());
797
+ let (tx, rx) = bounded::<StreamItem>(16);
798
+ let io = create_ruby_io(ruby, pipe.read_fd()).unwrap();
799
+ let select_arr = ruby.ary_new_from_values(&[io]);
800
+ let nil = ruby.qnil().as_value();
801
+ let io_class: Value = ruby.class_object().const_get("IO").unwrap();
802
+
803
+ // Producer: send items + notify for each
804
+ let pipe_clone = pipe.clone();
805
+ std::thread::spawn(move || {
806
+ for i in 0..5 {
807
+ tx.send(StreamItem::Value(SendableValue::Integer(i)))
808
+ .unwrap();
809
+ pipe_clone.notify();
810
+ std::thread::sleep(Duration::from_millis(10));
811
+ }
812
+ tx.send(StreamItem::End).unwrap();
813
+ pipe_clone.notify();
814
+ });
815
+
816
+ // Consumer: IO.select → drain → try_recv loop
817
+ let mut collected = Vec::new();
818
+ 'outer: loop {
819
+ let _: Value = io_class
820
+ .funcall("select", (select_arr, nil, nil, 2.0))
821
+ .unwrap();
822
+ pipe.drain();
823
+
824
+ loop {
825
+ match rx.try_recv() {
826
+ Ok(StreamItem::Value(v)) => {
827
+ if let SendableValue::Integer(n) = v {
828
+ collected.push(n);
829
+ }
830
+ }
831
+ Ok(StreamItem::End) => break 'outer,
832
+ Ok(StreamItem::Error(e)) => panic!("unexpected error: {e}"),
833
+ Err(crossbeam_channel::TryRecvError::Empty) => break,
834
+ Err(crossbeam_channel::TryRecvError::Disconnected) => break 'outer,
835
+ }
836
+ }
837
+ }
838
+
839
+ assert_eq!(
840
+ collected,
841
+ vec![0, 1, 2, 3, 4],
842
+ "all items should arrive in order"
843
+ );
844
+ });
845
+ }
846
+
847
+ #[test]
848
+ #[serial]
849
+ fn test_io_select_batch_notify_drains_all() {
850
+ // Producer sends multiple items between consumer wakeups.
851
+ // Consumer must drain ALL bytes from pipe and ALL items from channel.
852
+ with_ruby_python(|ruby, _api| {
853
+ let pipe = Arc::new(PipeNotify::new().unwrap());
854
+ let (tx, rx) = bounded::<StreamItem>(16);
855
+ let io = create_ruby_io(ruby, pipe.read_fd()).unwrap();
856
+ let select_arr = ruby.ary_new_from_values(&[io]);
857
+ let nil = ruby.qnil().as_value();
858
+ let io_class: Value = ruby.class_object().const_get("IO").unwrap();
859
+
860
+ // Producer: send 10 items rapidly with notifications, then End
861
+ let pipe_clone = pipe.clone();
862
+ std::thread::spawn(move || {
863
+ for i in 0..10 {
864
+ tx.send(StreamItem::Value(SendableValue::Integer(i)))
865
+ .unwrap();
866
+ pipe_clone.notify();
867
+ }
868
+ tx.send(StreamItem::End).unwrap();
869
+ pipe_clone.notify();
870
+ });
871
+
872
+ // Give producer time to send everything
873
+ std::thread::sleep(Duration::from_millis(50));
874
+
875
+ // One IO.select + drain should get everything
876
+ let _: Value = io_class
877
+ .funcall("select", (select_arr, nil, nil, 2.0))
878
+ .unwrap();
879
+ pipe.drain();
880
+
881
+ let mut collected = Vec::new();
882
+ loop {
883
+ match rx.try_recv() {
884
+ Ok(StreamItem::Value(v)) => {
885
+ if let SendableValue::Integer(n) = v {
886
+ collected.push(n);
887
+ }
888
+ }
889
+ Ok(StreamItem::End) => break,
890
+ Ok(StreamItem::Error(e)) => panic!("unexpected error: {e}"),
891
+ Err(crossbeam_channel::TryRecvError::Empty) => break,
892
+ Err(crossbeam_channel::TryRecvError::Disconnected) => break,
893
+ }
894
+ }
895
+
896
+ assert_eq!(
897
+ collected,
898
+ (0..10).collect::<Vec<_>>(),
899
+ "draining should collect all items sent in a batch"
900
+ );
901
+ });
902
+ }
903
+
904
+ #[test]
905
+ #[serial]
906
+ fn test_io_select_no_byte_accumulation() {
907
+ // After proper drain cycles, no stale notification bytes remain
908
+ with_ruby_python(|ruby, _api| {
909
+ let pipe = Arc::new(PipeNotify::new().unwrap());
910
+ let (tx, rx) = bounded::<StreamItem>(64);
911
+ let io = create_ruby_io(ruby, pipe.read_fd()).unwrap();
912
+ let select_arr = ruby.ary_new_from_values(&[io]);
913
+ let nil = ruby.qnil().as_value();
914
+ let io_class: Value = ruby.class_object().const_get("IO").unwrap();
915
+
916
+ // Send 50 items, each with a notification
917
+ for i in 0..50 {
918
+ tx.send(StreamItem::Value(SendableValue::Integer(i)))
919
+ .unwrap();
920
+ pipe.notify();
921
+ }
922
+
923
+ // Consume all items with proper drain pattern
924
+ let mut count = 0;
925
+ loop {
926
+ let result: Value = io_class
927
+ .funcall("select", (select_arr, nil, nil, 0.1))
928
+ .unwrap();
929
+ if result.is_nil() {
930
+ break; // Timeout — pipe is empty
931
+ }
932
+ pipe.drain();
933
+ while let Ok(StreamItem::Value(_)) = rx.try_recv() {
934
+ count += 1;
935
+ }
936
+ }
937
+
938
+ assert_eq!(count, 50, "all 50 items should be consumed");
939
+
940
+ // Final check: IO.select with short timeout should return nil (no stale bytes)
941
+ let result: Value = io_class
942
+ .funcall("select", (select_arr, nil, nil, 0.01))
943
+ .unwrap();
944
+ assert!(
945
+ result.is_nil(),
946
+ "no stale notification bytes should remain after proper draining"
947
+ );
948
+ });
949
+ }
950
+
951
+ // ========== Step 6: has_fiber_scheduler ==========
952
+
953
+ #[test]
954
+ #[serial]
955
+ fn test_has_fiber_scheduler_returns_false_by_default() {
956
+ with_ruby_python(|ruby, _api| {
957
+ assert!(
958
+ !has_fiber_scheduler(ruby),
959
+ "has_fiber_scheduler should return false when no scheduler is installed"
960
+ );
961
+ });
962
+ }
963
+
964
+ #[test]
965
+ #[serial]
966
+ fn test_has_fiber_scheduler_detects_nil_scheduler() {
967
+ // Fiber.scheduler returns nil by default — verify our function handles it
968
+ with_ruby_python(|ruby, _api| {
969
+ let fiber_class: Value = ruby.class_object().const_get("Fiber").unwrap();
970
+ let scheduler: Value = fiber_class.funcall("scheduler", ()).unwrap();
971
+ assert!(
972
+ scheduler.is_nil(),
973
+ "Fiber.scheduler should be nil by default"
974
+ );
975
+ assert!(!has_fiber_scheduler(ruby));
976
+ });
977
+ }
978
+
979
+ // ========== Step 6: each_fiber_aware building blocks ==========
980
+
981
+ #[test]
982
+ #[serial]
983
+ fn test_io_select_with_error_item() {
984
+ // Verify the drain pattern correctly surfaces errors from the channel
985
+ with_ruby_python(|ruby, _api| {
986
+ let pipe = Arc::new(PipeNotify::new().unwrap());
987
+ let (tx, rx) = bounded::<StreamItem>(16);
988
+ let io = create_ruby_io(ruby, pipe.read_fd()).unwrap();
989
+ let select_arr = ruby.ary_new_from_values(&[io]);
990
+ let nil = ruby.qnil().as_value();
991
+ let io_class: Value = ruby.class_object().const_get("IO").unwrap();
992
+
993
+ // Send a value, then an error, with notifications
994
+ tx.send(StreamItem::Value(SendableValue::Integer(1)))
995
+ .unwrap();
996
+ pipe.notify();
997
+ tx.send(StreamItem::Error("stream failed".to_string()))
998
+ .unwrap();
999
+ pipe.notify();
1000
+
1001
+ let _: Value = io_class
1002
+ .funcall("select", (select_arr, nil, nil, 1.0))
1003
+ .unwrap();
1004
+ pipe.drain();
1005
+
1006
+ // First item should be the value
1007
+ match rx.try_recv().unwrap() {
1008
+ StreamItem::Value(SendableValue::Integer(n)) => assert_eq!(n, 1),
1009
+ _ => panic!("expected Integer(1)"),
1010
+ }
1011
+
1012
+ // Second item should be the error
1013
+ match rx.try_recv().unwrap() {
1014
+ StreamItem::Error(msg) => assert_eq!(msg, "stream failed"),
1015
+ _ => panic!("expected Error"),
1016
+ }
1017
+ });
1018
+ }
1019
+
1020
+ #[test]
1021
+ #[serial]
1022
+ fn test_io_select_with_disconnect() {
1023
+ // If the producer drops the sender, try_recv returns Disconnected
1024
+ with_ruby_python(|ruby, _api| {
1025
+ let pipe = Arc::new(PipeNotify::new().unwrap());
1026
+ let (tx, rx) = bounded::<StreamItem>(16);
1027
+ let io = create_ruby_io(ruby, pipe.read_fd()).unwrap();
1028
+ let select_arr = ruby.ary_new_from_values(&[io]);
1029
+ let nil = ruby.qnil().as_value();
1030
+ let io_class: Value = ruby.class_object().const_get("IO").unwrap();
1031
+
1032
+ // Send one value, notify, then drop sender
1033
+ tx.send(StreamItem::Value(SendableValue::Integer(99)))
1034
+ .unwrap();
1035
+ pipe.notify();
1036
+ drop(tx);
1037
+
1038
+ let _: Value = io_class
1039
+ .funcall("select", (select_arr, nil, nil, 1.0))
1040
+ .unwrap();
1041
+ pipe.drain();
1042
+
1043
+ // Get the value
1044
+ match rx.try_recv().unwrap() {
1045
+ StreamItem::Value(SendableValue::Integer(n)) => assert_eq!(n, 99),
1046
+ _ => panic!("expected Integer(99)"),
1047
+ }
1048
+
1049
+ // Next try_recv should indicate disconnected
1050
+ assert!(matches!(
1051
+ rx.try_recv(),
1052
+ Err(crossbeam_channel::TryRecvError::Disconnected)
1053
+ ));
1054
+ });
1055
+ }
1056
+
1057
+ #[test]
1058
+ #[serial]
1059
+ fn test_create_ruby_io_multiple_pipes() {
1060
+ // Multiple Ruby IO objects wrapping different pipes should work independently
1061
+ with_ruby_python(|ruby, _api| {
1062
+ let pipe1 = PipeNotify::new().unwrap();
1063
+ let pipe2 = PipeNotify::new().unwrap();
1064
+
1065
+ let io1 = create_ruby_io(ruby, pipe1.read_fd()).unwrap();
1066
+ let io2 = create_ruby_io(ruby, pipe2.read_fd()).unwrap();
1067
+
1068
+ let fileno1: i32 = io1.funcall("fileno", ()).unwrap();
1069
+ let fileno2: i32 = io2.funcall("fileno", ()).unwrap();
1070
+
1071
+ assert_ne!(
1072
+ fileno1, fileno2,
1073
+ "different pipes should have different fds"
1074
+ );
1075
+
1076
+ // Notify pipe1 only — IO.select on io1 should return, io2 should not
1077
+ pipe1.notify();
1078
+
1079
+ let nil = ruby.qnil().as_value();
1080
+ let io_class: Value = ruby.class_object().const_get("IO").unwrap();
1081
+
1082
+ let arr1 = ruby.ary_new_from_values(&[io1]);
1083
+ let result1: Value = io_class.funcall("select", (arr1, nil, nil, 0.1)).unwrap();
1084
+ assert!(
1085
+ !result1.is_nil(),
1086
+ "io1 should be readable after pipe1.notify()"
1087
+ );
1088
+
1089
+ let arr2 = ruby.ary_new_from_values(&[io2]);
1090
+ let result2: Value = io_class.funcall("select", (arr2, nil, nil, 0.01)).unwrap();
1091
+ assert!(
1092
+ result2.is_nil(),
1093
+ "io2 should NOT be readable (pipe2 not notified)"
1094
+ );
1095
+
1096
+ pipe1.drain();
1097
+ });
1098
+ }
1099
+
1100
+ // ========== Step 7: each() auto-dispatch (needs Ruby class registration) ==========
1101
+
1102
+ /// Register NonBlockingStream as a Ruby class so we can test `each` with a block.
1103
+ /// Idempotent — safe to call multiple times.
1104
+ fn ensure_nb_class_registered(ruby: &Ruby) {
1105
+ let rubyx = ruby.define_module("Rubyx").unwrap();
1106
+ let class = rubyx
1107
+ .define_class("NonBlockingStream", ruby.class_object())
1108
+ .unwrap();
1109
+ // define_method is idempotent (replaces if exists)
1110
+ class
1111
+ .define_method("each", magnus::method!(NonBlockingStream::each, 0))
1112
+ .unwrap();
1113
+ class.include_module(ruby.module_enumerable()).unwrap();
1114
+ }
1115
+
1116
+ /// Wrap a NonBlockingStream as a Ruby object so we can call Ruby methods on it.
1117
+ fn wrap_stream(ruby: &Ruby, stream: NonBlockingStream) -> Value {
1118
+ use magnus::IntoValue;
1119
+ stream.into_value_with(ruby)
1120
+ }
1121
+
1122
+ #[test]
1123
+ #[serial]
1124
+ fn test_each_dispatches_gvl_release_without_scheduler() {
1125
+ // Without a Fiber Scheduler, each() should use the GVL-release path
1126
+ // and produce correct results via to_a (which calls each with a block)
1127
+ with_ruby_python(|ruby, _api| {
1128
+ ensure_nb_class_registered(ruby);
1129
+
1130
+ let (tx, rx) = unbounded();
1131
+ let pipe = Arc::new(PipeNotify::new().unwrap());
1132
+ for i in 0..5 {
1133
+ tx.send(StreamItem::Value(SendableValue::Integer(i)))
1134
+ .unwrap();
1135
+ }
1136
+ tx.send(StreamItem::End).unwrap();
1137
+
1138
+ let stream = NonBlockingStream::new(rx, pipe);
1139
+ let ruby_obj = wrap_stream(ruby, stream);
1140
+
1141
+ // to_a calls each internally, which will use GVL-release path
1142
+ // (no Fiber Scheduler installed)
1143
+ let result: Value = ruby_obj.funcall("to_a", ()).unwrap();
1144
+ let arr = RArray::try_convert(result).unwrap();
1145
+
1146
+ assert_eq!(arr.len(), 5);
1147
+ for i in 0..5 {
1148
+ let v = i64::try_convert(arr.entry::<Value>(i).unwrap()).unwrap();
1149
+ assert_eq!(v, i as i64);
1150
+ }
1151
+ });
1152
+ }
1153
+
1154
+ #[test]
1155
+ #[serial]
1156
+ fn test_each_empty_stream() {
1157
+ with_ruby_python(|ruby, _api| {
1158
+ ensure_nb_class_registered(ruby);
1159
+
1160
+ let (tx, rx) = unbounded();
1161
+ let pipe = Arc::new(PipeNotify::new().unwrap());
1162
+ tx.send(StreamItem::End).unwrap();
1163
+
1164
+ let stream = NonBlockingStream::new(rx, pipe);
1165
+ let ruby_obj = wrap_stream(ruby, stream);
1166
+
1167
+ let result: Value = ruby_obj.funcall("to_a", ()).unwrap();
1168
+ let arr = RArray::try_convert(result).unwrap();
1169
+ assert_eq!(arr.len(), 0, "empty stream should produce empty array");
1170
+ });
1171
+ }
1172
+
1173
+ #[test]
1174
+ #[serial]
1175
+ fn test_each_single_value() {
1176
+ with_ruby_python(|ruby, _api| {
1177
+ ensure_nb_class_registered(ruby);
1178
+
1179
+ let (tx, rx) = unbounded();
1180
+ let pipe = Arc::new(PipeNotify::new().unwrap());
1181
+ tx.send(StreamItem::Value(SendableValue::Str("hello".to_string())))
1182
+ .unwrap();
1183
+ tx.send(StreamItem::End).unwrap();
1184
+
1185
+ let stream = NonBlockingStream::new(rx, pipe);
1186
+ let ruby_obj = wrap_stream(ruby, stream);
1187
+
1188
+ let result: Value = ruby_obj.funcall("to_a", ()).unwrap();
1189
+ let arr = RArray::try_convert(result).unwrap();
1190
+ assert_eq!(arr.len(), 1);
1191
+ assert_eq!(
1192
+ String::try_convert(arr.entry::<Value>(0).unwrap()).unwrap(),
1193
+ "hello"
1194
+ );
1195
+ });
1196
+ }
1197
+
1198
+ #[test]
1199
+ #[serial]
1200
+ fn test_each_mixed_types() {
1201
+ with_ruby_python(|ruby, _api| {
1202
+ ensure_nb_class_registered(ruby);
1203
+
1204
+ let (tx, rx) = unbounded();
1205
+ let pipe = Arc::new(PipeNotify::new().unwrap());
1206
+ tx.send(StreamItem::Value(SendableValue::Integer(42)))
1207
+ .unwrap();
1208
+ tx.send(StreamItem::Value(SendableValue::Float(
1209
+ std::f64::consts::PI,
1210
+ )))
1211
+ .unwrap();
1212
+ tx.send(StreamItem::Value(SendableValue::Str("test".to_string())))
1213
+ .unwrap();
1214
+ tx.send(StreamItem::Value(SendableValue::Bool(true)))
1215
+ .unwrap();
1216
+ tx.send(StreamItem::Value(SendableValue::Nil)).unwrap();
1217
+ tx.send(StreamItem::End).unwrap();
1218
+
1219
+ let stream = NonBlockingStream::new(rx, pipe);
1220
+ let ruby_obj = wrap_stream(ruby, stream);
1221
+
1222
+ let result: Value = ruby_obj.funcall("to_a", ()).unwrap();
1223
+ let arr = RArray::try_convert(result).unwrap();
1224
+ assert_eq!(arr.len(), 5);
1225
+
1226
+ assert_eq!(
1227
+ i64::try_convert(arr.entry::<Value>(0).unwrap()).unwrap(),
1228
+ 42
1229
+ );
1230
+ assert!(
1231
+ (f64::try_convert(arr.entry::<Value>(1).unwrap()).unwrap() - std::f64::consts::PI)
1232
+ .abs()
1233
+ < 1e-9
1234
+ );
1235
+ assert_eq!(
1236
+ String::try_convert(arr.entry::<Value>(2).unwrap()).unwrap(),
1237
+ "test"
1238
+ );
1239
+ assert!(bool::try_convert(arr.entry::<Value>(3).unwrap()).unwrap());
1240
+ assert!(arr.entry::<Value>(4).unwrap().is_nil());
1241
+ });
1242
+ }
1243
+
1244
+ #[test]
1245
+ #[serial]
1246
+ fn test_each_propagates_error() {
1247
+ with_ruby_python(|ruby, _api| {
1248
+ ensure_nb_class_registered(ruby);
1249
+
1250
+ let (tx, rx) = unbounded();
1251
+ let pipe = Arc::new(PipeNotify::new().unwrap());
1252
+ tx.send(StreamItem::Value(SendableValue::Integer(1)))
1253
+ .unwrap();
1254
+ tx.send(StreamItem::Error("stream exploded".to_string()))
1255
+ .unwrap();
1256
+
1257
+ let stream = NonBlockingStream::new(rx, pipe);
1258
+ let ruby_obj = wrap_stream(ruby, stream);
1259
+
1260
+ let err = ruby_obj.funcall::<_, _, Value>("to_a", ()).unwrap_err();
1261
+ assert!(
1262
+ err.to_string().contains("stream exploded"),
1263
+ "error should propagate: {err}"
1264
+ );
1265
+ });
1266
+ }
1267
+
1268
+ #[test]
1269
+ #[serial]
1270
+ fn test_each_with_delayed_producer() {
1271
+ // Producer sends values after a delay — each() should wait via GVL release
1272
+ with_ruby_python(|ruby, _api| {
1273
+ ensure_nb_class_registered(ruby);
1274
+
1275
+ let (tx, rx) = unbounded();
1276
+ let pipe = Arc::new(PipeNotify::new().unwrap());
1277
+
1278
+ std::thread::spawn(move || {
1279
+ std::thread::sleep(Duration::from_millis(50));
1280
+ for i in 0..3 {
1281
+ tx.send(StreamItem::Value(SendableValue::Integer(i)))
1282
+ .unwrap();
1283
+ }
1284
+ tx.send(StreamItem::End).unwrap();
1285
+ });
1286
+
1287
+ let stream = NonBlockingStream::new(rx, pipe);
1288
+ let ruby_obj = wrap_stream(ruby, stream);
1289
+
1290
+ let start = Instant::now();
1291
+ let result: Value = ruby_obj.funcall("to_a", ()).unwrap();
1292
+ let elapsed = start.elapsed();
1293
+
1294
+ let arr = RArray::try_convert(result).unwrap();
1295
+ assert_eq!(arr.len(), 3);
1296
+ assert!(
1297
+ elapsed >= Duration::from_millis(30),
1298
+ "should have waited for producer, took {elapsed:?}"
1299
+ );
1300
+ });
1301
+ }
1302
+
1303
+ #[test]
1304
+ #[serial]
1305
+ fn test_each_disconnect_returns_empty() {
1306
+ // If producer drops sender immediately, each() should return without error
1307
+ with_ruby_python(|ruby, _api| {
1308
+ ensure_nb_class_registered(ruby);
1309
+
1310
+ let (tx, rx) = bounded::<StreamItem>(16);
1311
+ let pipe = Arc::new(PipeNotify::new().unwrap());
1312
+ drop(tx);
1313
+
1314
+ let stream = NonBlockingStream::new(rx, pipe);
1315
+ let ruby_obj = wrap_stream(ruby, stream);
1316
+
1317
+ let result: Value = ruby_obj.funcall("to_a", ()).unwrap();
1318
+ let arr = RArray::try_convert(result).unwrap();
1319
+ assert_eq!(
1320
+ arr.len(),
1321
+ 0,
1322
+ "disconnected channel should produce empty array"
1323
+ );
1324
+ });
1325
+ }
1326
+
1327
+ #[test]
1328
+ #[serial]
1329
+ fn test_each_enumerable_first() {
1330
+ // Enumerable#first should work (calls each, takes first N, breaks)
1331
+ with_ruby_python(|ruby, _api| {
1332
+ ensure_nb_class_registered(ruby);
1333
+
1334
+ let (tx, rx) = unbounded();
1335
+ let pipe = Arc::new(PipeNotify::new().unwrap());
1336
+ for i in 0..10 {
1337
+ tx.send(StreamItem::Value(SendableValue::Integer(i)))
1338
+ .unwrap();
1339
+ }
1340
+ tx.send(StreamItem::End).unwrap();
1341
+
1342
+ let stream = NonBlockingStream::new(rx, pipe);
1343
+ let ruby_obj = wrap_stream(ruby, stream);
1344
+
1345
+ let result: Value = ruby_obj.funcall("first", (3,)).unwrap();
1346
+ let arr = RArray::try_convert(result).unwrap();
1347
+ assert_eq!(arr.len(), 3);
1348
+ assert_eq!(i64::try_convert(arr.entry::<Value>(0).unwrap()).unwrap(), 0);
1349
+ assert_eq!(i64::try_convert(arr.entry::<Value>(1).unwrap()).unwrap(), 1);
1350
+ assert_eq!(i64::try_convert(arr.entry::<Value>(2).unwrap()).unwrap(), 2);
1351
+ });
1352
+ }
1353
+
1354
+ #[test]
1355
+ #[serial]
1356
+ fn test_each_uses_gvl_release_path_without_scheduler() {
1357
+ // Confirm has_fiber_scheduler returns false AND each() still works.
1358
+ // This proves the GVL-release dispatch path is exercised.
1359
+ with_ruby_python(|ruby, _api| {
1360
+ ensure_nb_class_registered(ruby);
1361
+
1362
+ assert!(
1363
+ !has_fiber_scheduler(ruby),
1364
+ "precondition: no Fiber Scheduler should be installed"
1365
+ );
1366
+
1367
+ let (tx, rx) = unbounded();
1368
+ let pipe = Arc::new(PipeNotify::new().unwrap());
1369
+ tx.send(StreamItem::Value(SendableValue::Integer(99)))
1370
+ .unwrap();
1371
+ tx.send(StreamItem::End).unwrap();
1372
+
1373
+ let stream = NonBlockingStream::new(rx, pipe);
1374
+ let ruby_obj = wrap_stream(ruby, stream);
1375
+
1376
+ let result: Value = ruby_obj.funcall("to_a", ()).unwrap();
1377
+ let arr = RArray::try_convert(result).unwrap();
1378
+ assert_eq!(arr.len(), 1);
1379
+ assert_eq!(
1380
+ i64::try_convert(arr.entry::<Value>(0).unwrap()).unwrap(),
1381
+ 99
1382
+ );
1383
+ });
1384
+ }
1385
+
1386
+ #[test]
1387
+ #[serial]
1388
+ fn test_each_large_stream() {
1389
+ with_ruby_python(|ruby, _api| {
1390
+ ensure_nb_class_registered(ruby);
1391
+
1392
+ let (tx, rx) = unbounded();
1393
+ let pipe = Arc::new(PipeNotify::new().unwrap());
1394
+
1395
+ std::thread::spawn(move || {
1396
+ for i in 0..1000 {
1397
+ tx.send(StreamItem::Value(SendableValue::Integer(i)))
1398
+ .unwrap();
1399
+ }
1400
+ tx.send(StreamItem::End).unwrap();
1401
+ });
1402
+
1403
+ let stream = NonBlockingStream::new(rx, pipe);
1404
+ let ruby_obj = wrap_stream(ruby, stream);
1405
+
1406
+ let result: Value = ruby_obj.funcall("to_a", ()).unwrap();
1407
+ let arr = RArray::try_convert(result).unwrap();
1408
+ assert_eq!(arr.len(), 1000);
1409
+
1410
+ // Verify order: check first, middle, last
1411
+ assert_eq!(i64::try_convert(arr.entry::<Value>(0).unwrap()).unwrap(), 0);
1412
+ assert_eq!(
1413
+ i64::try_convert(arr.entry::<Value>(500).unwrap()).unwrap(),
1414
+ 500
1415
+ );
1416
+ assert_eq!(
1417
+ i64::try_convert(arr.entry::<Value>(999).unwrap()).unwrap(),
1418
+ 999
1419
+ );
1420
+ });
1421
+ }
1422
+ }