Repository navigation
class static post-increment through imported base yields NaN and corrupts perform-ecs #8654
Description
Summary
An imported base class's public static field is not the same initialized cell when it is read and post-incremented from a function in the defining module. Component._id++ reads as undefined, assigns NaN to both derived constructors, and leaves the externally visible Component._id at 0.
This is a correctness bug with a catastrophic performance consequence in perform-ecs: component bitmasks collide, destroyed entities remain in a component view, and the nominally linear destroy benchmark becomes a growing-array workload.
Reproduced on current main (c2da03439e6e848cfed73163c131acb601ae96dc, Perry 0.5.1519) and on PR #8645 (0ab2fb3909d8ad503b526acb23b2695f2d825bcb). PR #8645 fixes the separate Array-subclass constructor failure but does not fix this case.
Self-contained repro
base.js:
export class Component {
static _id = 0;
}
export function makeComponent(constructor) {
constructor.id = Component._id++;
}main.js:
import { Component, makeComponent } from "./base.js";
class Position extends Component {}
class Velocity extends Component {}
makeComponent(Position);
makeComponent(Velocity);
console.log(JSON.stringify({
nextId: String(Component._id),
positionId: String(Position.id),
velocityId: String(Velocity.id),
}));Build and run from a Perry checkout:
cargo build --release -p perry -p perry-runtime-static -p perry-stdlib-static
node main.js
PERRY_NO_AUTO_OPTIMIZE=1 \
PERRY_RUNTIME_DIR=target/release \
target/release/perry compile main.js -o main-perry
./main-perryObserved:
Node: {"nextId":"2","positionId":"0","velocityId":"1"}
Perry: {"nextId":"0","positionId":"NaN","velocityId":"NaN"}
The perform-ecs integration below also reproduces through the default auto-optimize path.
Public-package impact
perform-ecs/src/Component.ts uses the same pattern:
export abstract class Component {
static readonly _id: number = 0;
static readonly id: number;
}
export function makeComponent(constructor: any) {
constructor.id = (<any>Component)._id++;
}In ddmills/js-ecs-benchmarks' perform-ecs / Destroy case:
| State after destroy | Node | Perry |
|---|---|---|
| component IDs | [0, 1] |
[NaN, NaN] |
| retained view entries after 100 destroys | 0 | 200 |
| retained view entries after 100,000 destroys | 0 | 200,000 |
At full scale on an M1 Mac mini, Perry took 169.70 s and peaked at 256,832 KiB RSS. Block time increased from 1.95 s for the first 10,000 destroys to 31.07 s for the last 50,000, consistent with the ever-growing retained array. This timing must not be treated as a valid performance comparison until the semantic bug is fixed.
Expected behavior
- A public static field has one canonical initialized storage cell across its defining module and compiled consumers.
Component._id++returns the old numeric value and stores the incremented numeric value.- The repro prints the same values as Node.
perform-ecsassigns distinct component IDs and retains zero view entries after destruction.
Acceptance criteria
- Add a two-module native regression based on the repro above and run it in both prebuilt-runtime and auto-optimize modes.
- Cover post-increment from the defining module, post-increment from an importing module, and reads from both modules.
- Include a forced-GC variant so moving/registration does not split or lose the static cell.
- Add an ecosystem regression using the
perform-ecscomponent-registration shape: IDs[0, 1], distinct masks, and zero retained entities after add/destroy. - Preserve the class-semantics coverage in test262 language/class tail — 175 (self-contained worklist) #5893/fix(runtime): finish class semantics follow-up #8645.
Likely area
The symptom points to static-field metadata/storage identity across module lowering: the defining-module function appears not to resolve the same initialized static cell that main.js observes. Relevant areas include static_field_meta, static initialization lowering, shared mutable capture, and class registration/parent-static storage.
Activity