Skip to content

fix(mergeAllOf): prevent infinite loop on mutually recursive allOf schemas - #39

Open
vwong wants to merge 2 commits into
stoplightio:masterfrom
vwong:fix/infinite-recursion
Open

fix(mergeAllOf): prevent infinite loop on mutually recursive allOf schemas#39
vwong wants to merge 2 commits into
stoplightio:masterfrom
vwong:fix/infinite-recursion

Conversation

@vwong

@vwong vwong commented Aug 27, 2026

Copy link
Copy Markdown

Problem

When a schema contains mutually recursive allOf references (e.g. A = { allOf: [{ $ref: '#/B' }] }, B = { allOf: [{ $ref: '#/A' }] }), mergeAllOf enters an infinite loop and hangs the process.

The root cause is in the do-while loop in mergeAllOf:

do {
  merged = _mergeAllOf(merged, path, resolveRef, seen);
} while ('allOf' in merged);

resolveAllOf (from @stoplight/json-schema-merge-allof, called with deep: false) returns a new object on every invocation. Because seen is a WeakMap keyed by object identity, the cache never hits for these new intermediate objects. The loop runs forever, consuming 100% CPU without ever overflowing the call stack.

This surfaces as an InternalError: too much recursion (Firefox) or RangeError: Maximum call stack size exceeded (Chrome) in consumers like @stoplight/elements when rendering OpenAPI specs that contain circular schema references.

Fix

Track which $refs have been resolved across do-while iterations using a Set<string> created once per mergeAllOf call. Each iteration records the $refs it resolves; subsequent iterations check against that accumulated set. If a $ref from a prior iteration appears again, the chain is circular and we throw ResolvingError — which the walker already catches and handles gracefully by falling back to the raw unmerged fragment.

Key properties of this approach:

  • No false positives for siblings: the same $ref appearing multiple times within a single allOf (resolved in the same iteration) is allowed — only cross-iteration repeats are blocked.
  • Long legitimate chains work: A → allOf → B → allOf → C → (no allOf) resolves #/B then #/C in successive iterations with no repeated refs.
  • Simplifies the code: removes the store WeakMap (which tracked schemaRefs per (resolveRef, fragment) pair) in favour of the per-call set.

Test

src/__tests__/mergeAllOf.spec.ts calls mergeAllOf directly with a mutually recursive A ↔ B schema and asserts toThrow(ResolvingError). Without the fix the test hangs; with it the test completes in ~3 ms.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant