Summary
Perry's moving/scavenging GC can corrupt live values while constructing stock Three.js objects from a package compiled with compilePackages.
A one-line new WebGLRenderTarget() using unmodified three@0.180.0 succeeds in Node and with Perry's default heap, but deterministically fails when the scavenging nursery is reduced to 1 MiB. The first constructor is sufficient; no Bloom, renderer shim, DOM, or WebGL implementation is involved.
This is also the next runtime blocker for a native Three.js + Bloom game after #9050, #9051, #9052, and #9053. In the full game, normal heap growth eventually produces the same apparent class of corruption during RenderTarget construction.
Environment
- Perry tested revision:
8b40634abdf525cfadb03bb0aa6f3c874bec140b (main; the only newer commit at filing time, d253e13a21, changes simctl CI cache invalidation)
- Perry version:
0.5.1519
- Backend: in-process LLVM 22.1.4
- Three.js:
0.180.0
- Host: macOS 26.5 arm64
- Node:
v26.5.1
Minimal reproduction
package.json:
{
"name": "perry-three-render-target-gc-repro",
"private": true,
"type": "module",
"dependencies": {
"three": "0.180.0"
},
"perry": {
"compilePackages": [
"three"
],
"allow": {
"compilePackages": [
"three"
]
}
}
}
actual-three.js:
import { WebGLRenderTarget } from 'three';
const target = new WebGLRenderTarget();
console.log(target.depthTexture === null ? 'ok' : 'bad');
From a Perry checkout at the revision above:
cargo build --release -p perry -p perry-runtime-static -p perry-stdlib-static
From the reproduction directory:
npm install
PERRY_RUNTIME_DIR=/path/to/perry/target/release \
/path/to/perry/target/release/perry compile actual-three.js \
-o actual-three.bin \
--no-auto-optimize \
--no-color
node actual-three.js
./actual-three.bin
PERRY_GC_SCAVENGE=1 \
PERRY_GC_SCAVENGE_NURSERY_MB=1 \
PERRY_GC_INCREMENTAL=0 \
./actual-three.bin
Expected result
All three executions print:
and exit 0.
Actual result
Node and the default Perry heap print ok and exit 0. The 1 MiB scavenging-nursery run deterministically fails on the first constructor and exits 1:
TypeError: Cannot read properties of undefined (reading 'slice')
at <anonymous>
The slice operation is Three's unmodified Texture.copy implementation:
this.mipmaps = source.mipmaps.slice( 0 );
Full integration symptom
With an otherwise working native Three.js renderer backed by Bloom, the game compiles all 161 modules and initializes Bloom and its atmosphere, but later fails under the default GC configuration with:
TypeError: Cannot set properties of null or undefined (setting 'renderTarget')
That assignment is in Three's unmodified RenderTarget.depthTexture setter:
set depthTexture( value ) {
const current = this._depthTexture;
if ( current !== null ) current.renderTarget = null;
if ( value !== null ) value.renderTarget = this;
this._depthTexture = value;
}
Local diagnostic logging around the stock RenderTarget constructor showed:
depthTexture is omitted from the caller-provided options.
- Immediately after
Object.assign(options, defaults), options.depthTexture exists and is null, as expected.
- After the intervening texture allocations/construction, the later
this.depthTexture = options.depthTexture passes undefined to the setter.
- Logging at the Perry Three renderer adapter's
setRenderTarget entry showed a valid receiver for every call; the failing write occurs before that adapter is entered.
This instrumentation was diagnostic only; the minimal reproduction above uses the published, unmodified Three package.
Likely cause
This looks like a missing or stale GC root across allocation-capable constructor/method calls in generated code. A live object/value used by the outer RenderTarget constructor appears not to be protected and reloaded across an evacuating minor collection, so a later property read observes invalid/stale state.
That is an inference, but the behavior is strongly GC-sensitive:
- Node succeeds.
- Perry succeeds when this tiny reproduction does not fill its normal nursery.
- A 1 MiB scavenging nursery fails deterministically on the first constructor.
- The larger application reaches a related corruption under default GC after natural heap growth.
- Removing Bloom and the renderer adapter does not remove the failure.
Suggested fix and regression coverage
- Ensure live NaN-boxed/object locals in constructors remain rooted across every allocation safepoint and are reloaded after a moving collection.
- Audit nested constructor/method calls in compiled package code for shadow-stack/temp-root dominance, especially values retained by an outer constructor while inner constructors allocate.
- Add a
compilePackages regression using three@0.180.0 and one new WebGLRenderTarget() under a forced small scavenging nursery.
- Make the test prove that a collection/evacuation happened, so it cannot pass merely because allocation volume or nursery sizing changes.
Summary
Perry's moving/scavenging GC can corrupt live values while constructing stock Three.js objects from a package compiled with
compilePackages.A one-line
new WebGLRenderTarget()using unmodifiedthree@0.180.0succeeds in Node and with Perry's default heap, but deterministically fails when the scavenging nursery is reduced to 1 MiB. The first constructor is sufficient; no Bloom, renderer shim, DOM, or WebGL implementation is involved.This is also the next runtime blocker for a native Three.js + Bloom game after #9050, #9051, #9052, and #9053. In the full game, normal heap growth eventually produces the same apparent class of corruption during
RenderTargetconstruction.Environment
8b40634abdf525cfadb03bb0aa6f3c874bec140b(main; the only newer commit at filing time,d253e13a21, changes simctl CI cache invalidation)0.5.15190.180.0v26.5.1Minimal reproduction
package.json:{ "name": "perry-three-render-target-gc-repro", "private": true, "type": "module", "dependencies": { "three": "0.180.0" }, "perry": { "compilePackages": [ "three" ], "allow": { "compilePackages": [ "three" ] } } }actual-three.js:From a Perry checkout at the revision above:
From the reproduction directory:
Expected result
All three executions print:
and exit 0.
Actual result
Node and the default Perry heap print
okand exit 0. The 1 MiB scavenging-nursery run deterministically fails on the first constructor and exits 1:The
sliceoperation is Three's unmodifiedTexture.copyimplementation:Full integration symptom
With an otherwise working native Three.js renderer backed by Bloom, the game compiles all 161 modules and initializes Bloom and its atmosphere, but later fails under the default GC configuration with:
That assignment is in Three's unmodified
RenderTarget.depthTexturesetter:Local diagnostic logging around the stock
RenderTargetconstructor showed:depthTextureis omitted from the caller-provided options.Object.assign(options, defaults),options.depthTextureexists and isnull, as expected.this.depthTexture = options.depthTexturepassesundefinedto the setter.setRenderTargetentry showed a valid receiver for every call; the failing write occurs before that adapter is entered.This instrumentation was diagnostic only; the minimal reproduction above uses the published, unmodified Three package.
Likely cause
This looks like a missing or stale GC root across allocation-capable constructor/method calls in generated code. A live object/value used by the outer
RenderTargetconstructor appears not to be protected and reloaded across an evacuating minor collection, so a later property read observes invalid/stale state.That is an inference, but the behavior is strongly GC-sensitive:
Suggested fix and regression coverage
compilePackagesregression usingthree@0.180.0and onenew WebGLRenderTarget()under a forced small scavenging nursery.