A path item whose $ref names its own location followed by an empty pointer segment deadlocks Compile. It never returns and no diagnostic is emitted.
Reproducer
A valid OpenAPI 3.1.0 document — real version, real info, one path item — whose only defect is the trailing / on the last pointer:
openapi: 3.1.0
info: {title: t, version: '1'}
paths:
/a:
$ref: '#/components/pathItems/A'
components:
pathItems:
A:
$ref: '#/components/pathItems/A/'
openapi.New().Compile(ctx, ...) on this source does not return. The header is spelled out because the shape is easy to dismiss as garbage input; it is not, and the same hang occurs with the header stripped down.
Where it blocks
The same non-reentrant RWMutex as the reference-through-itself class already guarded in #229: Reference.resolve holds the write lock across its pointer walk, and the walk read-locks every reference it passes through, so a pointer that re-enters the reference currently resolving blocks forever (speakeasy-api/openapi v1.24.0, openapi/reference.go GetObject at :293).
sync.(*RWMutex).RLock(...)
github.com/speakeasy-api/openapi/openapi.(*Reference[...]).GetObject(...)
github.com/speakeasy-api/openapi/jsonpointer.getNavigableNoderTarget(...)
Why the existing guard misses it
refScan.outsideCycle is meant to refuse exactly this (chainReenters, "reference resolves through itself"), and it catches the same document when the self-reference is spelled without the trailing separator. The trailing empty segment is what escapes it: nodeview.PointerPath returns an incomplete walk for #/components/pathItems/A/ — there is no "" key to descend into — and the escape needs tracing from there rather than assuming the incomplete-walk branch is the whole of it.
Impact
morphic compile hangs with no timeout and no diagnostic on a spec a user can plausibly write, and there is no way to interrupt it short of killing the process. It also makes the test suite unrunnable once the input reaches the fuzz corpus: go test ./... panics at the 10-minute timeout, and because a hung seed is written back into testdata/fuzz/FuzzCycleDetector/, every subsequent run replays it.
Notes
Found by FuzzCycleDetector while running the gate; reproduced against c317d6e on main. The generated seed is not committed — the reproducer above stands on its own.
A path item whose
$refnames its own location followed by an empty pointer segment deadlocksCompile. It never returns and no diagnostic is emitted.Reproducer
A valid OpenAPI 3.1.0 document — real version, real
info, one path item — whose only defect is the trailing/on the last pointer:openapi.New().Compile(ctx, ...)on this source does not return. The header is spelled out because the shape is easy to dismiss as garbage input; it is not, and the same hang occurs with the header stripped down.Where it blocks
The same non-reentrant
RWMutexas the reference-through-itself class already guarded in #229:Reference.resolveholds the write lock across its pointer walk, and the walk read-locks every reference it passes through, so a pointer that re-enters the reference currently resolving blocks forever (speakeasy-api/openapi v1.24.0,openapi/reference.goGetObjectat :293).Why the existing guard misses it
refScan.outsideCycleis meant to refuse exactly this (chainReenters, "reference resolves through itself"), and it catches the same document when the self-reference is spelled without the trailing separator. The trailing empty segment is what escapes it:nodeview.PointerPathreturns an incomplete walk for#/components/pathItems/A/— there is no""key to descend into — and the escape needs tracing from there rather than assuming the incomplete-walk branch is the whole of it.Impact
morphic compilehangs with no timeout and no diagnostic on a spec a user can plausibly write, and there is no way to interrupt it short of killing the process. It also makes the test suite unrunnable once the input reaches the fuzz corpus:go test ./...panics at the 10-minute timeout, and because a hung seed is written back intotestdata/fuzz/FuzzCycleDetector/, every subsequent run replays it.Notes
Found by
FuzzCycleDetectorwhile running the gate; reproduced againstc317d6eonmain. The generated seed is not committed — the reproducer above stands on its own.