Skip to content

fix(cli): guard OpenAPI ingestion against pathologically long schema names#17099

Open
devin-ai-integration[bot] wants to merge 2 commits into
mainfrom
devin/1784156200-openapi-schema-name-oom-guard
Open

fix(cli): guard OpenAPI ingestion against pathologically long schema names#17099
devin-ai-integration[bot] wants to merge 2 commits into
mainfrom
devin/1784156200-openapi-schema-name-oom-guard

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Description

Some OpenAPI generators emit component-schema keys that are entire serialized type expressions (hundreds of KB each). A schema name is carried as a breadcrumb into every nested subschema's generated name and is repeatedly joined/camelCase'd while walking the tree, so across thousands of oneOf/allOf branches a ~5 MB spec balloons into gigabytes and crashes fern generate --docs with FATAL ERROR: JavaScript heap out of memory during docs validation / OpenAPI→IR ingestion.

Root cause is the upstream generator (schema keys should be short identifiers), but the importer had a robustness gap: it derives names from arbitrarily large schema keys with no bound. This adds a defensive cap at ingestion so a pathological name yields an ugly-but-valid identifier instead of taking the whole build down.

The fix caps the name at the source (where a schema name first enters the breadcrumbs) rather than only at the final name-generation step — otherwise the giant string keeps propagating through recursive breadcrumbs and is materialized in many downstream consumers.

capBreadcrumbToken(token):
  token.length <= 512 ? token : `${token.slice(0,512)}_${djb2Hash(token)}`

The short base36 hash of the full token keeps distinct long names distinct and deterministic (so a $ref and its definition still resolve to the same generated name). Real schema/property names never approach 512 chars, so normal specs are unaffected and produce byte-for-byte identical output — nothing about docs rendering changes.

Changes Made

  • Add capBreadcrumbToken to @fern-api/core-utils (shared, deterministic length cap + hash).
  • Legacy OpenAPI importer (openapi-ir-parser): cap the schema/response/parameter/property names extracted in getBreadcrumbsFromReference, cap component-schema keys in generateIr, and cap tokens in getGeneratedTypeName as defense-in-depth.
  • v3 importer (v3-importer-commons): cap breadcrumb tokens in convertBreadcrumbsToName.
  • Add CLI changelog entry.
  • Updated README.md generator (if applicable) — n/a

Testing

  • Unit tests added/updated — capBreadcrumbToken (core-utils), getGeneratedTypeName, and getBreadcrumbsFromReference cap/determinism cases. Full suites pass: core-utils 119, openapi-ir-parser 93, v3-importer-commons 123.
  • Manual testing completed — reproduced against the real pathological spec (5 MB, two ~270 KB serialized-type-expression schema names, 4,031 oneOf / 1,127 allOf):
    • Before: OOM at Node's default ~4 GB heap ceiling (matches the reported crash: String::SlowFlattenNewRawStringWithMap).
    • After: parses in 0.7s / ~122 MB even with the heap capped at 1 GB (--max-old-space-size=1024). Renaming the two giant keys to short identifiers produced the same ~92 MB / 0.4s, confirming the schema name — not the union nesting — was the cause.

Link to Devin session: https://app.devin.ai/sessions/b411fdf7efcb4ec1ab0a6f84e34607af


Open in Devin Review

…names

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@nitpickybot nitpickybot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review Summary

Sensible defensive cap on pathologically long schema names, well-tested. One potential collision concern: the DJB2 hash is applied to the full token, but the cap only preserves the first 512 chars — two long tokens differing only after char 512 but sharing the same 32-bit hash would collide (astronomically unlikely, but worth noting). The implementation is otherwise clean.

  • 🔵 1 suggestion(s)

function hashToken(token: string): string {
let hash = 5381;
for (let i = 0; i < token.length; i++) {
hash = ((hash << 5) + hash + token.charCodeAt(i)) >>> 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 suggestion

DJB2 truncated to 32 bits gives ~4B distinct values. For two schema names sharing a 512-char prefix and differing only in the tail, distinctness relies solely on this hash. Collision risk is negligible in practice, but if you want a stronger guarantee consider a 53-bit accumulation or appending the token length. Not blocking.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Strengthened in 2af23d9: hashToken now combines two independent rolling hashes (different seeds) plus the token length into the suffix, widening the space well beyond 32 bits so two long tokens sharing a truncated prefix are extremely unlikely to collide. Still fully deterministic.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

View 2 additional findings in Devin Review.

Open in Devin Review

if (token.length <= MAX_BREADCRUMB_TOKEN_LENGTH) {
return token;
}
return `${token.slice(0, MAX_BREADCRUMB_TOKEN_LENGTH)}_${hashToken(token)}`;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Token capping function is not idempotent, causing double-application to silently corrupt already-capped names

The capping function produces output longer than its own threshold (capBreadcrumbToken at packages/commons/core-utils/src/capBreadcrumbToken.ts:29) because 512 prefix + "_" + up to 7-char hash = 520 chars, which exceeds the 512-char limit, so a second application re-truncates and re-hashes the already-capped value.

Impact: Pathologically long schema names get a different generated type name than intended because the cap is applied twice (once in generateIr.ts:227 and again in getSchemaName.ts:7), producing an inconsistent identifier.

Double-capping mechanism and trigger condition

In packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/generateIr.ts:227, the schema key is capped before being passed as a breadcrumb to convertSchema. Later, convertSchemaObject at packages/cli/api-importers/openapi/openapi-ir-parser/src/schema/convertSchemas.ts:382 calls getGeneratedTypeName(breadcrumbs, ...), which applies capBreadcrumbToken again at packages/cli/api-importers/openapi/openapi-ir-parser/src/schema/utils/getSchemaName.ts:7.

For a token of length > 512:

  • First cap: token.slice(0, 512) + "_" + hashToken(token) → ~520 chars
  • Second cap sees 520 > 512, so: cappedToken.slice(0, 512) + "_" + hashToken(cappedToken) → different ~520 char result

The fix is to make the function idempotent by ensuring its output never exceeds the threshold. For example, slice to MAX - 1 - maxHashLength (e.g., 504) so the total output is always ≤ 512.

Suggested change
return `${token.slice(0, MAX_BREADCRUMB_TOKEN_LENGTH)}_${hashToken(token)}`;
return `${token.slice(0, MAX_BREADCRUMB_TOKEN_LENGTH - 8)}_${hashToken(token)}`;
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 2af23d9. capBreadcrumbToken now reserves room for the _<hash> suffix (prefixLength = MAX - hash.length - 1) so output is always ≤ MAX_BREADCRUMB_TOKEN_LENGTH, making it idempotent. Added a regression test asserting capBreadcrumbToken(capBreadcrumbToken(x)) === capBreadcrumbToken(x).

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown
Contributor

Docs Generation Benchmark Results

Comparing PR branch against median of 5 nightly run(s) on main (latest: 2026-07-15T04:59:03Z).

Fixture main PR Delta
docs 247.0s (n=5) 242.5s (35 versions) -4.5s (-1.8%)

Docs generation runs fern generate --docs --preview end-to-end against the benchmark fixture with 35 API versions (each version: markdown processing + OpenAPI-to-IR + FDR upload).
Delta is computed against the nightly baseline on main.
Baseline from nightly run(s) on main (latest: 2026-07-15T04:59:03Z). Trigger benchmark-baseline to refresh.
Last updated: 2026-07-16 00:39 UTC

@github-actions

Copy link
Copy Markdown
Contributor

SDK Generation Benchmark Results

Comparing PR branch against median of 5 nightly run(s) on main (latest: 2026-07-15T04:59:03Z).

Full benchmark table (click to expand)
Generator Spec main (generator) main (E2E) PR (generator) Delta
csharp-sdk square 74s (n=5) 115s (n=5) 51s -23s (-31.1%)
go-sdk square 139s (n=5) 289s (n=5) 134s -5s (-3.6%)
java-sdk square 226s (n=5) 265s (n=5) 159s -67s (-29.6%)
php-sdk square 60s (n=5) 84s (n=5) 51s -9s (-15.0%)
python-sdk square 137s (n=5) 247s (n=5) 130s -7s (-5.1%)
ruby-sdk-v2 square 94s (n=5) 129s (n=5) 74s -20s (-21.3%)
rust-sdk square 178s (n=5) 176s (n=5) 163s -15s (-8.4%)
swift-sdk square 58s (n=5) 436s (n=5) 52s -6s (-10.3%)
ts-sdk square 131s (n=5) 131s (n=5) 122s -9s (-6.9%)

main (generator): generator-only time via --skip-scripts (includes Docker image build, container startup, IR parsing, and code generation — this is the same Docker-based flow customers use via fern generate). main (E2E): full customer-observable time including build/test scripts (nightly baseline, informational). Delta is computed against generator-only baseline.
⚠️ = generation exited with a non-zero exit code (timing may not reflect a successful run).
Baseline from nightly runs on main (latest: 2026-07-15T04:59:03Z). Trigger benchmark-baseline to refresh.
Last updated: 2026-07-16 00:39 UTC

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