@@ -3766,3 +3766,133 @@ async def resolve_obj(_source, _info) -> dict:
37663766 (b_id ,) = [id_ for id_ , label in label_of_id .items () if label == "b" ]
37673767 fast_entries = [i for i in incremental if i .get ("data" ) == {"fast" : "fast" }]
37683768 assert fast_entries == [{"data" : {"fast" : "fast" }, "id" : b_id }]
3769+
3770+
3771+ def describe_defer_and_stream_on_spec_invalid_queries ():
3772+ """Regression tests for graphql-python/graphql-core#272."""
3773+
3774+ @pytest .mark .timeout (1 )
3775+ @pytest .mark .parametrize ("early_execution" , [False , True ])
3776+ async def terminates_when_streamed_field_conflicts_with_deferred_selection (
3777+ early_execution ,
3778+ ):
3779+ """A spec-invalid ``@defer``/``@stream`` combination must not hang.
3780+
3781+ A streamed list field without a subselection that also merge-conflicts
3782+ with an unstreamed selection of the same field inside a sibling
3783+ ``@defer`` used to deadlock incremental delivery whenever another field
3784+ of that sibling resolved later than the streamed field: the incremental
3785+ graph never converged to a terminal state, so ``subsequent_results``
3786+ never yielded a final payload with ``hasNext: False``. Spec-valid
3787+ queries were not affected, but ``experimental_execute_incrementally``
3788+ may be called without a prior validation pass and must terminate on
3789+ invalid input, too. See issue #272.
3790+ """
3791+
3792+ async def resolve_fast (_source , _info ) -> str :
3793+ # `fast` must resolve (at least) two event loop iterations later
3794+ # than `child` and `items` to trigger the deadlock
3795+ await sleep (0 )
3796+ await sleep (0 )
3797+ return "fast"
3798+
3799+ async def resolve_obj (_source , _info ) -> dict :
3800+ return {}
3801+
3802+ async def resolve_list (_source , _info ) -> list :
3803+ return [{}, {}]
3804+
3805+ obj_type : GraphQLObjectType = GraphQLObjectType (
3806+ "Obj" ,
3807+ lambda : {
3808+ "fast" : GraphQLField (GraphQLString , resolve = resolve_fast ),
3809+ "child" : GraphQLField (obj_type , resolve = resolve_obj ),
3810+ "items" : GraphQLField (GraphQLList (obj_type ), resolve = resolve_list ),
3811+ },
3812+ )
3813+ local_schema = GraphQLSchema (
3814+ GraphQLObjectType (
3815+ "Query" , {"obj" : GraphQLField (obj_type , resolve = resolve_obj )}
3816+ )
3817+ )
3818+ # The query is spec-invalid in two ways: the streamed `items` field
3819+ # lacks a subselection, and it merge-conflicts with the unstreamed
3820+ # `items` selection inside the sibling `@defer`. Neither invalid
3821+ # feature alone triggered the deadlock, and fixing either (aliasing
3822+ # the conflict away or adding a subselection) made it disappear.
3823+ document = parse (
3824+ """
3825+ query {
3826+ obj {
3827+ ... {
3828+ child {
3829+ child {
3830+ ... @defer(label: "L4") { items @stream(initialCount: 1) }
3831+ }
3832+ ... @defer { child { fast items } }
3833+ }
3834+ }
3835+ }
3836+ }
3837+ """
3838+ )
3839+
3840+ result = experimental_execute_incrementally (
3841+ local_schema , document , {}, enable_early_execution = early_execution
3842+ )
3843+ assert is_awaitable (result )
3844+ result = await result
3845+ assert isinstance (result , ExperimentalIncrementalExecutionResults )
3846+ initial = result .initial_result .formatted
3847+ # Draining the stream must terminate (guarded by the timeout above).
3848+ subsequent = [patch .formatted async for patch in result .subsequent_results ]
3849+ payloads : list [Any ] = [initial , * subsequent ]
3850+
3851+ # The stream must terminate cleanly: `hasNext` is true on every
3852+ # payload except the last, which ends the stream.
3853+ assert initial ["hasNext" ] is True
3854+ assert subsequent [- 1 ]["hasNext" ] is False
3855+ assert all (patch ["hasNext" ] is True for patch in subsequent [:- 1 ])
3856+
3857+ assert initial ["data" ] == {"obj" : {"child" : {"child" : {}}}}
3858+
3859+ # Aggregate announcements, deliveries and completions across the
3860+ # whole stream; their grouping into payloads is not asserted as it
3861+ # depends on resolver scheduling and `enable_early_execution`.
3862+ pending = [p for payload in payloads for p in payload .get ("pending" , [])]
3863+ incremental = [
3864+ i for payload in payloads for i in payload .get ("incremental" , [])
3865+ ]
3866+ completed = [c for payload in payloads for c in payload .get ("completed" , [])]
3867+
3868+ # Three delivery groups are announced: the anonymous `@defer`, the
3869+ # labeled nested `@defer`, and the stream over `items`.
3870+ id_of_path = {tuple (p ["path" ]): p ["id" ] for p in pending }
3871+ label_of_path = {tuple (p ["path" ]): p .get ("label" ) for p in pending }
3872+ assert label_of_path == {
3873+ ("obj" , "child" ): None ,
3874+ ("obj" , "child" , "child" ): "L4" ,
3875+ ("obj" , "child" , "child" , "items" ): None ,
3876+ }
3877+
3878+ # Every announced group completes exactly once and without errors.
3879+ assert sorted (c ["id" ] for c in completed ) == sorted (id_of_path .values ())
3880+ assert all ("errors" not in c for c in completed )
3881+
3882+ # All three groups deliver: the initial streamed item under `L4` and
3883+ # the second item via the stream (both as empty objects, since the
3884+ # invalid selection has no subfields), and `fast` exactly once under
3885+ # the anonymous `@defer` (the conflicting unstreamed `items` selection
3886+ # merges into the streamed one instead of being delivered again).
3887+ expected_deliveries = [
3888+ {"data" : {"items" : [{}]}, "id" : id_of_path ["obj" , "child" , "child" ]},
3889+ {"items" : [{}], "id" : id_of_path ["obj" , "child" , "child" , "items" ]},
3890+ {
3891+ "data" : {"fast" : "fast" },
3892+ "id" : id_of_path ["obj" , "child" ],
3893+ "subPath" : ["child" ],
3894+ },
3895+ ]
3896+ assert len (incremental ) == len (expected_deliveries )
3897+ for entry in expected_deliveries :
3898+ assert entry in incremental
0 commit comments