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,713 @@
1
+ use crate::api;
2
+ use crate::python_ffi::PyObject;
3
+ use crate::ruby_helpers::runtime_error;
4
+ use crate::rubyx_object::python_to_sendable;
5
+ use crossbeam_channel::{bounded, unbounded, Receiver, Sender};
6
+ use magnus::value::ReprValue;
7
+ use magnus::{IntoValue, Value};
8
+ use std::thread;
9
+ use std::thread::JoinHandle;
10
+
11
+ /// Thread-safe intermediate representation of a Python/Ruby value.
12
+ ///
13
+ /// `magnus::Value` wraps a `*mut RBasic` (a raw pointer), which is NOT Send.
14
+ /// We must convert Python values to pure Rust types in the worker thread,
15
+ /// send those through the channel, and convert to `magnus::Value` on the
16
+ /// Ruby thread (which holds the GVL).
17
+ #[derive(Debug)]
18
+ pub(crate) enum SendableValue {
19
+ Nil,
20
+ Integer(i64),
21
+ Float(f64),
22
+ Str(String),
23
+ Bool(bool),
24
+ List(Vec<SendableValue>),
25
+ Dict(Vec<(SendableValue, SendableValue)>),
26
+ }
27
+ impl TryInto<magnus::Value> for SendableValue {
28
+ type Error = magnus::Error;
29
+
30
+ fn try_into(self) -> Result<Value, Self::Error> {
31
+ let ruby = magnus::Ruby::get().map_err(|_| {
32
+ magnus::Error::new(runtime_error(), "Must be called on Ruby thread".to_string())
33
+ })?;
34
+ let result = match self {
35
+ SendableValue::Nil => ruby.qnil().as_value(),
36
+ SendableValue::Integer(n) => n.into_value_with(&ruby),
37
+ SendableValue::Float(f) => f.into_value_with(&ruby),
38
+ SendableValue::Str(s) => s.as_str().into_value_with(&ruby),
39
+ SendableValue::Bool(b) => b.into_value_with(&ruby),
40
+ SendableValue::List(l) => {
41
+ let ruby_array = ruby.ary_new_capa(l.len());
42
+ for item in l {
43
+ let val: Value = item.try_into()?;
44
+ ruby_array.push(val)?;
45
+ }
46
+ ruby_array.as_value()
47
+ }
48
+ SendableValue::Dict(entries) => {
49
+ let hash = ruby.hash_new();
50
+ for (k, v) in entries {
51
+ let key: Value = k.try_into()?;
52
+ let val: Value = v.try_into()?;
53
+ hash.aset(key, val)?;
54
+ }
55
+ hash.as_value()
56
+ }
57
+ };
58
+ Ok(result)
59
+ }
60
+ }
61
+
62
+ /// Item sent through stream - Send safe
63
+ pub(crate) enum StreamItem {
64
+ Value(SendableValue),
65
+ Error(String),
66
+ End,
67
+ }
68
+
69
+ /// A Stream of values from a background thread
70
+ #[allow(dead_code)]
71
+ pub struct AsyncStream {
72
+ receiver: Option<Receiver<StreamItem>>,
73
+ cancel_sender: Sender<()>,
74
+ handle: Option<JoinHandle<()>>,
75
+ }
76
+
77
+ impl AsyncStream {
78
+ /// Stream which iterates a Python Iterator in the background
79
+ #[allow(dead_code)]
80
+ pub fn from_python_iterator(py_iter: *mut PyObject) -> Self {
81
+ let (value_tx, value_rx) = unbounded();
82
+ let (cancel_tx, cancel_rx) = bounded(1);
83
+
84
+ // Cast the raw pointer to usize so it can cross the thread boundary.
85
+ // *mut PyObject is not Send, but the usize value is just a number.
86
+ // This is safe because the worker thread will acquire the GIL before
87
+ // using the pointer, and the pointer remains valid (Python iterator
88
+ // is kept alive by its refcount).
89
+ let py_iter_addr = py_iter as usize;
90
+
91
+ let handle = thread::spawn(move || {
92
+ let py_iter = py_iter_addr as *mut PyObject;
93
+ // Worker thread: acquire GIL, iterate, send values
94
+ let api = api();
95
+ let gil = api.ensure_gil();
96
+
97
+ loop {
98
+ // Check if there is a cancellation
99
+ if cancel_rx.try_recv().is_ok() {
100
+ break;
101
+ }
102
+
103
+ // Get next item from Python iterator
104
+ let item = api.iter_next(py_iter);
105
+ if item.is_null() {
106
+ // Check if an exception was raised (vs normal exhaustion)
107
+ if api.has_error() {
108
+ if let Some(exc) = crate::python_api::PythonApi::extract_exception(api) {
109
+ value_tx.send(StreamItem::Error(exc.to_string())).ok();
110
+ } else {
111
+ value_tx.send(StreamItem::End).ok();
112
+ }
113
+ } else {
114
+ value_tx.send(StreamItem::End).ok();
115
+ }
116
+ break;
117
+ }
118
+ // Convert and send to ruby
119
+ let ruby_value = python_to_sendable(item, api)
120
+ .map_err(|e| format!("Error converting Python value to Ruby: {e}"));
121
+ api.decref(item);
122
+ match ruby_value {
123
+ Ok(value) => {
124
+ if value_tx.send(StreamItem::Value(value)).is_err() {
125
+ break; // Consumer dropped — stop producing
126
+ }
127
+ }
128
+ Err(e) => {
129
+ value_tx.send(StreamItem::Error(e)).ok();
130
+ break;
131
+ }
132
+ }
133
+ }
134
+ api.decref(py_iter);
135
+ api.release_gil(gil);
136
+ });
137
+ Self {
138
+ receiver: Some(value_rx),
139
+ cancel_sender: cancel_tx,
140
+ handle: Some(handle),
141
+ }
142
+ }
143
+ }
144
+
145
+ impl Drop for AsyncStream {
146
+ fn drop(&mut self) {
147
+ // 1. Signal cancellation
148
+ self.cancel_sender.try_send(()).ok();
149
+ // 2. Drain then drop the receiver so value_tx.send() returns Err,
150
+ // unblocking the worker thread
151
+ if let Some(rx) = self.receiver.take() {
152
+ while rx.try_recv().is_ok() {}
153
+ drop(rx);
154
+ }
155
+ // 3. Join the worker thread (now guaranteed to exit)
156
+ if let Some(handle) = self.handle.take() {
157
+ let _ = handle.join();
158
+ }
159
+ }
160
+ }
161
+
162
+ #[cfg(test)]
163
+ impl AsyncStream {
164
+ /// Test constructor: create an AsyncStream from a channel of `Option<SendableValue>`.
165
+ /// `Some(val)` sends a value, `None` signals end-of-stream.
166
+ pub(crate) fn from_channel(rx: Receiver<Option<SendableValue>>, cancel_tx: Sender<()>) -> Self {
167
+ let (value_tx, value_rx) = unbounded();
168
+
169
+ let handle = thread::spawn(move || {
170
+ while let Ok(item) = rx.recv() {
171
+ match item {
172
+ Some(val) => {
173
+ if value_tx.send(StreamItem::Value(val)).is_err() {
174
+ return;
175
+ }
176
+ }
177
+ None => {
178
+ value_tx.send(StreamItem::End).ok();
179
+ return;
180
+ }
181
+ }
182
+ }
183
+ // Sender dropped without None — treat as end
184
+ value_tx.send(StreamItem::End).ok();
185
+ });
186
+
187
+ Self {
188
+ receiver: Some(value_rx),
189
+ cancel_sender: cancel_tx,
190
+ handle: Some(handle),
191
+ }
192
+ }
193
+ }
194
+
195
+ impl Iterator for AsyncStream {
196
+ type Item = Result<Value, magnus::Error>;
197
+ fn next(&mut self) -> Option<Self::Item> {
198
+ let rx = self.receiver.as_ref()?;
199
+ match rx.recv() {
200
+ Ok(StreamItem::Value(v)) => Some(v.try_into()),
201
+ Ok(StreamItem::Error(e)) => Some(Err(magnus::Error::new(runtime_error(), e))),
202
+ Ok(StreamItem::End) | Err(_) => None,
203
+ }
204
+ }
205
+ }
206
+
207
+ #[cfg(test)]
208
+ mod tests {
209
+ use super::*;
210
+ use crate::test_helpers::{skip_if_no_python, with_ruby_python};
211
+ use crossbeam_channel::bounded;
212
+ use magnus::TryConvert;
213
+ use serial_test::serial;
214
+ use std::sync::atomic::{AtomicBool, Ordering};
215
+ use std::sync::Arc;
216
+ use std::time::{Duration, Instant};
217
+
218
+ // ========== Iterator: basic value delivery ==========
219
+
220
+ #[test]
221
+ #[serial]
222
+ fn test_iterator_delivers_single_value() {
223
+ with_ruby_python(|_ruby, _api| {
224
+ let (tx, rx) = unbounded();
225
+ let (cancel_tx, _cancel_rx) = bounded(1);
226
+
227
+ thread::spawn(move || {
228
+ tx.send(Some(SendableValue::Integer(42))).ok();
229
+ tx.send(None).ok();
230
+ });
231
+
232
+ let mut stream = AsyncStream::from_channel(rx, cancel_tx);
233
+ let val = stream.next().unwrap().unwrap();
234
+ assert_eq!(i64::try_convert(val).unwrap(), 42);
235
+ assert!(stream.next().is_none());
236
+ });
237
+ }
238
+
239
+ #[test]
240
+ #[serial]
241
+ fn test_iterator_delivers_multiple_values_in_order() {
242
+ with_ruby_python(|_ruby, _api| {
243
+ let (tx, rx) = unbounded();
244
+ let (cancel_tx, _cancel_rx) = bounded(1);
245
+
246
+ thread::spawn(move || {
247
+ for i in 0..5 {
248
+ tx.send(Some(SendableValue::Integer(i))).ok();
249
+ }
250
+ tx.send(None).ok();
251
+ });
252
+
253
+ let mut stream = AsyncStream::from_channel(rx, cancel_tx);
254
+ for expected in 0..5 {
255
+ let val = stream.next().unwrap().unwrap();
256
+ assert_eq!(i64::try_convert(val).unwrap(), expected);
257
+ }
258
+ assert!(stream.next().is_none());
259
+ });
260
+ }
261
+
262
+ #[test]
263
+ #[serial]
264
+ fn test_iterator_empty_stream() {
265
+ with_ruby_python(|_ruby, _api| {
266
+ let (tx, rx) = unbounded();
267
+ let (cancel_tx, _cancel_rx) = bounded(1);
268
+
269
+ thread::spawn(move || {
270
+ tx.send(None).ok();
271
+ });
272
+
273
+ let mut stream = AsyncStream::from_channel(rx, cancel_tx);
274
+ assert!(stream.next().is_none());
275
+ });
276
+ }
277
+
278
+ #[test]
279
+ #[serial]
280
+ fn test_iterator_propagates_error() {
281
+ with_ruby_python(|_ruby, _api| {
282
+ let (value_tx, value_rx) = unbounded();
283
+
284
+ // Manually build the stream to inject an error StreamItem
285
+ let (cancel_tx, _cancel_rx) = bounded(1);
286
+ let handle = thread::spawn(move || {
287
+ value_tx
288
+ .send(StreamItem::Value(SendableValue::Integer(1)))
289
+ .ok();
290
+ value_tx
291
+ .send(StreamItem::Error("something went wrong".to_string()))
292
+ .ok();
293
+ });
294
+
295
+ let mut stream = AsyncStream {
296
+ receiver: Some(value_rx),
297
+ cancel_sender: cancel_tx,
298
+ handle: Some(handle),
299
+ };
300
+
301
+ // First item succeeds
302
+ let val = stream.next().unwrap().unwrap();
303
+ assert_eq!(i64::try_convert(val).unwrap(), 1);
304
+
305
+ // Second item is an error
306
+ let err = stream.next().unwrap().unwrap_err();
307
+ assert!(err.to_string().contains("something went wrong"));
308
+ });
309
+ }
310
+
311
+ #[test]
312
+ #[serial]
313
+ fn test_iterator_end_then_none() {
314
+ with_ruby_python(|_ruby, _api| {
315
+ let (value_tx, value_rx) = unbounded();
316
+ let (cancel_tx, _cancel_rx) = bounded(1);
317
+
318
+ let handle = thread::spawn(move || {
319
+ value_tx.send(StreamItem::End).ok();
320
+ });
321
+
322
+ let mut stream = AsyncStream {
323
+ receiver: Some(value_rx),
324
+ cancel_sender: cancel_tx,
325
+ handle: Some(handle),
326
+ };
327
+
328
+ assert!(stream.next().is_none());
329
+ // Calling next after End should also return None (channel closed)
330
+ assert!(stream.next().is_none());
331
+ });
332
+ }
333
+
334
+ #[test]
335
+ #[serial]
336
+ fn test_iterator_channel_closed_returns_none() {
337
+ with_ruby_python(|_ruby, _api| {
338
+ let (value_tx, value_rx) = bounded::<StreamItem>(16);
339
+ let (cancel_tx, _cancel_rx) = bounded(1);
340
+
341
+ // Drop sender immediately — simulates producer crash
342
+ drop(value_tx);
343
+
344
+ let mut stream = AsyncStream {
345
+ receiver: Some(value_rx),
346
+ cancel_sender: cancel_tx,
347
+ handle: None,
348
+ };
349
+
350
+ assert!(stream.next().is_none());
351
+ });
352
+ }
353
+
354
+ // ========== Iterator: type handling ==========
355
+
356
+ #[test]
357
+ #[serial]
358
+ fn test_iterator_all_sendable_types() {
359
+ with_ruby_python(|_ruby, _api| {
360
+ let (tx, rx) = unbounded();
361
+ let (cancel_tx, _cancel_rx) = bounded(1);
362
+
363
+ thread::spawn(move || {
364
+ tx.send(Some(SendableValue::Nil)).ok();
365
+ tx.send(Some(SendableValue::Integer(99))).ok();
366
+ tx.send(Some(SendableValue::Float(1.5))).ok();
367
+ tx.send(Some(SendableValue::Str("test".to_string()))).ok();
368
+ tx.send(Some(SendableValue::Bool(false))).ok();
369
+ tx.send(Some(SendableValue::List(vec![SendableValue::Integer(1)])))
370
+ .ok();
371
+ tx.send(Some(SendableValue::Dict(vec![(
372
+ SendableValue::Str("k".to_string()),
373
+ SendableValue::Integer(2),
374
+ )])))
375
+ .ok();
376
+ tx.send(None).ok();
377
+ });
378
+
379
+ let mut stream = AsyncStream::from_channel(rx, cancel_tx);
380
+ let mut results = Vec::new();
381
+ while let Some(Ok(val)) = stream.next() {
382
+ results.push(val);
383
+ }
384
+ assert_eq!(results.len(), 7);
385
+
386
+ assert!(results[0].is_nil());
387
+ assert_eq!(i64::try_convert(results[1]).unwrap(), 99);
388
+ assert!((f64::try_convert(results[2]).unwrap() - 1.5).abs() < 1e-9);
389
+ assert_eq!(String::try_convert(results[3]).unwrap(), "test");
390
+ assert!(!bool::try_convert(results[4]).unwrap());
391
+
392
+ let arr = magnus::RArray::try_convert(results[5]).unwrap();
393
+ assert_eq!(arr.len(), 1);
394
+
395
+ let hash = magnus::RHash::try_convert(results[6]).unwrap();
396
+ let v: i64 = hash.fetch("k").unwrap();
397
+ assert_eq!(v, 2);
398
+ });
399
+ }
400
+
401
+ // ========== Drop: cancellation signal ==========
402
+
403
+ #[test]
404
+ #[serial]
405
+ fn test_drop_sends_cancel_signal() {
406
+ let Some(_guard) = skip_if_no_python() else {
407
+ return;
408
+ };
409
+
410
+ let (tx, rx) = unbounded();
411
+ let (cancel_tx, cancel_rx) = bounded(1);
412
+
413
+ thread::spawn(move || {
414
+ tx.send(Some(SendableValue::Integer(1))).ok();
415
+ // Don't send None — stream stays open
416
+ });
417
+
418
+ let stream = AsyncStream::from_channel(rx, cancel_tx);
419
+ drop(stream);
420
+
421
+ // The cancel channel should have received a signal
422
+ // (or be disconnected because cancel_sender was dropped after sending)
423
+ // Either way, the cancel_rx side should have gotten the message
424
+ assert!(
425
+ cancel_rx.try_recv().is_ok() || cancel_rx.try_recv().is_err(),
426
+ "cancel signal should have been sent before drop completed"
427
+ );
428
+ }
429
+
430
+ #[test]
431
+ #[serial]
432
+ fn test_drop_joins_worker_thread() {
433
+ let Some(_guard) = skip_if_no_python() else {
434
+ return;
435
+ };
436
+
437
+ let thread_finished = Arc::new(AtomicBool::new(false));
438
+ let thread_finished_clone = thread_finished.clone();
439
+
440
+ let (value_tx, value_rx) = unbounded();
441
+ let (cancel_tx, cancel_rx) = bounded(1);
442
+
443
+ let handle = thread::spawn(move || {
444
+ // Wait for cancel signal or a short timeout
445
+ let _ = cancel_rx.recv_timeout(Duration::from_secs(5));
446
+ value_tx.send(StreamItem::End).ok();
447
+ thread_finished_clone.store(true, Ordering::SeqCst);
448
+ });
449
+
450
+ let stream = AsyncStream {
451
+ receiver: Some(value_rx),
452
+ cancel_sender: cancel_tx,
453
+ handle: Some(handle),
454
+ };
455
+
456
+ // Drop should send cancel, drain, and join
457
+ drop(stream);
458
+
459
+ // After drop returns, the thread must have finished
460
+ assert!(
461
+ thread_finished.load(Ordering::SeqCst),
462
+ "worker thread should have been joined by Drop"
463
+ );
464
+ }
465
+
466
+ #[test]
467
+ #[serial]
468
+ fn test_drop_unblocks_producer_via_drain() {
469
+ let Some(_guard) = skip_if_no_python() else {
470
+ return;
471
+ };
472
+
473
+ let (value_tx, value_rx) = bounded(1); // tiny buffer
474
+ let (cancel_tx, cancel_rx) = bounded(1);
475
+
476
+ let producer_done = Arc::new(AtomicBool::new(false));
477
+ let producer_done_clone = producer_done.clone();
478
+
479
+ let handle = thread::spawn(move || {
480
+ // Fill the buffer
481
+ let _ = value_tx.send(StreamItem::Value(SendableValue::Integer(1)));
482
+ // This will block because buffer is full
483
+ // Drop's drain + cancel should unblock us
484
+ loop {
485
+ crossbeam_channel::select! {
486
+ send(value_tx, StreamItem::Value(SendableValue::Integer(2))) -> res => {
487
+ if res.is_err() { break; }
488
+ }
489
+ recv(cancel_rx) -> _ => {
490
+ break;
491
+ }
492
+ }
493
+ }
494
+ producer_done_clone.store(true, Ordering::SeqCst);
495
+ });
496
+
497
+ let stream = AsyncStream {
498
+ receiver: Some(value_rx),
499
+ cancel_sender: cancel_tx,
500
+ handle: Some(handle),
501
+ };
502
+
503
+ let start = Instant::now();
504
+ drop(stream);
505
+ let elapsed = start.elapsed();
506
+
507
+ assert!(
508
+ producer_done.load(Ordering::SeqCst),
509
+ "producer should have finished after drop"
510
+ );
511
+ assert!(
512
+ elapsed < Duration::from_secs(2),
513
+ "drop should not hang — took {:?}",
514
+ elapsed
515
+ );
516
+ }
517
+
518
+ // ========== Drop: mid-iteration ==========
519
+
520
+ #[test]
521
+ #[serial]
522
+ fn test_drop_mid_iteration_does_not_hang() {
523
+ with_ruby_python(|_ruby, _api| {
524
+ let (tx, rx) = unbounded();
525
+ let (cancel_tx, _cancel_rx) = bounded(1);
526
+
527
+ thread::spawn(move || {
528
+ for i in 0..100 {
529
+ if tx.send(Some(SendableValue::Integer(i))).is_err() {
530
+ return;
531
+ }
532
+ }
533
+ tx.send(None).ok();
534
+ });
535
+
536
+ let mut stream = AsyncStream::from_channel(rx, cancel_tx);
537
+
538
+ // Read only a few items, then drop
539
+ let _ = stream.next();
540
+ let _ = stream.next();
541
+
542
+ let start = Instant::now();
543
+ drop(stream);
544
+ let elapsed = start.elapsed();
545
+
546
+ assert!(
547
+ elapsed < Duration::from_secs(2),
548
+ "dropping mid-iteration should not hang — took {:?}",
549
+ elapsed
550
+ );
551
+ });
552
+ }
553
+
554
+ #[test]
555
+ #[serial]
556
+ fn test_drop_without_reading_any_items() {
557
+ let Some(_guard) = skip_if_no_python() else {
558
+ return;
559
+ };
560
+
561
+ let (tx, rx) = unbounded();
562
+ let (cancel_tx, _cancel_rx) = bounded(1);
563
+
564
+ let producer_done = Arc::new(AtomicBool::new(false));
565
+ let producer_done_clone = producer_done.clone();
566
+
567
+ thread::spawn(move || {
568
+ for i in 0..10 {
569
+ if tx.send(Some(SendableValue::Integer(i))).is_err() {
570
+ break;
571
+ }
572
+ }
573
+ tx.send(None).ok();
574
+ producer_done_clone.store(true, Ordering::SeqCst);
575
+ });
576
+
577
+ let stream = AsyncStream::from_channel(rx, cancel_tx);
578
+
579
+ let start = Instant::now();
580
+ drop(stream); // Never called .next()
581
+ let elapsed = start.elapsed();
582
+
583
+ assert!(
584
+ elapsed < Duration::from_secs(2),
585
+ "dropping without reading should not hang — took {:?}",
586
+ elapsed
587
+ );
588
+ }
589
+
590
+ // ========== Drop: handle is None ==========
591
+
592
+ #[test]
593
+ #[serial]
594
+ fn test_drop_with_no_handle() {
595
+ let Some(_guard) = skip_if_no_python() else {
596
+ return;
597
+ };
598
+
599
+ let (_value_tx, value_rx) = bounded::<StreamItem>(16);
600
+ let (cancel_tx, _cancel_rx) = bounded(1);
601
+
602
+ // handle: None — simulates already-joined or no thread
603
+ let stream = AsyncStream {
604
+ receiver: Some(value_rx),
605
+ cancel_sender: cancel_tx,
606
+ handle: None,
607
+ };
608
+
609
+ // Should not panic
610
+ drop(stream);
611
+ }
612
+
613
+ // ========== from_channel: producer sends then drops ==========
614
+
615
+ #[test]
616
+ #[serial]
617
+ fn test_from_channel_producer_drops_without_none() {
618
+ with_ruby_python(|_ruby, _api| {
619
+ let (tx, rx) = unbounded();
620
+ let (cancel_tx, _cancel_rx) = bounded(1);
621
+
622
+ thread::spawn(move || {
623
+ tx.send(Some(SendableValue::Integer(1))).ok();
624
+ tx.send(Some(SendableValue::Integer(2))).ok();
625
+ drop(tx); // Drop without sending None
626
+ });
627
+
628
+ let mut stream = AsyncStream::from_channel(rx, cancel_tx);
629
+
630
+ let v1 = stream.next().unwrap().unwrap();
631
+ assert_eq!(i64::try_convert(v1).unwrap(), 1);
632
+
633
+ let v2 = stream.next().unwrap().unwrap();
634
+ assert_eq!(i64::try_convert(v2).unwrap(), 2);
635
+
636
+ // from_channel sends End when producer drops
637
+ assert!(stream.next().is_none());
638
+ });
639
+ }
640
+
641
+ // ========== Backpressure ==========
642
+
643
+ #[test]
644
+ #[serial]
645
+ fn test_backpressure_with_slow_consumer() {
646
+ with_ruby_python(|_ruby, _api| {
647
+ let (tx, rx) = bounded(2); // very small buffer
648
+ let (cancel_tx, _cancel_rx) = bounded(1);
649
+
650
+ let items_sent = Arc::new(std::sync::atomic::AtomicUsize::new(0));
651
+ let items_sent_clone = items_sent.clone();
652
+
653
+ thread::spawn(move || {
654
+ for i in 0..20 {
655
+ if tx.send(Some(SendableValue::Integer(i))).is_err() {
656
+ break;
657
+ }
658
+ items_sent_clone.fetch_add(1, Ordering::SeqCst);
659
+ }
660
+ tx.send(None).ok();
661
+ });
662
+
663
+ // Give producer time to fill buffer
664
+ thread::sleep(Duration::from_millis(50));
665
+
666
+ // Producer should be blocked after filling the internal buffer
667
+ // (2 items in tx→rx + 16 in internal value channel)
668
+ let sent_before_read = items_sent.load(Ordering::SeqCst);
669
+ assert!(
670
+ sent_before_read <= 20, // bounded by buffer sizes
671
+ "producer should be bounded by channel capacity"
672
+ );
673
+
674
+ // Now consume everything
675
+ let mut stream = AsyncStream::from_channel(rx, cancel_tx);
676
+ let mut count = 0;
677
+ while let Some(Ok(_)) = stream.next() {
678
+ count += 1;
679
+ }
680
+ assert_eq!(count, 20);
681
+ });
682
+ }
683
+
684
+ // ========== Large stream ==========
685
+
686
+ #[test]
687
+ #[serial]
688
+ fn test_large_stream_1000_items() {
689
+ with_ruby_python(|_ruby, _api| {
690
+ let (tx, rx) = unbounded();
691
+ let (cancel_tx, _cancel_rx) = bounded(1);
692
+
693
+ thread::spawn(move || {
694
+ for i in 0..1000 {
695
+ if tx.send(Some(SendableValue::Integer(i))).is_err() {
696
+ return;
697
+ }
698
+ }
699
+ tx.send(None).ok();
700
+ });
701
+
702
+ let mut stream = AsyncStream::from_channel(rx, cancel_tx);
703
+ let mut count = 0i64;
704
+ let mut sum = 0i64;
705
+ while let Some(Ok(val)) = stream.next() {
706
+ sum += i64::try_convert(val).unwrap();
707
+ count += 1;
708
+ }
709
+ assert_eq!(count, 1000);
710
+ assert_eq!(sum, (0..1000i64).sum::<i64>());
711
+ });
712
+ }
713
+ }