You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
DeepCloneNode allocation runaway (44 GB / 308 s) for recursive tagged-tuple type alias
Summary
tsgo enters an allocation runaway through ast.NodeFactory.DeepCloneNode and printer.EmitContext.SetOriginalEx's LinkStore when type-checking a package whose type-reference graph hinges on a self-recursive, multi-variadic-tuple type alias and whose .tsbuildinfo is on disk. We observed 43.59 GB of allocations across 308.76 s of CPU time on ~10 cores in a single tsgo invocation (~40 GB RSS) before the process was killed externally. Deleting the .tsbuildinfo on the same source compiles in 1.8 s with normal allocation. Same family as the open issue #3378 (Crash, assigned to @jakebailey) and as the closed #2917 / #2987 (template-literal recursion, fixed) — but for a different recursive-type family (tuple/union with variadic tails) and a different surface (declaration-emit path, not LSP crash).
Environment
tsgo version: 7.0.0-dev.20260509.2
Platform: Darwin 25.5.0, arm64 (M-series Mac), 10 hardware threads
Codebase: Bun workspace monorepo. The affected package has ~50 source files referencing the recursive type alias; ~110 additional files across the monorepo import it transitively. tsgo resolves ~8.4k files into its compilation scope (workspace deps + their transitive node_modules).
tsconfig.json: incremental: true, noEmit: true, declaration: false, composite: false, isolatedDeclarations: false (no declaration emit requested anywhere in the monorepo).
Profiles
Captured with tsgo --pprofDir ./profile. Attaching four .pb.gz files:
74.7% of allocations are inside DeepCloneNode's recursive visitor, walking a TupleType → UnionType → ArrayType → RestType → LiteralType chain. That is the exact AST shape of the recursive type alias described below.
51% is EmitContext.SetOriginalEx and its backing LinkStore — the data structure that records original → clone pointers for every node deep-clone produces. Its backing slice is grown via slices.Grow ~9 GB worth.
Flat allocation top hitter is SetOriginalEx at 9.08 GB. The leak is EmitContext metadata, not the AST nodes themselves.
90% of CPU is in the GC's mark/scan path. The process is heap-thrashed; allocation rate exceeds GC throughput, so GOMEMLIMIT doesn't bound it.
Control profile (same code, no .tsbuildinfo)
File: tsgo Type: alloc_space
Total: 3.22 GB Total time: 1.77 s
Top of cumulative is normal type-checker work:
- checker.(*Relater).isRelatedToEx 36.75%
- checker.(*Checker).checkTypeRelatedToEx 36.36%
- checker.(*Relater).recursiveTypeRelatedTo 35.21%
- checker.(*Relater).structuredTypeRelatedTo 35.07%
No samples in DeepCloneNode (go tool pprof -focus DeepCloneNode returns "focus expression matched no samples"). The same source, with .tsbuildinfo removed, finishes in 1.77 seconds and never enters the deep-clone path.
The type pattern
The package defines (anonymized; original identifiers replaced with neutral tokens; structure unchanged):
Six-alternate self-referential union with two variadic tuple branches (["array", T, …] ×3 overloads and ["union", T, ...T[]]). The AST shape Tuple→Union→Array→Rest→Literal reported in the pprof maps 1:1 onto this declaration.
This is the type pattern of a tagged-tuple JSON DSL — same family as Schema.declare(...)'s recursive shapes, structural-editor ASTs, blockchain ABI encodings, ts-json-schema generators, etc. ~50 files within the affected package reference Doc directly; ~110 across the wider monorepo do.
What appears to trigger it
Three conditions seem necessary together. We could not produce the leak with any two of the three:
A self-recursive type alias with multiple variadic-tuple alternates (above).
A non-trivial set of internal consumers + cross-package imports, such that tsgo materializes the type as an AST and deep-clones it across many sites (e.g. for declaration diagnostics — which run even when declaration: false / noEmit: true; see LSP request failure for diagnostics during type serialization #3378 stack).
A pre-existing .tsbuildinfo whose cached symbol/type signatures have desynchronized from the current source. Anything that produces this works: adding a new union alternate to the recursive type; bulk-renaming a heavily-referenced identifier; upgrading @typescript/native-preview without clearing the buildinfo.
When all three are present, tsgo's NodeBuilderImpl.serializeTypeForDeclaration → typeToTypeNode → DeepCloneNode path runs across the dependency graph and EmitContext's LinkStore grows linearly with allocations.
The same code path appears in the stack for #3378 (serializeTypeForDeclaration → typeToTypeNode → DeepCloneNode), which was reported as an LSP crash. Our reproduction is non-LSP — tsgo invoked directly with --pprofDir.
Cross-references
microsoft/typescript-go#3378 — same serializeTypeForDeclaration → typeToTypeNode → DeepCloneNode stack; reported as LSP diagnostics crash.
microsoft/typescript-go#2917 / #2987 — recursive template-literal memory leak (closed, fixed via truncation check in conditionalTypeToTypeNode). Different recursive-type family from ours.
This issue body and the supporting analysis were drafted with assistance from Claude (Anthropic), per CONTRIBUTING.md's AI-disclosure requirement. The pprofs were captured against a real codebase; the type-alias shape shown above is anonymized but structurally identical to the original. I have read the analysis, understand it, and will iterate on any follow-up the maintainers raise.
DeepCloneNodeallocation runaway (44 GB / 308 s) for recursive tagged-tuple type aliasSummary
tsgoenters an allocation runaway throughast.NodeFactory.DeepCloneNodeandprinter.EmitContext.SetOriginalEx's LinkStore when type-checking a package whose type-reference graph hinges on a self-recursive, multi-variadic-tuple type alias and whose.tsbuildinfois on disk. We observed 43.59 GB of allocations across 308.76 s of CPU time on ~10 cores in a singletsgoinvocation (~40 GB RSS) before the process was killed externally. Deleting the.tsbuildinfoon the same source compiles in 1.8 s with normal allocation. Same family as the open issue #3378 (Crash, assigned to@jakebailey) and as the closed #2917 / #2987 (template-literal recursion, fixed) — but for a different recursive-type family (tuple/union with variadic tails) and a different surface (declaration-emit path, not LSP crash).Environment
7.0.0-dev.20260509.2tsgoresolves ~8.4k files into its compilation scope (workspace deps + their transitivenode_modules).tsconfig.json:incremental: true,noEmit: true,declaration: false,composite: false,isolatedDeclarations: false(no declaration emit requested anywhere in the monorepo).Profiles
Captured with
tsgo --pprofDir ./profile. Attaching four.pb.gzfiles:teammate-memprofile.pb.gzDeepCloneNode74.7% cumulative.teammate-cpuprofile.pb.gzruntime.gcDrain/runtime.scanObject— GC-thrashed, not compute-bound.control-memprofile.pb.gz.tsbuildinfodeleted before invocation. 3.22 GB total allocs, zeroDeepCloneNodesamples.control-cpuprofile.pb.gz.tsbuildinfodeleted. 1.77 s total.Memprofile — top cumulative (teammate, leak)
Read of the breakdown:
DeepCloneNode's recursive visitor, walking aTupleType → UnionType → ArrayType → RestType → LiteralTypechain. That is the exact AST shape of the recursive type alias described below.EmitContext.SetOriginalExand its backingLinkStore— the data structure that recordsoriginal → clonepointers for every node deep-clone produces. Its backing slice is grown viaslices.Grow~9 GB worth.SetOriginalExat 9.08 GB. The leak isEmitContextmetadata, not the AST nodes themselves.CPU profile — top cumulative (teammate, leak)
Control profile (same code, no
.tsbuildinfo)No samples in
DeepCloneNode(go tool pprof -focus DeepCloneNodereturns "focus expression matched no samples"). The same source, with.tsbuildinforemoved, finishes in 1.77 seconds and never enters the deep-clone path.The type pattern
The package defines (anonymized; original identifiers replaced with neutral tokens; structure unchanged):
Six-alternate self-referential union with two variadic tuple branches (
["array", T, …]×3 overloads and["union", T, ...T[]]). The AST shapeTuple→Union→Array→Rest→Literalreported in the pprof maps 1:1 onto this declaration.This is the type pattern of a tagged-tuple JSON DSL — same family as
Schema.declare(...)'s recursive shapes, structural-editor ASTs, blockchain ABI encodings, ts-json-schema generators, etc. ~50 files within the affected package referenceDocdirectly; ~110 across the wider monorepo do.What appears to trigger it
Three conditions seem necessary together. We could not produce the leak with any two of the three:
tsgomaterializes the type as an AST and deep-clones it across many sites (e.g. for declaration diagnostics — which run even whendeclaration: false/noEmit: true; see LSP request failure for diagnostics during type serialization #3378 stack)..tsbuildinfowhose cached symbol/type signatures have desynchronized from the current source. Anything that produces this works: adding a new union alternate to the recursive type; bulk-renaming a heavily-referenced identifier; upgrading@typescript/native-previewwithout clearing the buildinfo.When all three are present,
tsgo'sNodeBuilderImpl.serializeTypeForDeclaration→typeToTypeNode→DeepCloneNodepath runs across the dependency graph andEmitContext'sLinkStoregrows linearly with allocations.The same code path appears in the stack for #3378 (
serializeTypeForDeclaration → typeToTypeNode → DeepCloneNode), which was reported as an LSP crash. Our reproduction is non-LSP —tsgoinvoked directly with--pprofDir.Cross-references
serializeTypeForDeclaration → typeToTypeNode → DeepCloneNodestack; reported as LSP diagnostics crash.conditionalTypeToTypeNode). Different recursive-type family from ours.--singleThreaded,--builders 1,GOMEMLIMIT.Disclosure
This issue body and the supporting analysis were drafted with assistance from Claude (Anthropic), per
CONTRIBUTING.md's AI-disclosure requirement. The pprofs were captured against a real codebase; the type-alias shape shown above is anonymized but structurally identical to the original. I have read the analysis, understand it, and will iterate on any follow-up the maintainers raise.