Skip to content

class static post-increment through imported base yields NaN and corrupts perform-ecs #8654

Description

@proggeramlug

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-perry

Observed:

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-ecs assigns 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-ecs component-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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugConfirmed defect or regressionparityCompatibility gap with Node.js, ECMAScript, or the supported ecosystemperformanceRuntime, compile-time, build-size, or memory performance

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions