RedMirror Reflection is now the default way to use RedMirror — one offline binary that gives your own coding agent the power to find real bugs and prove them, right on your machine. Get started →
Unbounded recursion Fixed · merged Python · orchestration

A cycle in Prefect block references could hang the server

TargetPrefect (PrefectHQ)
ClassUnbounded recursion
Triggera cycle in block_document references
ReportedPrivately, before fix
FixedPR #21829 (merged)

Prefect resolved references between configuration blocks by walking the reference graph recursively with no cycle check. A block that referenced itself, or two blocks that referenced each other, made a read recurse forever, overflowing the stack or hanging the server. PR #21829 fixed it with three layers: reject at write, a database constraint, and a depth bound at read.

Prefect stores reusable configuration as blocks. A block is persisted as a block_document, and one block can reference another, so the references between blocks form a graph. When Prefect reads a block, it resolves those references to assemble the full document.

We found that the resolver walked the reference graph recursively with no check that the graph was acyclic. Arrange two blocks to reference each other, or a block to reference itself, and reading it recurses without a base case, overflowing the stack or hanging the request.

Block A references block B, and block B references block A. Now read either one: the resolver follows A to B, B back to A, A to B, and never stops.

How did a graph walk with no cycle check hang the server?

Assembling the full block document meant following each reference and resolving the block it pointed at, recursively. Nothing tracked which blocks had already been visited, and nothing bounded the depth:

# _construct_full_block_document, before the fix
def resolve(doc):
    for ref in doc.references:
        resolve(ref.document)   # no visited-set, no depth bound
    # a reference cycle recurses until the stack blows

Creating and linking blocks is an ordinary authenticated action through Prefect's API, and a cycle is just references arranged pathologically, in the simplest case a single self-reference. The graph is written by the user; the bad state is reached later, on the read path, when the server tries to expand it.

How was it fixed?

PR #21829 (merged) closes the hole with three independent layers, so no single missed check reopens it:

The result is a consistent HTTP 400 on both the POST and PATCH paths when a cycle is detected, and a guaranteed-terminating read even in the worst case.

Why is this the kind of bug RedMirror finds?

Resolving references is a walk over a graph the user gets to shape, and the invariant that matters is simply that the walk terminates. A recursive resolver with no visited-set assumes the graph is a tree; the moment a user can introduce a back-edge, that assumption is a denial of service. RedMirror looks for a reachable input, here a set of references, that drives a walk into non-termination, and reports the configuration that triggers it. The fix is textbook defense in depth: make the cycle impossible to write, impossible to store, and harmless to read.

Frequently asked questions

What is the Prefect block reference cycle bug?

Prefect stores reusable configuration as blocks, and one block can reference another. The resolver that assembles a block walked those references recursively with no check that the graph was acyclic, so a self-referential or mutually-referential block made a read recurse without a base case, overflowing the stack or hanging the server.

How could someone trigger the hang?

Creating and linking blocks is an ordinary authenticated action through Prefect's API, and a cycle is just references arranged pathologically, in the simplest case a single self-reference. The graph is written by the user, and the bad state is reached later on the read path, when the server tries to expand it.

Is the Prefect block reference cycle fixed?

Yes. PR #21829 is merged. It closes the hole with three independent layers: reject a cycle-closing reference at write with HTTP 400, a database constraint that forbids a block from referencing itself, and a depth limit in the read path that raises a clean error instead of recursing forever.

What kind of bug is this and why does RedMirror find it?

It is an unbounded recursion, a denial of service reached on the read path. Resolving references is a walk over a graph the user gets to shape, and the invariant that matters is that the walk terminates. RedMirror looks for a reachable input, here a set of references, that drives the walk into non-termination and reports the configuration that triggers it.

What language and product does this affect?

The target is Prefect, the Python orchestration tool from PrefectHQ, and the bug is in how its server resolves block_document references when it reads a block.

Find the recursions that assume a tree

Anywhere your code walks a user-shaped graph, reference resolution, nested config, dependency expansion, a cycle can turn a normal read into a hang. RedMirror finds the input that makes the walk run forever and reports it as a concrete path.

Get RedMirror Read the docs