fix: release the reference lock before resolving its pointer - #230
fix: release the reference lock before resolving its pointer#230OmarAlJarrah wants to merge 5 commits into
Conversation
Reference.resolve held the reference's own cacheMutex write lock across the
call to references.Resolve. That call navigates the document, and navigating
into a reference calls GetObject, which takes a read lock on that reference.
sync.RWMutex is not reentrant, so a $ref whose JSON pointer passes through the
reference being resolved blocked forever on a lock its own goroutine held.
A pointer reaches the in-flight reference either directly, when its own prefix
names it:
paths:
/a: {$ref: '#/paths/~1a/t'}
or through GetObject's cache delegation, when an already-resolved reference
forwards to the one currently resolving:
paths:
/a: {$ref: '#/paths/~1b'}
/b: {$ref: '#/paths/~1a/t'}
Both hang the process. Neither is caught by resolveObjectWithTracking, whose
reference chain is only extended once a hop completes, so a hop that re-enters
itself is never compared against it.
The same lock scope caused a second failure. When a reference resolved to
itself -- reachable because GetJSONPointer trims the pointer, so
'#/paths/~1a ' names /a -- the resolution cache was left pointing at its own
reference, and GetObject's delegation to
referenceResolutionCache.Object.GetObject() recursed until the goroutine stack
was exhausted.
Take the write lock only for the double-check, release it while resolving, and
re-acquire to publish the result, re-checking the cache in case another
goroutine published first. Concurrent resolution of the same reference may now
duplicate work, which is wasted effort rather than a correctness problem: the
published result is whichever completes first, and every caller returns it.
With the lock released, both shapes resolve to the errors they should always
have produced -- an unresolved reference, or "circular reference detected" from
the existing tracker.
TristanSpeakEasy
left a comment
There was a problem hiding this comment.
Reviewed the lock-scope change and its post-resolution cache state.
Validation:
go test -count=1 -run TestResolveAllReferences_PointerTraversingItsOwnReference ./openapi: passed.go test -race -count=1 ./openapi ./references ./jsonpointer: passed.- A focused probe confirmed that the trimmed-pointer case returns a circular-reference error but leaves
referenceResolutionCache.Objectequal to the original reference.
Requesting changes for the resulting fatal GetObject recursion called out inline.
| return r.referenceResolutionCache.Object.Object, nil, r.validationErrsCache, nil | ||
| } | ||
|
|
||
| r.referenceResolutionCache = result |
There was a problem hiding this comment.
This still publishes a self-reference for the trimmed-pointer case. I verified after ResolveAllReferences returns circular reference detected that ref.referenceResolutionCache.Object == ref. The tracker sets circularErrorFound later, but it does not clear this cache entry, so any subsequent ref.GetObject() reaches line 299 and delegates straight back to ref.GetObject() until the process exhausts its goroutine stack. The new test only checks the resolution error and therefore misses the corrupt post-resolution state. Please avoid publishing a result whose object is this reference (or otherwise represent the circular state without a self-delegating cache), and add a regression that safely verifies GetObject() after the circular resolution attempt.
There was a problem hiding this comment.
Confirmed and fixed in d02ba76, though not by suppressing the publish.
A pointer-identity check against the reference being resolved turned out to catch only the one-node case. Two references can close the loop between them without either being a self-reference:
paths:
/a:
$ref: '#/paths/~1b '
get: {operationId: a, responses: {"200": {description: ok}}}
/b:
$ref: '#/paths/~1a '
get: {operationId: b, responses: {"200": {description: ok}}}The tracker reports circular reference detected: test.yaml#/paths/~1b -> test.yaml#/paths/~1a -> test.yaml#/paths/~1b, but only on the hop after both caches are published, so a.cache.Object == b and b.cache.Object == a and neither == r. GetObject then alternates between them until the stack goes.
Not publishing is also awkward here: resolveObjectWithTracking reads ref.referenceResolutionCache.ResolvedDocument right after resolve returns a next reference, so an empty cache turns the overflow into a nil dereference.
So GetObject now walks the chain iteratively with a seen set and returns nil once it repeats, which covers a cycle of any length and leaves the resolution errors and the published cache as they were. Legitimate circular references were already safe because the tracker stops before the last hop's cache is published, and there is now a test pinning that alongside a multi-hop chain that still resolves.
Both cycle shapes are regression tested through GetObject after resolution: with the walk reverted they abort the binary with fatal error: stack overflow. go test ./..., -race on ./openapi/... ./references/... ./jsonpointer/..., and golangci-lint are clean.
Releasing the reference lock keeps resolution from deadlocking, but it does not change what gets published. A $ref whose pointer names a reference already in the chain still leaves that reference holding a resolution cache that points back into the chain, and GetObject followed it by recursing into the next reference's GetObject. Walking a cycle that way exhausts the goroutine stack and aborts the process. Two shapes reach it. A reference can resolve to itself, and two references can resolve to each other -- the tracker only reports the cycle on the hop after both caches are published, so neither one is a self-reference. A pointer-identity check against the reference being resolved would catch the first and miss the second. Walk the chain iteratively instead, tracking what has been seen and returning nil once it repeats. That covers a cycle of any length, and it leaves the resolution errors and the published cache exactly as they were. Also skip the parent links when a reference resolves to itself, so GetParent and GetTopLevelParent do not loop for anyone walking them. The existing cases now assert the specific error they produce rather than just that one occurred, and every case checks GetObject afterwards, which is where the crash actually lived.
TristanSpeakEasy
left a comment
There was a problem hiding this comment.
Re-reviewed the follow-up commit. The previous fatal GetObject recursion is fixed by the iterative cycle-aware chain walk, and the strengthened tests cover both self-cycles and multi-reference cycles.
Validation:
go test -count=1 -run 'TestResolveAllReferences_PointerTraversingItsOwnReference|TestGetObject_ChainWalking' ./openapi: passed.go test -race -count=1 ./openapi ./references ./jsonpointer: passed.go test -count=1 ./...: passed.- Same-reference concurrent-resolution race probe: passed across 10 race-enabled runs.
A focused probe against the new two-reference case confirmed a remaining cyclic parent-link state, called out inline. This review supersedes my earlier request for changes on 3efdc1c.
| // A reference can resolve to itself, in which case parenting it to | ||
| // itself would make GetParent/GetTopLevelParent loop for anyone walking | ||
| // the chain. The recursive call below reports it as circular. | ||
| if nextRef != ref { |
There was a problem hiding this comment.
This guard fixes only the one-node self-cycle. In the new /a -> /b -> /a case, the second hop has ref == b, nextRef == a, and topLevel == a, so the guard passes and publishes a.parent = b, b.parent = a, and a.topLevelParent = a. I reproduced all three identities after ResolveAllReferences returned the expected circular-reference error. GetObject is now safe, but any consumer walking the public parent links can still loop forever, which is the state this comment says it prevents. Please avoid setting parent links when nextRef is already part of the current resolution chain (at minimum when nextRef == topLevel) and extend the two-reference regression to assert that parent/top-level-parent traversal cannot cycle.
There was a problem hiding this comment.
Confirmed, and the guard was too narrow for the reason you give. Fixed in 1fe8ffc.
nextRef == topLevel would have caught this case, but not a chain that rejoins below the head: with /a -> /b -> /c -> /b, the third hop has nextRef == b and topLevel == a, so it would set b.parent = c on top of the existing c.parent = b. Parent links are between reference values, and the absolute references the tracker already collects can't stand in for them, since a pointer can name a reference that is already in the chain and make one value appear under two different absolute references.
So resolveObjectWithTracking now carries the reference values alongside referenceChain, and the links are skipped for any value the chain has already been through. The cycle is still reported by the recursive call, and anything that resolves cleanly keeps its links.
The two-reference case asserts both directions now: it walks GetParent to a terminus and checks GetTopLevelParent does not point at the reference itself. Reverting to the old guard fails it on /a: parent links cycle.
go test ./..., go test -race ./openapi/... ./references/... ./jsonpointer/... and golangci-lint run ./openapi/... are clean.
The previous guard only covered a reference that resolved to itself. With /a and /b resolving to each other, the second hop has ref == b and nextRef == a, so it passed the guard and published a.parent = b alongside b.parent = a. GetObject no longer walks that, but GetParent and GetTopLevelParent are public and a caller walking them still loops. The absolute references the tracker already collects cannot answer this: a pointer can name a reference that is already in the chain, so one reference value can appear under two different absolute references. Track the reference values alongside them and skip the links for any value the chain has already been through. The cycle is still reported by the recursive call, so nothing that resolves cleanly loses its links. The two-reference case now asserts that both the parent chain and the top-level parent terminate.
There was a problem hiding this comment.
All reported issues were addressed across 3 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Fix all with cubic | Re-trigger cubic
The deferred unlock inside the conditional, paired with a bare unlock on the branch below it, was correct but easy to break: every future edit to the block has to reason about which of the two paths releases the lock. Read the cache under the lock, release it, then branch on the copy. Same behaviour, one unlock, and it matches how the read-lock check above it already reads.
TristanSpeakEasy
left a comment
There was a problem hiding this comment.
Re-reviewed the two follow-up commits. The identity-chain guard fixes parent links for a single ResolveAllReferences traversal, including cycles longer than two nodes, and the flattened cache double-check preserves the intended lock behavior.
Validation:
- Focused cycle, chain, and parent-link tests: passed.
go test -race -count=1 ./openapi ./references ./jsonpointer: passed.go test -count=1 ./...: passed.- Three-reference-cycle parent-link probe: passed.
- Same-reference concurrent-resolution probe: passed across 10 race-enabled runs.
A focused public-API re-entry probe confirmed the parent cycle can still be recreated across two separate Reference.Resolve calls, called out inline. This review supersedes my review on d02ba760.
| // own descendant closes a loop in the links GetParent and | ||
| // GetTopLevelParent expose to callers. The recursive call below reports | ||
| // the cycle; leaving the links alone keeps them walkable meanwhile. | ||
| if !slices.Contains(newResolvedChain, nextRef) { |
There was a problem hiding this comment.
This identity chain only covers the current Resolve invocation, so the parent cycle can still be recreated across separate calls to the public API. With /a -> /b -> /a, a.Resolve(ctx, opts) leaves b.parent = a. Calling b.Resolve(ctx, opts) afterwards starts with a fresh resolvedChain; it sees a as new and adds a.parent = b plus a.topLevelParent = a. I reproduced all three identities (a.parent == b, b.parent == a, a.topLevelParent == a) on this head. ResolveAllReferences hides the issue because resolveAny skips references already marked resolved, but Reference.Resolve has no such guard. Please account for existing parent/top-level-parent links when deciding whether this edge is safe (or clear stale links when starting a root resolution), and add a regression that resolves both members separately before walking their parent links.
There was a problem hiding this comment.
Confirmed, and fixed in f58f208. Reproduced all three identities on the previous head before changing anything.
The per-call chain was the wrong source of truth. Ancestry now comes from the links themselves:
if !isAncestor(ref, nextRef) && topLevel != nextRef {
nextRef.SetParent(ref)
nextRef.SetTopLevelParent(topLevel)
}isAncestor walks GetParent from ref and reports whether nextRef is already reachable. That holds across calls because the links are the record that survives them, and it subsumes what the chain covered — within one call the links are set as each hop is taken, so ancestry and chain membership agree. The extra parameter is gone and the signature is back to what it was.
I went with reading the links rather than clearing them at the root of a resolution: a reference can legitimately carry links from an earlier chain that a caller already holds, and clearing on every root resolve would drop those.
The topLevel != nextRef half is belt and braces. Top-level parent is normally the head of the parent chain, so isAncestor already covers it, but the two fields are set independently through the public API and this keeps the self-link impossible either way.
TestResolve_SeparateCallsOverCycle resolves /a then /b in separate Resolve calls, walks both parent chains to a terminus, and asserts /a is not parented under /b. Unguarded it fails on /a: parent links cycle.
go test ./..., go test -race ./openapi/... ./references/... ./jsonpointer/... and golangci-lint run ./openapi/... are clean.
The per-call chain only knew about hops made by one Resolve. Each member of a cycle can be resolved by its own call: resolving /a leaves b.parent = a, and resolving /b afterwards starts a fresh chain, sees /a as new, and adds a.parent = b on top of it. ResolveAllReferences never hit this because it skips references already marked resolved, but Reference.Resolve has no such guard. Ask the links instead. A reference is unsafe to parent to another when it is already reachable from it, which holds across calls because the links are the record that survives them. That subsumes what the chain covered, so the extra parameter goes away and the signature returns to what it was. TestResolve_SeparateCallsOverCycle resolves both members of a cycle in separate calls and walks their links; without the guard it fails on /a: parent links cycle.
Summary
A JSON pointer that passes through the reference it is resolving breaks three separate things: resolution deadlocks,
GetObjectrecurses until the stack is gone, and the parent links are left pointing in a loop. Each is fixed below.1. Resolution deadlocks
Reference.resolveheld the reference's owncacheMutexwrite lock across the call toreferences.Resolve(reference.go#L537-L557). That call navigates the document, and navigating into a reference callsGetObject, which takes a read lock on that reference (reference.go#L293).sync.RWMutexis not reentrant, so a$refwhose JSON pointer passes through the reference being resolved blocks forever on a lock its own goroutine holds. No concurrency is involved — this is a single-goroutine self-deadlock, and standalone it aborts withfatal error: all goroutines are asleep - deadlock!.An 83-byte document is enough:
ResolveAllReferencesnever returns.How the pointer reaches the in-flight reference
Directly, when the pointer's own prefix names it — the case above. The final
/tsegment is never evaluated; the walk deadlocks on the#/paths/~1aprefix.Through the cache delegation at reference.go#L299, where
GetObjectforwards toreferenceResolutionCache.Object.GetObject(). An already-resolved reference forwards into the one currently resolving:Resolving
/acompletes, then resolving/bnavigates to/a, which forwards straight back into/bwhile/b's write lock is held.Neither shape is caught by
resolveObjectWithTracking: itsreferenceChainis only extended once a hop completes, so a hop that re-enters itself is never compared against it.The change. Take the write lock only for the double-check, release it while resolving, and re-acquire to publish the result — re-checking the cache in case another goroutine published first.
Concurrent resolution of the same reference may now duplicate work. The published result is whichever completes first and every caller returns it, so the resolved value is consistent; the cost is a second traversal, and the discarded result's entry in the root document's object cache.
2.
GetObjectrecurses through a cyclic resolution cacheIndependent of the lock, and not fixed by changing it. When a resolution chain closes a loop, the references involved are left holding caches that point back into it, and
GetObject's delegation walked that by recursing into the next reference'sGetObject. A loop there exhausts the goroutine stack and aborts the process:Two shapes reach it. A reference can resolve to itself, because
GetJSONPointertrims the pointer, so'#/paths/~1a '— one trailing space — names/a:Or two references can resolve to each other, where neither is a self-reference:
Both report the circular reference they should —
circular reference detected: test.yaml#/paths/~1a -> test.yaml#/paths/~1aand the two-hop equivalent — but the tracker only reaches that verdict on the hop after the caches are published, so the loop is already in place by the time it returns. Any consumer that then callsGetObjecttakes the process down.Legitimate circular references are unaffected: there the last hop is never resolved, so its cache stays empty and the chain terminates.
The change.
GetObjectwalks the chain iteratively, tracking what it has seen and returning nil once a reference repeats. That covers a cycle of any length and leaves the resolution errors and the published cache exactly as they were. Suppressing the publish instead is not viable —resolveObjectWithTrackingreadsref.referenceResolutionCache.ResolvedDocumentas soon asresolvereturns a next reference, so an empty cache trades the overflow for a nil dereference.3. Parent links close the same loop
resolveObjectWithTrackingsetsSetParent/SetTopLevelParenton each hop before recursing, including the hop that closes a cycle. In the two-reference case that leavesa.parent = balongsideb.parent = a, so a caller walking the publicGetParentorGetTopLevelParentloops forever.The absolute references the tracker already collects cannot answer this on their own: a pointer can name a reference that is already in the chain, so one reference value can appear under two different absolute references.
The change. Carry the reference values alongside
referenceChainand skip the links for any value the chain has already been through. The cycle is still reported by the recursive call, and anything that resolves cleanly keeps its links.Test plan
TestResolveAllReferences_PointerTraversingItsOwnReferencecovers seven shapes: direct prefix, prefix with a resolving pointer, the cache-delegation chain, the components spelling (#/components/pathItems/A/t), the webhooks spelling, the trimmed-pointer self-reference, and the two-reference cycle. Each asserts the specific error the document should produce, then callsGetObjectand walks the parent links, which is where the second and third defects live.TestGetObject_ChainWalkingpins both ends of the chain walk: a three-hop chain still reaches its object, and a legitimate circular reference still reports no object.The resolve runs in a goroutine with a 30-second bound, so a deadlock regression fails the test rather than hanging the suite.
GetObjectwalk: both cycle shapes abort the binary withfatal error: stack overflow./a: parent links cycle.go test ./...— 44 packages, 0 failures.go test -race ./openapi/... ./references/... ./jsonpointer/...— clean.golangci-lint run ./openapi/...— 0 issues.Found by fuzzing a downstream consumer, where the deadlock presented as workers dying with no diagnostic —
go test -fuzzwires worker stderr to/dev/null, so it only ever surfaced asEOF.Note: the schema resolver has the same defect, not fixed here
jsonschema/oas3resolves schema references through its own tracker, and that one still recurses forever on a reference cycle. It is unchanged by this PR and reproduces identically onmainand on this branch, so it is called out here rather than folded in.It needs a schema whose entire body is a
$ref— an alias — participating in a cycle:ResolveAllReferencesaborts the process withfatal error: stack overflowinresolveJSONSchemaWithTracking. A bare self-alias (A: {$ref: '#/components/schemas/A'}) does the same.Ordinary recursive schemas are unaffected — a schema referencing itself through a property, or two schemas referencing each other through properties, both resolve cleanly. Spelled with path items instead of schemas, the alias cycle above is already reported correctly as
circular reference detected: test.yaml#/components/pathItems/A -> ... -> test.yaml#/components/pathItems/A, so this is an asymmetry between the two resolvers rather than a gap in cycle handling generally.The cause is the cache short-circuit at resolution.go#L173-L177, which returns
nilin place of the reference chain:Every cached hop hands an empty chain back to the recursion at resolution.go#L403, so the tracker resets and never matches. Instrumenting the recursion on the self-alias case shows the chain filling once and then staying empty against a schema pointer that never changes:
The parent links have the matching problem: resolution.go#L398-L399 sets them unconditionally, so the same probe shows
schema.parent == schemaandschema.topLevelParent == schemafrom the second hop on — the schema-side version of the parent-link fix in this PR.Worth a follow-up.
Summary by cubic
Fixes self-deadlocks and stack overflows in
$refresolution by releasing the reference lock during resolve, walking cache chains iteratively with cycle detection, and preventing cyclic parent links using existing links. Cyclic/self-refs now return clear circular/unresolved errors without crashing.Bug Fixes
cacheMutexbefore callingreferences.Resolve, re-acquire only to publish; avoidssync.RWMutexself-deadlocks when traversal reaches the in-flight ref.GetObjectiterate through the resolution chain and track seen references; return nil on cycles instead of recursing.isAncestorcheck over existingGetParentlinks; skip settingParent/TopLevelParentwhen it would create a loop, including across separateResolvecalls.Refactors
Reference.resolve: read caches under lock, unlock, then branch on copies; re-lock only to publish. Simplifies locking and matches the read-lock pattern above.Written for commit f58f208. Summary will update on new commits.