From bcda14c89376852e3b06bb90be5dc469d35d0d8b Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Thu, 4 Jun 2026 15:46:56 +0200 Subject: [PATCH 1/4] fix: terminate register dependency walk on cyclic package deps mkPackageAwareResolver memoized the package index only after recursing into dependencies, so mutually-dependent packages (e.g. hl7.terminology.r5 <-> hl7.fhir.uv.extensions.r5) recursed forever until OOM. Memoize before recursing so the cycle terminates at the guard. --- src/typeschema/register.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/typeschema/register.ts b/src/typeschema/register.ts index 141dfccac..88600f995 100644 --- a/src/typeschema/register.ts +++ b/src/typeschema/register.ts @@ -93,6 +93,9 @@ const mkPackageAwareResolver = async ( if (acc[pkgId]) return acc[pkgId]; const index = mkEmptyPkgIndex(pkg); + // Memoize before recursing into dependencies so cyclic deps (e.g. hl7.terminology.r5 ↔ + // hl7.fhir.uv.extensions.r5) terminate at the guard above instead of recursing forever. + acc[pkgId] = index; for (const resource of await manager.search({ package: pkg })) { const rawUrl = resource.url; if (!rawUrl) continue; @@ -115,7 +118,6 @@ const mkPackageAwareResolver = async ( resolutionOptions.sort((a, b) => a.deep - b.deep); } - acc[pkgId] = index; return index; }; From d1f18d1674dd8c799521e5619c62450e96d72906 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Thu, 4 Jun 2026 15:46:56 +0200 Subject: [PATCH 2/4] test: cover cyclic package dependency resolution --- test/unit/typeschema/register.test.ts | 29 ++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/test/unit/typeschema/register.test.ts b/test/unit/typeschema/register.test.ts index 221085152..9f09f9290 100644 --- a/test/unit/typeschema/register.test.ts +++ b/test/unit/typeschema/register.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from "bun:test"; import type { FHIRSchema } from "@atomic-ehr/fhirschema"; import { type CanonicalUrl, enrichFHIRSchema, type Name } from "@root/typeschema/types"; -import { mergeFsElementProps, registerFromPackageMetas, resolveFsElementGenealogy } from "@typeschema/register"; +import { + mergeFsElementProps, + registerFromManager, + registerFromPackageMetas, + resolveFsElementGenealogy, +} from "@typeschema/register"; type PFS = Partial; @@ -150,3 +155,25 @@ describe("Register tests", async () => { }); }); }); + +describe("cyclic package dependencies", () => { + it("registerFromManager terminates on a dependency cycle", async () => { + // a ↔ b mutually depend (like hl7.terminology.r5 ↔ hl7.fhir.uv.extensions.r5). + // Without guarding the dependency walk this recurses until stack overflow / OOM. + const deps: Record = { a: ["b"], b: ["a"] }; + const manager = { + packages: async () => Object.keys(deps).map((name) => ({ name, version: "1.0.0" })), + packageJson: async (name: string) => ({ + name, + version: "1.0.0", + dependencies: Object.fromEntries((deps[name] ?? []).map((d) => [d, "1.0.0"])), + }), + search: async () => [], + } as unknown as Parameters[0]; + + const reg = await registerFromManager(manager, { focusedPackages: [{ name: "a", version: "1.0.0" }] }); + + expect(reg.resolver["a#1.0.0"]).toBeDefined(); + expect(reg.resolver["b#1.0.0"]).toBeDefined(); + }); +}); From 4d01bf948ac3ecfa99841f3d2f1bcd243b1bc325 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Thu, 4 Jun 2026 15:51:23 +0200 Subject: [PATCH 3/4] test: make cycle regression fail fast via search-call cap Cap manager.search() calls and assert each package is scanned exactly once, so reintroducing the bug fails red immediately instead of slowly exhausting memory. --- test/unit/typeschema/register.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/test/unit/typeschema/register.test.ts b/test/unit/typeschema/register.test.ts index 9f09f9290..fc76adb04 100644 --- a/test/unit/typeschema/register.test.ts +++ b/test/unit/typeschema/register.test.ts @@ -159,8 +159,11 @@ describe("Register tests", async () => { describe("cyclic package dependencies", () => { it("registerFromManager terminates on a dependency cycle", async () => { // a ↔ b mutually depend (like hl7.terminology.r5 ↔ hl7.fhir.uv.extensions.r5). - // Without guarding the dependency walk this recurses until stack overflow / OOM. + // Without memoizing each package before recursing into its deps, the walk recurses + // forever; each unique package should be visited exactly once. The search-call cap + // makes a regression fail fast (red) instead of slowly exhausting memory. const deps: Record = { a: ["b"], b: ["a"] }; + let searchCalls = 0; const manager = { packages: async () => Object.keys(deps).map((name) => ({ name, version: "1.0.0" })), packageJson: async (name: string) => ({ @@ -168,11 +171,15 @@ describe("cyclic package dependencies", () => { version: "1.0.0", dependencies: Object.fromEntries((deps[name] ?? []).map((d) => [d, "1.0.0"])), }), - search: async () => [], + search: async () => { + if (++searchCalls > 10) throw new Error("dependency walk did not terminate on cycle"); + return []; + }, } as unknown as Parameters[0]; const reg = await registerFromManager(manager, { focusedPackages: [{ name: "a", version: "1.0.0" }] }); + expect(searchCalls).toBe(2); // each package scanned exactly once expect(reg.resolver["a#1.0.0"]).toBeDefined(); expect(reg.resolver["b#1.0.0"]).toBeDefined(); }); From 93f9201c20deb3fa0a3d62a4f4593f4295eee592 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Thu, 4 Jun 2026 17:05:22 +0200 Subject: [PATCH 4/4] ref: drop explanatory comment on memoize-before-recurse --- src/typeschema/register.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/typeschema/register.ts b/src/typeschema/register.ts index 88600f995..cf45d6741 100644 --- a/src/typeschema/register.ts +++ b/src/typeschema/register.ts @@ -93,8 +93,6 @@ const mkPackageAwareResolver = async ( if (acc[pkgId]) return acc[pkgId]; const index = mkEmptyPkgIndex(pkg); - // Memoize before recursing into dependencies so cyclic deps (e.g. hl7.terminology.r5 ↔ - // hl7.fhir.uv.extensions.r5) terminate at the guard above instead of recursing forever. acc[pkgId] = index; for (const resource of await manager.search({ package: pkg })) { const rawUrl = resource.url;