Skip to content

Incremental delivery: experimental_execute_incrementally hangs (never terminates) on some invalid @stream queries #272

Description

@ben-xo

Hi there,

Whilst investigating a production bug (see #271), the fuzzing harness found a second bug. Not as critical as 271 because it only triggers on an invalid graphql shape, but it is a reproducible hang that can be reached in production. Found with the help of Claude code.

Hope this helps - unfortunately I don't have a suggested fix for this one as the root cause was less obvious.

  • Ben (Mixcloud)

Summary

experimental_execute_incrementally never terminates for certain queries combining @stream with nested @defer/child selections — the async iterator over subsequent_results blocks forever, with no final hasNext: false. Reproduced reliably on graphql-core 3.3.0rc0 (≈13/15 attempts hang at a 3 s timeout in isolation, ≈9/10 at 5 s).

Scope, stated up front: every deadlocking query I found is spec-invalid — it fails graphql.validate (a streamed list field with no subselection, and/or a field-merge conflict between a streamed and a non-streamed selection of the same field). A caller that validates before executing rejects these first. Despite extensive search I could not find a spec-valid query that hangs — making the query valid removes the hang. So this is a robustness issue rather than a correctness issue on valid input: execution should fail fast / terminate rather than hang, since experimental_execute_incrementally can be (and is, for performance) called without a separate validation pass, and a hang on any input is a DoS risk.

Environment

  • graphql-core 3.3.0rc0
  • Python 3.14

Query (spec-invalid; hangs instead of erroring)

query {
  obj {
    ... {
      child {
        child { ... @defer(label: "L4") { items @stream(initialCount: 1) } }
        ... @defer { child { fast items } }
      }
    }
  }
}

validate() reports: "Fields 'child' conflict because subfields 'items' conflict because they have differing stream directives" and "Field 'items' … must have a selection of subfields". experimental_execute_incrementally neither raises nor completes.

What is / isn't required (isolation testing)

Query Valid? Hangs?
The query above invalid yes (~13/15)
items @stream(initialCount: 1) with no subselection, alone invalid no
Streamed + non-streamed items merge conflict, alone invalid no
The same structure made valid (aliased, subselections added) valid no
~15 other hand-crafted valid @stream/@defer/nesting shapes valid no

So neither invalid feature alone hangs; the deadlock needs the invalid feature embedded in the nested @defer + @stream + child structure — and making that structure valid stops it.

Notes

  • Timing-sensitive (needs out-of-order async resolver completion), so the repro loops; not 100%/run but high (≈85–90%).
  • Likely the same incremental-publisher/graph subsystem as the _enqueue "race with a stopping consumer" guard and the separate _add_deferred_fragment_node invariant crash (filed as a separate issue): the graph doesn't converge to a terminal state for these inputs.

Self-contained reproduction (graphql-core only)

import asyncio
import random

from graphql import (
    GraphQLDeferDirective,
    GraphQLField,
    GraphQLList,
    GraphQLObjectType,
    GraphQLSchema,
    GraphQLStreamDirective,
    GraphQLString,
    parse,
    specified_directives,
    validate,
)
from graphql.execution import (
    ExperimentalIncrementalExecutionResults,
    experimental_execute_incrementally,
)
from graphql.pyutils import is_awaitable

ATTEMPTS = 15
TIMEOUT_S = 3.0


async def r_fast(_s, _i) -> str:
    await asyncio.sleep(random.uniform(0, 0.002))
    return "fast"


async def r_obj(_s, _i) -> dict:
    await asyncio.sleep(random.uniform(0, 0.001))
    return {}


async def r_list(_s, _i) -> list:
    await asyncio.sleep(random.uniform(0, 0.001))
    return [{}, {}]


OBJ = GraphQLObjectType(
    "Obj",
    lambda: {
        "fast": GraphQLField(GraphQLString, resolve=r_fast),
        "child": GraphQLField(OBJ, resolve=r_obj),
        "items": GraphQLField(GraphQLList(OBJ), resolve=r_list),
    },
)
schema = GraphQLSchema(
    GraphQLObjectType("Query", {"obj": GraphQLField(OBJ, resolve=r_obj)}),
    directives=[*specified_directives, GraphQLDeferDirective, GraphQLStreamDirective],
)

QUERY = """
query {
  obj {
    ... {
      child {
        child { ... @defer(label: "L4") { items @stream(initialCount: 1) } }
        ... @defer { child { fast items } }
      }
    }
  }
}
"""


async def run_once() -> None:
    result = experimental_execute_incrementally(schema, parse(QUERY), root_value={})
    if is_awaitable(result):
        result = await result
    if isinstance(result, ExperimentalIncrementalExecutionResults):
        async for _patch in result.subsequent_results:
            pass


async def main() -> None:
    import graphql

    print("graphql-core:", graphql.__version__)
    errs = validate(schema, parse(QUERY))
    print("query is spec-" + ("INVALID" if errs else "VALID"))
    hangs = 0
    for i in range(1, ATTEMPTS + 1):
        try:
            await asyncio.wait_for(run_once(), TIMEOUT_S)
        except TimeoutError:
            hangs += 1
            print(f"attempt {i}: HUNG (no termination within {TIMEOUT_S}s)")
    print(f"{hangs}/{ATTEMPTS} attempts hung (never terminated).")


asyncio.run(main())

This non-termination, and the finding that valid queries do not reproduce it, were established by fuzzing @defer/@stream query shapes.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions