@@ -458,6 +458,56 @@ def stream_chunks(x):
458458
459459 asyncio .run (_test ())
460460
461+ def test_collector_empty_stream (self ):
462+ """Test that Collector emits an empty list for a stream with zero chunks."""
463+
464+ def empty_stream (x ):
465+ return
466+ yield # Makes it a generator
467+
468+ controller = build_flow (
469+ [
470+ SyncEmitSource (),
471+ Map (empty_stream ),
472+ Collector (),
473+ Reduce ([], lambda acc , x : acc + [x ]),
474+ ]
475+ ).run ()
476+
477+ controller .emit ("test" )
478+ controller .terminate ()
479+ result = controller .await_termination ()
480+
481+ # Empty stream should emit an empty list
482+ assert len (result ) == 1
483+ assert result [0 ] == []
484+
485+ def test_async_collector_empty_stream (self ):
486+ """Async version: Test that Collector emits an empty list for a stream with zero chunks."""
487+
488+ async def _test ():
489+ def empty_stream (x ):
490+ return
491+ yield # Makes it a generator
492+
493+ controller = build_flow (
494+ [
495+ AsyncEmitSource (),
496+ Map (empty_stream ),
497+ Collector (),
498+ Reduce ([], lambda acc , x : acc + [x ]),
499+ ]
500+ ).run ()
501+
502+ await controller .emit ("test" )
503+ await controller .terminate ()
504+ result = await controller .await_termination ()
505+
506+ assert len (result ) == 1
507+ assert result [0 ] == []
508+
509+ asyncio .run (_test ())
510+
461511
462512class TestCompleteStreaming :
463513 """Tests for Complete step streaming support."""
@@ -1190,13 +1240,16 @@ def select_outlets(self, event):
11901240 controller .terminate ()
11911241 result = controller .await_termination ()
11921242
1193- # Should have 2 collected results (one per emit)
1194- assert len (result ) == 2
1195- # low_value chunks go to branch_low
1196- low_result = [r for r in result if any ("LOW_" in str (item ) for item in r )]
1197- high_result = [r for r in result if any ("HIGH_" in str (item ) for item in r )]
1243+ # 4 results: chunks go to one branch, but StreamCompletion goes to all branches
1244+ # (like _termination_obj) to avoid hangs in cyclic graphs.
1245+ # Branches that don't receive chunks emit empty lists.
1246+ assert len (result ) == 4
1247+ low_result = [r for r in result if r and any ("LOW_" in str (item ) for item in r )]
1248+ high_result = [r for r in result if r and any ("HIGH_" in str (item ) for item in r )]
1249+ empty_results = [r for r in result if r == []]
11981250 assert len (low_result ) == 1
11991251 assert len (high_result ) == 1
1252+ assert len (empty_results ) == 2
12001253 assert "LOW_low_value_chunk_0" in low_result [0 ]
12011254 assert "HIGH_high_value_chunk_0" in high_result [0 ]
12021255
@@ -1235,11 +1288,14 @@ def select_outlets(self, event):
12351288 await controller .terminate ()
12361289 result = await controller .await_termination ()
12371290
1238- assert len (result ) == 2
1239- low_result = [r for r in result if any ("LOW_" in str (item ) for item in r )]
1240- high_result = [r for r in result if any ("HIGH_" in str (item ) for item in r )]
1291+ # 4 results: chunks go to one branch, but StreamCompletion goes to all branches
1292+ assert len (result ) == 4
1293+ low_result = [r for r in result if r and any ("LOW_" in str (item ) for item in r )]
1294+ high_result = [r for r in result if r and any ("HIGH_" in str (item ) for item in r )]
1295+ empty_results = [r for r in result if r == []]
12411296 assert len (low_result ) == 1
12421297 assert len (high_result ) == 1
1298+ assert len (empty_results ) == 2
12431299 assert "LOW_low_value_chunk_0" in low_result [0 ]
12441300 assert "HIGH_high_value_chunk_0" in high_result [0 ]
12451301
@@ -1403,3 +1459,86 @@ def failing_transform(x):
14031459 await controller .await_termination ()
14041460
14051461 asyncio .run (_test ())
1462+
1463+ def test_streaming_in_cycle_fails (self ):
1464+ """Test that streaming step inside a cycle fails on second iteration.
1465+
1466+ When a streaming step is inside a cycle, the first iteration streams chunks.
1467+ When those chunks loop back to the streaming step, they already have
1468+ streaming_step set, so the step should fail with StreamingError.
1469+ """
1470+
1471+ class AlwaysLoop (Map ):
1472+ """A Map step that always routes back to the loop target."""
1473+
1474+ def __init__ (self , loop_target , ** kwargs ):
1475+ super ().__init__ (** kwargs )
1476+ self ._loop_target = loop_target
1477+
1478+ def select_outlets (self , event_body ):
1479+ # Always loop back - the streaming error should stop us
1480+ return [self ._loop_target ]
1481+
1482+ def stream_chunks (x ):
1483+ yield f"{ x } _chunk_0"
1484+ yield f"{ x } _chunk_1"
1485+
1486+ source = SyncEmitSource ()
1487+ # The streaming map is the entry point of the loop
1488+ streaming_map = Map (stream_chunks , name = "streamer" , max_iterations = 5 )
1489+ loop_controller = AlwaysLoop (
1490+ fn = lambda x : x , name = "loop_ctrl" , loop_target = "streamer" , max_iterations = 5
1491+ )
1492+ end = Reduce ([], lambda acc , x : acc + [x ], name = "end" )
1493+
1494+ source .to (streaming_map )
1495+ streaming_map .to (loop_controller )
1496+ loop_controller .to (end )
1497+ loop_controller .to (streaming_map ) # Create cycle
1498+
1499+ controller = source .run ()
1500+
1501+ controller .emit ("test" )
1502+ controller .terminate ()
1503+
1504+ # Should fail because chunks looping back already have streaming_step set
1505+ with pytest .raises (StreamingError , match = "Streaming on top of streaming is not allowed" ):
1506+ controller .await_termination ()
1507+
1508+ def test_async_streaming_in_cycle_fails (self ):
1509+ """Async version: Test that streaming step inside a cycle fails on second iteration."""
1510+
1511+ async def _test ():
1512+ class AlwaysLoop (Map ):
1513+ def __init__ (self , loop_target , ** kwargs ):
1514+ super ().__init__ (** kwargs )
1515+ self ._loop_target = loop_target
1516+
1517+ def select_outlets (self , event_body ):
1518+ return [self ._loop_target ]
1519+
1520+ def stream_chunks (x ):
1521+ yield f"{ x } _chunk_0"
1522+ yield f"{ x } _chunk_1"
1523+
1524+ source = AsyncEmitSource ()
1525+ streaming_map = Map (stream_chunks , name = "streamer" , max_iterations = 5 )
1526+ loop_controller = AlwaysLoop (
1527+ fn = lambda x : x , name = "loop_ctrl" , loop_target = "streamer" , max_iterations = 5
1528+ )
1529+ end = Reduce ([], lambda acc , x : acc + [x ], name = "end" )
1530+
1531+ source .to (streaming_map )
1532+ streaming_map .to (loop_controller )
1533+ loop_controller .to (end )
1534+ loop_controller .to (streaming_map ) # Create cycle
1535+
1536+ controller = source .run ()
1537+
1538+ await controller .emit ("test" )
1539+ await controller .terminate ()
1540+
1541+ with pytest .raises (StreamingError , match = "Streaming on top of streaming is not allowed" ):
1542+ await controller .await_termination ()
1543+
1544+ asyncio .run (_test ())
0 commit comments