Skip to content

fix: iterate (not recurse) in external dereferencing depth loop#506

Open
aqeelat wants to merge 2 commits into
mattpolzin:release/7_0from
aqeelat:fix/external-deref-stack-overflow
Open

fix: iterate (not recurse) in external dereferencing depth loop#506
aqeelat wants to merge 2 commits into
mattpolzin:release/7_0from
aqeelat:fix/external-deref-stack-overflow

Conversation

@aqeelat

@aqeelat aqeelat commented Jul 11, 2026

Copy link
Copy Markdown

Fixes #505.

Problem

Components.externallyDereference(with:depth:) implemented its depth/iteration loop recursively, nesting one large async frame per pass (each frame: 11 OrderedDictionary snapshots + 11 async let futures + 11 resolved Components, all live across awaits). For .full on a document with chained external $refs, that stacked ~5 multi-KB frames and intermittently overflowed nightly-focal's cooperative-thread stack → SIGSEGV in ExternalDereferencingDocumentTests.test_example. It was flaky (Swift's task scheduler sometimes runs child tasks inline, deepening the stack) and also a latent scalability bug (deep external-ref chains would overflow even on macOS). See #505 for the full diagnosis.

Fix

Convert the recursive self-call to a while loop by extracting one pass into a performExternalDereferencePass helper. Per-pass stack cost goes from O(passes) recursive frames → O(1), eliminating the depth-based stack growth.

  • Behavior-identical and non-breaking (internal refactor only).
  • Applied to both OpenAPIKit and OpenAPIKit30 (identical pattern).
  • Full suite: 2158 tests, 0 failures (includes a new cyclic-external-ref test).

Notes

  • Cyclic external refs: .full converges for cycles via merge-collision (detectCollision keeps the already-resolved component). A cyclic test (A→B→A) is included to document this. No explicit cycle guard needed for loaders that follow the componentKey contract.
  • Memoization of loaded URLs — perf only; left as a separate follow-up.

This PR was drafted with assistance from AI tooling. The submitter has reviewed and validated the contents prior to submission.

aqeelat added 2 commits July 11, 2026 17:13
Components.externallyDereference implemented its depth/iteration loop
recursively, nesting one large async frame per pass. For .full on a document
with chained external $refs, that stacked enough multi-KB frames to overflow
nightly-focal's cooperative-thread stack (intermittent SIGSEGV in
test_example). It was also a latent scalability bug for deep external-ref chains.

Convert the recursive self-call to a while loop by extracting one pass into a
performExternalDereferencePass helper. Per-pass stack cost goes from O(passes)
recursive frames to O(1). Behavior-identical; non-breaking. Applied to both
OpenAPIKit and OpenAPIKit30.

Fixes mattpolzin#505.
Adds a test with a cyclic external-ref graph (A→B→A) to prove that
externallyDereference(with: .full) converges — the merge-collision
(detectCollision keeps the old component) discards re-loaded duplicates
and the loop terminates once all refs are internal.
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