Repository navigation
perry compile OOMs at ~34 GB RSS on Effect (effect@3.21.2) — codegen phase #309
Description
What happened
perry compile of a 4-line program importing Effect (a popular TypeScript framework, ~12k weekly downloads of the effect package) consumes 34 GB of RSS and 249 GB of peak memory footprint before being SIGKILL'd by the OS after ~7 minutes of wall-clock time, during the codegen phase.
Module collection and parse succeed cleanly (152 native modules collected). The compiler reaches Generating code... and then memory grows unboundedly.
What you expected
perry compile to either (a) finish, (b) finish in proportional memory to the source size (Effect is ~150 modules and ~30k LOC for Effect.ts alone — substantial but not pathological), or (c) emit a clear "compiler limit exceeded" error at a sensible memory bound rather than letting the OS kill the process.
Minimal reproduction
mkdir effect-oom && cd effect-oom
cat > package.json <<'JSON'
{
"name": "effect-oom",
"version": "0.0.0",
"type": "module",
"perry": { "compilePackages": ["effect"] }
}
JSON
bun add effect # installs effect@3.21.2 + 3 transitive deps// test_direct.ts
import * as Effect from "effect/Effect";
const program = Effect.succeed(42);
const result = Effect.runSync(program);
console.log("result:", result);Command:
/usr/bin/time -l perry compile test_direct.ts -o /tmp/outDiagnostic output
Collecting modules...
Compile package: effect
Found 152 module(s): 152 native, 0 JavaScript
Generating code...
[ ~7 min later, OS SIGKILLs the process ]
exit=137 (SIGKILL)
/usr/bin/time -l summary on the run that crashed:
430.45 real 384.12 user 35.67 sys
36482269184 maximum resident set size (~34.0 GB)
23261581 page reclaims
21674 page faults
0 swaps
442 voluntary context switches
186928 involuntary context switches
6795075182564 instructions retired
1253129492975 cycles elapsed
267389742376 peak memory footprint (~249 GB virtual)
time: command terminated abnormally
The compiler emits 173 Warning: unknown identifier '...' lines during HIR lowering — most of these are arguments (148×, used heavily by Effect's dual() data-first/data-last helper) and Intl (~10×, used in DateTime / Schedule modules). These are pre-existing categorical gaps (arguments is documented in docs/src/language/limitations.md); I do not believe they cause the OOM but they may be amplifying it via the per-warning lowering fallback path.
Environment
- Perry version: 0.5.399
- Host OS: macOS 26.4 (arm64, Apple Silicon)
- Target: native (default — no
--targetflag) - Installed via: from source (
cargo build --releaseagainst current main) - Effect version: 3.21.2 (latest as of filing)
Notes for triage
- Module collection + parse phases finish promptly. Memory growth begins after
Generating code.... This points the finger at HIR transforms, codegen, or a pass that runs between them. Reasonable starting suspects from CHANGELOG.md history:widen_mutable_capturesincrates/perry-hir/src/lower.rs— v0.5.336 noted three back-to-back walks; quadratic factor possible on a deeply-nested HIR like Effect's Channel/Stream/Fiber generic chains.compute_max_local_idwalks — class-scan was added for Class method body cannot capture enclosing-function locals #212 (v0.5.323) and Gap: await using (Symbol.asyncDispose / ES2024 explicit resource management) #154 (v0.5.388); similar walk over Effect's many class methods could compound.- The
async_to_generatortransform (v0.5.371) — Effect uses generators heavily forEffect.gen; this pass walks every fn body. - The inliner (
crates/perry-transform/src/inline.rs) — Effect'sdual()helper produces functions that are tiny and called from everywhere; inlining cost may be quadratic in call-site count.
- Suggested investigation: build Perry with debug symbols (
RUSTFLAGS='-g' cargo build --release), run the repro undersamplyorInstruments → Allocations, look for the call site holding the most live bytes when RSS is at ~10-20 GB. If a single transform pass owns most of the heap, that's the wedge. - Workaround for users today: none.
import { Effect } from "effect"(the bare form) instead "compiles" but silently producesresult: 0because Perry's collector doesn't traverseexport * as Effect from "./Effect.js"— filed separately as #ADJACENT.
Why this matters
Effect is one of the largest and most-watched TypeScript-only frameworks. A successful "perry compile your Effect app to a native binary" story is a strong lever for adoption — but at 34 GB RSS the bar isn't just "Effect works", it's "Perry works on any large TypeScript codebase." This OOM almost certainly reproduces on other large libraries too (Angular, NestJS, RxJS-heavy apps); Effect is just the largest pure-TS surface I've tested.
Activity
proggeramlug commentedon Apr 30, 2026
Adjacent silent-correctness bug filed as #310 — together they form the full Effect compat story.
proggeramlug commentedon Apr 30, 2026
Auto-closed by the v0.5.403 commit. Summary of the fix for future readers:
Root cause: inheritance-chain cycle in
compile_module'sclass_table. Effect declares same-named classes across modules —class Base extends Class<any>inside Data.ts'sTaggedClassIIFE plusclass Class<A,E,R> extends Base<A,E,R>in Effectable.ts. When the cross-module-import resolver pulls every exported class into Data.ts'simported_class_stubs(the v0.5.362 #240 augmentation fires for every Effect module since they reference unresolved type names like bareA/B/E1/R1generics by the dozen), the stub forClasscarriesparent_name = "Base"from Effectable's source. In Data.ts's namespace that resolves to Data.ts's OWN localBase— different class, same name — formingBase → Class → Base → …cycle.apply_field_initializers_recursivethen accumulated visited names intochain: Vec<String>without bound: ~3.5 GB/sec single-threaded, no upper limit.Fix: one centralized cycle break in
compile_moduleatcrates/perry-codegen/src/codegen.rs— runs once afterimported_class_stubsare built but before they enterclass_table. DFS through the (local ∪ stub) union name space with a HashSet visited-set + depth cap of 64; dropsextends_name = Noneon stubs whose chain closes a cycle. All downstream chain walks then operate on a guaranteed-acyclic graph.End-to-end on the exact repro:
All 152 Effect modules now compile cleanly to
.ofiles. The link step then fails with separateld: symbol(s) not founderrors for unresolved cross-module method symbols (e.g.RedBlackTreeIterator__next) — pre-existing dispatch gaps for specific Effect patterns (likely the v0.5.362 polymorphic-receiver augmentation pulling in classes whose method bodies don't get emitted), tracked separately as the natural follow-up to "perry compile and links an Effect app". The OOM that this issue documented is fully fixed.Verified no regression: gap tests 27/28 = baseline (lone fail is the documented
console_methodsci-env quirk); inheritance-chain regression smoke (3-level Animal/Dog/Puppy with super-method calls) compiles + runs correctly; existing #212 / #240 / #261 regression tests all matchnode --experimental-strip-typesbyte-for-byte.The fundamental name-collision issue (different classes that happen to share a name across modules) is left unfixed — a proper resolution requires module-prefixing class names in HIR, which is a multi-day refactor with breaking implications for the entire dispatch /
instanceof/ class-id machinery. The cycle break here is purely defensive and lets compilation proceed even when name collisions occur.chore: fmt codegen.rs after v0.5.403 #309 fix (no version bump)
Revert "chore: fmt codegen.rs after v0.5.403 #309 fix (no version bump)"
proggeramlug commentedon Apr 30, 2026
End-to-end Effect compat is now tracked under #321 — the codegen long tail surfaced once this was resolved is split across #314 / #315 / #316 / #317 / #318 / #319 / #320.
fix(codegen): closes #318 — delegate closure-collector descent to cen…
chore(stdlib): closes #320 — pin ReadableStream regression test (link…
fix(codegen): closes #319 — gate string self-append fast path on allo…
fix(codegen): PerryTS#309 Effect 34 GB OOM via inheritance-chain cycl…
fix(codegen): closes #315 — accept String.startsWith/endsWith 2-arg f…
fix(codegen): closes #316 — accept String.slice/substring 0-arg form …
fix(codegen): closes #316 — accept String.slice/substring 0-arg form …
fix(codegen): closes #316 — accept String.slice/substring 0-arg form …
fix(codegen): closes #316 — accept String.slice/substring 0-arg form …