stitchSchemas mutates the resolver objects it is given. addLocalFieldResolvers wraps every user resolver whose field returns a merged type so the merged type's fields get resolved, and writes the wrapper back into the caller's field config:
const baseResolve = originalResolve ?? defaultMergedResolver;
const wrappedResolve = (parent, args, context, info) =>
handleMaybePromise(
() => baseResolve(parent, args, context, info),
(result) => resolveLocalFieldResult(result, context, info, stitchingInfo, providedFields),
);
if (existing != null && typeof existing === 'object') {
existing.resolve = wrappedResolve; // the caller's object
}
wrappedResolve closes over baseResolve, which is whatever existing.resolve was when the call started, and over this call's stitchingInfo. Pass the same resolvers object to stitchSchemas twice and the second wrapper wraps the first; pass it N times and you get a chain of N wrappers, each one holding a different stitchingInfo.
Observed on @graphql-tools/stitch 10.2.2; 10.2.3 has the same code (packages/stitch/src/stitchSchemas.ts).
Why this is a problem
@graphql-mesh/fusion-runtime does exactly that. UnifiedGraphManager hands this.opts.additionalResolvers to every unified-graph build, so a Hive Gateway that reloads its supergraph at runtime (polling, Hive CDN, any dynamic source) re-stitches with the same module-level resolver map on every reload. Each reload adds one wrapper to every stitched field config, and each wrapper keeps its build's stitchingInfo reachable: the subschema map, every subschema's GraphQLSchema, executors and transports of a generation that gracefulSchemaReload has already disposed. Nothing in the old generation can be collected.
What that looks like on a production gateway (2.11.2, a few dozen subgraphs, multi-MB supergraph), post-GC heapUsed per reload:
216 MB → 350 → 486 → 621 → 757 → 893 → FATAL ERROR: Ineffective mark-compacts near heap limit
+135 MB per reload, linear, until Node aborts at the old-space cap; a day with a handful of supergraph publications restarts every instance. Heap snapshots taken between reloads show every schema-shaped constructor growing by exactly one generation (GraphQLSchema 468 → 702 across two reloads, one executor closure per subgraph, the supergraph SDL's parse tokens), and the shortest retainer path to every stale GraphQLSchema is the same:
additionalResolvers.<Type>.<field>.resolve fn wrappedResolve
→ fn wrappedResolve → fn wrappedResolve one per reload
→ stitchingInfo → subschemaMap → Subschema → schema / executor / transports
Before runtime supergraph reloads, every schema change restarted the process, which is why a leak in a function that is normally called once never surfaced. Any consumer that reuses a resolvers object across stitchSchemas calls has it, not only fusion-runtime.
Proposal
Clone the merged resolver map before addLocalFieldResolvers runs — the type maps of object and interface types, and their field config objects, one level each — so the wrapper lands on the copy that addResolversToSchema consumes and the caller's objects stay untouched. Everything else (enum value maps, scalar instances or configs, union and input type resolvers, functions) passes through by reference, since it is never mutated and enum internal values must keep their identity. The clone must not test for plain objects: mergeResolvers deep-merges a type that appears in more than one map into an object whose prototype is not Object.prototype, while still reusing the field config objects by reference, and that is precisely the shape a type with both user resolvers and @resolveTo-generated ones has. A first attempt with a plain-object guard still leaked through such a type.
PR with the implementation, a regression test that fails on main (expected [Function wrappedResolve] to be [Function resolveOwner]) and a changeset: #2591. With the change applied, the same production-shaped gateway holds at 216–219 MB across four reloads.
stitchSchemasmutates the resolver objects it is given.addLocalFieldResolverswraps every user resolver whose field returns a merged type so the merged type's fields get resolved, and writes the wrapper back into the caller's field config:wrappedResolvecloses overbaseResolve, which is whateverexisting.resolvewas when the call started, and over this call'sstitchingInfo. Pass the sameresolversobject tostitchSchemastwice and the second wrapper wraps the first; pass it N times and you get a chain of N wrappers, each one holding a differentstitchingInfo.Observed on
@graphql-tools/stitch10.2.2; 10.2.3 has the same code (packages/stitch/src/stitchSchemas.ts).Why this is a problem
@graphql-mesh/fusion-runtimedoes exactly that.UnifiedGraphManagerhandsthis.opts.additionalResolversto every unified-graph build, so a Hive Gateway that reloads its supergraph at runtime (polling, Hive CDN, any dynamic source) re-stitches with the same module-level resolver map on every reload. Each reload adds one wrapper to every stitched field config, and each wrapper keeps its build'sstitchingInforeachable: the subschema map, every subschema'sGraphQLSchema, executors and transports of a generation thatgracefulSchemaReloadhas already disposed. Nothing in the old generation can be collected.What that looks like on a production gateway (2.11.2, a few dozen subgraphs, multi-MB supergraph), post-GC
heapUsedper reload:+135 MB per reload, linear, until Node aborts at the old-space cap; a day with a handful of supergraph publications restarts every instance. Heap snapshots taken between reloads show every schema-shaped constructor growing by exactly one generation (
GraphQLSchema468 → 702 across two reloads, one executor closure per subgraph, the supergraph SDL's parse tokens), and the shortest retainer path to every staleGraphQLSchemais the same:Before runtime supergraph reloads, every schema change restarted the process, which is why a leak in a function that is normally called once never surfaced. Any consumer that reuses a
resolversobject acrossstitchSchemascalls has it, not only fusion-runtime.Proposal
Clone the merged resolver map before
addLocalFieldResolversruns — the type maps of object and interface types, and their field config objects, one level each — so the wrapper lands on the copy thataddResolversToSchemaconsumes and the caller's objects stay untouched. Everything else (enum value maps, scalar instances or configs, union and input type resolvers, functions) passes through by reference, since it is never mutated and enum internal values must keep their identity. The clone must not test for plain objects:mergeResolversdeep-merges a type that appears in more than one map into an object whose prototype is notObject.prototype, while still reusing the field config objects by reference, and that is precisely the shape a type with both user resolvers and@resolveTo-generated ones has. A first attempt with a plain-object guard still leaked through such a type.PR with the implementation, a regression test that fails on
main(expected [Function wrappedResolve] to be [Function resolveOwner]) and a changeset: #2591. With the change applied, the same production-shaped gateway holds at 216–219 MB across four reloads.