Skip to content

compilePackages: moving GC corrupts Three WebGLRenderTarget construction #9081

Description

@proggeramlug

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:

ok

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:

  1. depthTexture is omitted from the caller-provided options.
  2. Immediately after Object.assign(options, defaults), options.depthTexture exists and is null, as expected.
  3. After the intervening texture allocations/construction, the later this.depthTexture = options.depthTexture passes undefined to the setter.
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions