@@ -833,3 +833,192 @@ async def mock_receive():
833833 assert isinstance (messages [- 1 ], ResultMessage )
834834
835835 anyio .run (_test )
836+
837+
838+ class TestAsyncGeneratorCleanup :
839+ """Tests for async generator cleanup behavior (issue #454).
840+
841+ These tests verify that the RuntimeError "Attempted to exit cancel scope
842+ in a different task" does not occur during async generator cleanup.
843+
844+ The key behavior we're testing is that cleanup doesn't raise RuntimeError,
845+ not that specific mock methods are called (which depends on mock setup).
846+ """
847+
848+ def test_streaming_client_early_disconnect (self ):
849+ """Test ClaudeSDKClient early disconnect doesn't raise RuntimeError.
850+
851+ This is the primary test case from issue #454 - breaking out of an
852+ async for loop should not cause RuntimeError during cleanup.
853+ """
854+
855+ async def _test ():
856+ with patch (
857+ "clawd_code_sdk._internal.transport.subprocess_cli.SubprocessCLITransport"
858+ ) as mock_transport_class :
859+ mock_transport = create_mock_transport ()
860+ mock_transport_class .return_value = mock_transport
861+
862+ async def mock_receive ():
863+ # Send init response
864+ await asyncio .sleep (0.01 )
865+ written = mock_transport .write .call_args_list
866+ for call in written :
867+ if call :
868+ data = call [0 ][0 ]
869+ try :
870+ msg = json .loads (data .strip ())
871+ if (
872+ msg .get ("type" ) == "control_request"
873+ and msg .get ("request" , {}).get ("subtype" )
874+ == "initialize"
875+ ):
876+ yield {
877+ "type" : "control_response" ,
878+ "response" : {
879+ "request_id" : msg .get ("request_id" ),
880+ "subtype" : "success" ,
881+ "commands" : [],
882+ },
883+ }
884+ break
885+ except (json .JSONDecodeError , KeyError , AttributeError ):
886+ pass
887+
888+ # Yield some messages
889+ for i in range (5 ):
890+ yield {
891+ "type" : "assistant" ,
892+ "message" : {
893+ "role" : "assistant" ,
894+ "content" : [{"type" : "text" , "text" : f"Message { i } " }],
895+ "model" : "claude-opus-4-1-20250805" ,
896+ },
897+ }
898+
899+ mock_transport .read_messages = mock_receive
900+
901+ # Connect, get one message, then disconnect early
902+ client = ClaudeSDKClient ()
903+ await client .connect ()
904+
905+ count = 0
906+ async for msg in client .receive_messages ():
907+ count += 1
908+ if count >= 2 :
909+ break # Early exit - this should NOT raise RuntimeError
910+
911+ # Early disconnect should not raise RuntimeError
912+ # The key assertion is that we reach this point without exception
913+ await client .disconnect ()
914+
915+ assert count == 2
916+ # Transport close is called by disconnect
917+ mock_transport .close .assert_called ()
918+
919+ anyio .run (_test )
920+
921+ def test_query_cancel_scope_can_be_cancelled (self ):
922+ """Test that Query's cancel scope can be safely cancelled from any context.
923+
924+ This verifies the fix for issue #454 where the cancel scope mechanism
925+ allows cleanup without RuntimeError.
926+ """
927+
928+ async def _test ():
929+ from clawd_code_sdk ._internal .query import Query
930+ from clawd_code_sdk ._internal .transport import Transport
931+
932+ # Create a mock transport
933+ mock_transport = AsyncMock (spec = Transport )
934+ mock_transport .connect = AsyncMock ()
935+ mock_transport .close = AsyncMock ()
936+ mock_transport .write = AsyncMock ()
937+
938+ messages_to_yield = [
939+ {"type" : "system" , "subtype" : "init" },
940+ {
941+ "type" : "assistant" ,
942+ "message" : {
943+ "content" : [{"type" : "text" , "text" : "Hello" }],
944+ "model" : "test" ,
945+ },
946+ },
947+ ]
948+ message_index = 0
949+
950+ async def mock_read ():
951+ nonlocal message_index
952+ while message_index < len (messages_to_yield ):
953+ yield messages_to_yield [message_index ]
954+ message_index += 1
955+ await asyncio .sleep (0.01 )
956+
957+ mock_transport .read_messages = mock_read
958+
959+ # Create Query
960+ q = Query (
961+ transport = mock_transport ,
962+ is_streaming_mode = False ,
963+ )
964+
965+ # Start the query
966+ await q .start ()
967+
968+ # Give reader time to start
969+ await asyncio .sleep (0.05 )
970+
971+ # Cancel scope should exist
972+ assert q ._reader_cancel_scope is not None
973+
974+ # Close should work without RuntimeError
975+ # This is the key test - close() used to raise RuntimeError
976+ await q .close ()
977+
978+ # Verify closed state
979+ assert q ._closed is True
980+ mock_transport .close .assert_called ()
981+
982+ anyio .run (_test )
983+
984+ def test_query_as_async_context_manager (self ):
985+ """Test using Query as an async context manager for proper cleanup."""
986+
987+ async def _test ():
988+ from clawd_code_sdk ._internal .query import Query
989+ from clawd_code_sdk ._internal .transport import Transport
990+
991+ mock_transport = AsyncMock (spec = Transport )
992+ mock_transport .connect = AsyncMock ()
993+ mock_transport .close = AsyncMock ()
994+ mock_transport .write = AsyncMock ()
995+
996+ async def mock_read ():
997+ yield {"type" : "system" , "subtype" : "init" }
998+ yield {
999+ "type" : "assistant" ,
1000+ "message" : {
1001+ "content" : [{"type" : "text" , "text" : "Hello" }],
1002+ "model" : "test" ,
1003+ },
1004+ }
1005+
1006+ mock_transport .read_messages = mock_read
1007+
1008+ # Use Query as async context manager
1009+ q = Query (
1010+ transport = mock_transport ,
1011+ is_streaming_mode = False ,
1012+ )
1013+
1014+ async with q :
1015+ # Query should be started
1016+ assert q ._tg is not None
1017+ # Get one message
1018+ msg = await q .__anext__ ()
1019+ assert msg ["type" ] == "system"
1020+
1021+ # After context exit, should be closed
1022+ assert q ._closed is True
1023+
1024+ anyio .run (_test )
0 commit comments