Summary
ResolveAllReferences never returns on an 83-byte OpenAPI document. It is a single-goroutine self-deadlock on a non-reentrant sync.RWMutex — no concurrency on the caller's side is involved.
Affects v1.24.0 (current release) and main.
Reproduction
package main
import (
"context"
"fmt"
"strings"
"github.com/speakeasy-api/openapi/openapi"
)
func main() {
const spec = `openapi: 3.1.0
info: {title: t, version: "1"}
paths:
/a: {$ref: '#/paths/~1a/t'}
`
ctx := context.Background()
doc, _, err := openapi.Unmarshal(ctx, strings.NewReader(spec))
if err != nil {
panic(err)
}
_, _ = doc.ResolveAllReferences(ctx, openapi.ResolveAllOptions{
OpenAPILocation: "test.yaml",
DisableExternalRefs: true,
})
fmt.Println("unreachable")
}
Run standalone it aborts:
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [sync.RWMutex.RLock]:
github.com/speakeasy-api/openapi/openapi.(*Reference[...]).GetObject(...)
openapi/reference.go:294
github.com/speakeasy-api/openapi/openapi.(*Reference[...]).GetNavigableNode(...)
openapi/reference.go:487
github.com/speakeasy-api/openapi/jsonpointer.getNavigableNoderTarget(...)
jsonpointer/jsonpointer.go:409
...
github.com/speakeasy-api/openapi/openapi.(*Reference[...]).resolve(...)
openapi/reference.go:563
Inside a process with other live goroutines the runtime's deadlock detector never fires, so it simply hangs forever.
Cause
Reference.resolve takes the reference's own cacheMutex write lock and holds it across references.Resolve:
// openapi/reference.go:537
r.cacheMutex.Lock()
defer r.cacheMutex.Unlock()
...
// openapi/reference.go:557
result, validationErrs, err := references.Resolve(ctx, *r.Reference, unmarshaler[T, V, C](rootDoc), resolveOpts)
references.Resolve navigates the document, and navigating into a reference calls GetObject, which takes a read lock:
// openapi/reference.go:293
r.cacheMutex.RLock()
defer r.cacheMutex.RUnlock()
sync.RWMutex is not reentrant, so this waits on itself.
resolveObjectWithTracking cannot catch it: referenceChain is only extended after ref.resolve returns, and the deadlock happens inside a hop that never returns.
Other affected shapes
Through the cache delegation at reference.go:299 — GetObject forwards to referenceResolutionCache.Object.GetObject(), so an already-resolved reference can forward into the one currently resolving:
paths:
/a: {$ref: '#/paths/~1b'}
/b: {$ref: '#/paths/~1a/t'}
Components and webhooks spellings deadlock identically — #/components/pathItems/A/t, #/webhooks/onA/t.
Stack overflow instead of deadlock, when a reference resolves to itself. GetJSONPointer trims the pointer, so '#/paths/~1a ' names /a, the cache ends up pointing at its own reference, and the delegation at :299 recurses until the stack is exhausted:
paths:
/a:
$ref: '#/paths/~1a '
get: {operationId: a, responses: {"200": {description: ok}}}
Impact
Any consumer resolving an untrusted or machine-generated OpenAPI document can be hung permanently by a few dozen bytes. A goroutine blocked on a mutex cannot be cancelled or timed out, so a caller cannot defend against it — context cancellation does not reach a blocking RLock, and the goroutine and the document it retains leak for the process's lifetime.
Validating first does not protect a caller. Every document above returns zero validation errors, from both Unmarshal and doc.Validate() — $ref is a legal Path Item Object field in 3.1, and a pointer that fails to resolve is a resolution failure rather than a schema violation. A consumer that validates before resolving sees a clean document and then hangs.
These documents are semantically broken — the pointer names nothing, or it resolves to an Operation Object where a Path Item is expected. But that is not what distinguishes them, because a plainly dangling reference is broken in exactly the same way and behaves correctly:
openapi: 3.1.0
info: {title: t, version: "1"}
paths:
/a: {$ref: '#/paths/~1nope'}
Equally malformed, equally validation-clean, and it returns an unresolved-reference error in about a millisecond. The difference between that and the documents above is not their validity — it is only whether the pointer's walk passes through a reference whose resolve is in flight.
Fix
#230 releases the write lock before resolving and re-acquires it to publish. With it, all of the shapes above report the errors they should have: an unresolved reference, or circular reference detected from the existing tracker.
Found while fuzzing a downstream consumer, where it surfaced only as workers dying with EOF — go test -fuzz wires worker stderr to /dev/null, so the panic is discarded.
Summary
ResolveAllReferencesnever returns on an 83-byte OpenAPI document. It is a single-goroutine self-deadlock on a non-reentrantsync.RWMutex— no concurrency on the caller's side is involved.Affects v1.24.0 (current release) and
main.Reproduction
Run standalone it aborts:
Inside a process with other live goroutines the runtime's deadlock detector never fires, so it simply hangs forever.
Cause
Reference.resolvetakes the reference's owncacheMutexwrite lock and holds it acrossreferences.Resolve:references.Resolvenavigates the document, and navigating into a reference callsGetObject, which takes a read lock:sync.RWMutexis not reentrant, so this waits on itself.resolveObjectWithTrackingcannot catch it:referenceChainis only extended afterref.resolvereturns, and the deadlock happens inside a hop that never returns.Other affected shapes
Through the cache delegation at
reference.go:299—GetObjectforwards toreferenceResolutionCache.Object.GetObject(), so an already-resolved reference can forward into the one currently resolving:Components and webhooks spellings deadlock identically —
#/components/pathItems/A/t,#/webhooks/onA/t.Stack overflow instead of deadlock, when a reference resolves to itself.
GetJSONPointertrims the pointer, so'#/paths/~1a 'names/a, the cache ends up pointing at its own reference, and the delegation at:299recurses until the stack is exhausted:Impact
Any consumer resolving an untrusted or machine-generated OpenAPI document can be hung permanently by a few dozen bytes. A goroutine blocked on a mutex cannot be cancelled or timed out, so a caller cannot defend against it —
contextcancellation does not reach a blockingRLock, and the goroutine and the document it retains leak for the process's lifetime.Validating first does not protect a caller. Every document above returns zero validation errors, from both
Unmarshalanddoc.Validate()—$refis a legal Path Item Object field in 3.1, and a pointer that fails to resolve is a resolution failure rather than a schema violation. A consumer that validates before resolving sees a clean document and then hangs.These documents are semantically broken — the pointer names nothing, or it resolves to an Operation Object where a Path Item is expected. But that is not what distinguishes them, because a plainly dangling reference is broken in exactly the same way and behaves correctly:
Equally malformed, equally validation-clean, and it returns an unresolved-reference error in about a millisecond. The difference between that and the documents above is not their validity — it is only whether the pointer's walk passes through a reference whose
resolveis in flight.Fix
#230 releases the write lock before resolving and re-acquires it to publish. With it, all of the shapes above report the errors they should have: an unresolved reference, or
circular reference detectedfrom the existing tracker.Found while fuzzing a downstream consumer, where it surfaced only as workers dying with
EOF—go test -fuzzwires worker stderr to/dev/null, so the panic is discarded.