Skip to content

redis blocked: RedisClient's class-capture slot 7 (isolationPool) is never rebound, so js_class_capture_value returns undefined — indirect 'extends <expression>' cap-arg split, cf #5957 #10660

Description

@proggeramlug

Found by a package-compilability probe: redis (@redis/client) now compiles from real source but fails at
runtime
, and this is the single blocker.

Cannot read private member from an object whose class did not declare it

@redis/client's classes use JS private (#) fields. Something in Perry's class handling loses the association
between an instance and the class that declared its private fields, so a legitimate this.#x read on an instance
of the declaring class is rejected.

Note this is a correctness bug, not the known perf one. #10501 covers private members being ~1200× slower than
Node; this is separate — the read fails outright.

Why it matters

redis is one of the packages whose hand-written Rust native binding we intend to delete. The probe result is:
compiles ✓, runs ✗, and this error is the only thing standing between the two. Fixing it plausibly makes the
redis binding deletable.

Worth checking first

Several class-identity fixes landed or are in flight this cycle and may be adjacent — the private-field brand
check is exactly the kind of thing that would break if instance→class association is resolved by a shared or
stale id:

If the private-field brand check consults a class id rather than the pinned constructing class, that is the likely
mechanism and #10640's pin_instance_constructing_class is the tool.

Reproduce: npm i redis, a package.json with perry.compilePackages including redis, @redis/client and its
@redis/* siblings, then any client construction. The probe's harness is on perrymaster at
/root/claude-pkgaudit-measure/pkgtest/redis_test.ts.

Activity

  1. added
    package-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindings
    on Sep 18, 2026
  2. changed the title [-]redis blocked at runtime: 'Cannot read private member from an object whose class did not declare it' — private #field brand check loses instance→class association[/-] [+]redis blocked: WeakMap.has(receiver) returns false for a legitimately-added instance, so tsc's downlevel private-field helper throws[/+] on Sep 19, 2026
  3. proggeramlug commented on Sep 19, 2026

    @proggeramlug
    ContributorAuthor

    Correcting this issue's framing — it is not a native # private-field bug. I filed it that way from the error
    text; the diagnosis shows otherwise.

    @redis/client's shipped dist/*.js uses TypeScript's downlevel private-field emission:
    __classPrivateFieldGet / __classPrivateFieldSet plus module-scope WeakMap/WeakSet. The message
    "Cannot read private member from an object whose class did not declare it" is tsc's own helper throwing, not a
    Perry brand check. Confirmed empirically: a grep across the compiled dependency tree found no native # fields
    anywhere
    .

    So the actual defect is: WeakMap.prototype.has(receiver) returns false for an instance that was legitimately
    added.

    That also means the leads I originally pointed at — #10640's pin_instance_constructing_class, #10641, #10622 —
    are probably red herrings. Class identity may still be involved (if the receiver passed to .has() is not the same
    object identity that was .set()), but the failing operation is a WeakMap lookup.

    What has been ruled out

    Diagnostic instrumentation is pushed on wip/10660-redis-private-weakmap-diag (a PERRY_DEBUG_WEAKMAP=1-gated
    eprintln! in js_weakmap_has distinguishing a zero-pointer receiver from a genuine key-not-found — temporary,
    remove before landing a fix
    ).

    These synthetic repros all failed to reproduce it standalone:

    • plain WeakMap under GC pressure
    • the WeakSet private-method pattern
    • a dynamic ClassExprFresh subclass created via a factory
    • async-method identity

    So it is not the obvious shape of any of those.

    Two hypotheses worth separating

    1. Identity — the receiver reaching .has() is a different object than the one .set() recorded (a copy, a
      re-boxed handle, a proxy/wrapper, or a re-materialized instance).
    2. Liveness — the entry was reclaimed while its key was still reachable, i.e. the weak-entry scan does not see
      some root that holds the key. This campaign has repeatedly found values live-but-unrooted across collections
      (Event/listener dispatch paths reuse unrooted JSValue copies across user callbacks that can move the heap #10600 was a confirmed segfault of that shape, and scripts/gc_runtime_root_holders.py exists precisely
      because runtime-side caches of heap pointers are invisible to the static checker).

    The instrumentation should distinguish these: a zero-pointer receiver points at (1), a genuine key-not-found with a
    live receiver points at (2). Worth also running the failing case under PERRY_GC_SCHEDULE_SEED +
    PERRY_GC_PROTECT_FROMSPACE=1, and checking whether PERRY_GEN_GC=0 (non-moving collector) makes it pass — that
    differential isolated #10600 immediately.

    Why it matters

    redis is the last of the five packages whose real source now compiles but does not run. The other four (axios,
    pg, mysql2, ws) are fixed, with three binding removals already open. This is the remaining gate on the redis
    binding.

  4. added a commit that references this issue on Sep 19, 2026
  5. proggeramlug commented on Sep 19, 2026

    @proggeramlug
    ContributorAuthor

    Ran the differential the brief asked for first, plus a deeper trace. Summary for whoever picks this up next.

    Differential: IDENTITY, not liveness

    PERRY_DEBUG_WEAKMAP=1 PERRY_GEN_GC=0 ./redis_test_bin
    PERRY_DEBUG_WEAKMAP=1 ./redis_test_bin   (default GC)
    

    Both fail identically — same [weakmap-debug] has MISS ... entries_len=0 line, same relative
    pointer offsets. Non-moving mark-sweep doesn't change anything, so this rules out from-space /
    evacuation staleness. It's an identity bug: the WeakMap .has() receives is not the one .set()
    recorded, deterministically, with no GC involvement.

    Exact failing site

    --debug-symbols + gdb (breaking on js_weakmap_has, second hit) gives a real backtrace:

    #0 js_weakmap_has
    #1 weakref_proto_thunks::try_weak_method_dispatch
    #4 perry_closure_...redis_client_dist_lib_client_index_js  (the __classPrivateFieldGet helper)
    #7 promise::async_step::call_async_step_body
    #8 perry_method_..._RedisClient__connect
    

    This is RedisClient.connect()'s first statement:

    __classPrivateFieldSet(this, _RedisClient_isolationPool,
      __classPrivateFieldGet(this, _RedisClient_isolationPool, "f") ??
      __classPrivateFieldGet(this, _RedisClient_instances, "m", _RedisClient_initiateIsolationPool).call(this),
      "f");

    --trace hir --focus RedisClient shows this lowers to a ClassCaptureValue { class_name: "RedisClient", index: 7, fallback: PropertyGet{This, "__perry_cap_10m00008e6a2006"}, prefer_fallback: true } — i.e. js_param_or_class_capture_value(this.__perry_cap_10…, class_id, 7) (crates/perry-codegen/src/expr/static_field_meta.rs). Per
    crates/perry-runtime/src/object/class_constructors.rs, that function returns
    this.__perry_cap_10… whenever it's not undefined, and only then falls back to the class's
    global decl-site capture snapshot (CLASS_CAPTURE_VALUES, keyed by (class_id, index)).

    Two things confirmed live via gdb at the crash point:

    • call (double) js_class_capture_value(<RedisClient class_id>, 7) → TAG_UNDEFINED. The
      decl-site snapshot for this slot is empty.
    • A breakpoint on js_param_or_class_capture_value shows RedisClient's own ctor-rebind
      sequence only ever calls indices 0..6 (7 calls, all real non-undefined params) before
      control passes to a second class's rebind sequence (all 22 indices, all undefined). Index 7
      (_RedisClient_isolationPool) is never in RedisClient's own rebind/stash list for this
      construction path, even though connect() and other methods need indices up to 21.

    So this.__perry_cap_10… is never stashed onto the instance, the per-instance path is empty, it
    falls through to the (also empty) global snapshot, and somewhere in that fallback chain a real but
    totally empty WeakMap gets substituted in (rather than a bare undefined, which would throw a
    different error) — that last hop I did not fully trace, but the two upstream facts above are
    independently confirmed and already explain the observed behavior.

    Not reproducible standalone (4 attempts, increasing fidelity)

    All pushed on wip/10660-redis-private-weakmap-diag:

    1. repro_10660_double_ref.ts — the same WeakMap referenced twice in one statement, inside an
      async method, set validly in a sync constructor. PASS.
    2. repro_10660_native_base.ts — adds a native EventEmitter base + construction through a
      Client.extend()-style dynamic subclass (class extends _a {}, _a.extend() factory),
      matching RedisClient extends events_1.EventEmitter + static create() { return new (_a.extend(options))(options); }. PASS.
    3. repro_10660_full_shape.ts — same as linux compilation and README #2 but with the exact 22-variable declaration order
      from the real dist/lib/client/index.js (all the _RedisClient_* WeakMap/WeakSet/method
      names, in the same order), so _isolationPool lands at the same capture index. PASS.
    4. repro_10660_nested_factory.ts — adds a second, unrelated class with its own
      extend()-factory pattern, constructed from inside the first class's constructor (mirrors
      Object.defineProperty(this, "multi", { value: this.MULTI }), which really does construct a
      RedisClientMultiCommand via js_super_construct_apply while RedisClient's own constructor
      is still on the stack — confirmed by an earlier gdb backtrace). PASS.

    So whatever's missing needs either the real 543-module compile scale, or some other real-file
    detail these didn't capture.

    Likely same family as #5957 (closed)

    #5957 documented a user-arg/cap-arg count-split bug in the same dispatcher family
    (js_super_construct_apply and siblings in class_constructors.rs) specifically for classes
    reached through an indirect/dynamic extends <expression> chain (there: a mixin function
    extends WithSuffix(Logged); here: extends _a where _a is a plain variable alias for
    RedisClient). #5957's fix covered the arg-forwarding-count shapes it enumerated; this looks like
    a residual/sibling case in the same mechanism, surfacing as capture-index starvation (a
    capture past some threshold never gets stashed) rather than a dropped positional argument. Worth
    starting there rather than from scratch.

    Where I stopped

    I did not reduce this to a minimal standalone repro, so per the campaign's own rule ("prove the gap
    test fails without the fix, then passes with it") I'm not opening a fix PR — reporting the
    diagnosis instead, as the brief allows. wip/10660-redis-private-weakmap-diag now carries the
    extended instrumentation (logs every WeakMap hit too, not just misses, with a sequence number) plus
    the four repros above, for whoever continues this.

    redis end-to-end: still fails (connect() throws immediately). The binding is not
    deletable yet — this is the blocking issue for it.

  6. changed the title [-]redis blocked: WeakMap.has(receiver) returns false for a legitimately-added instance, so tsc's downlevel private-field helper throws[/-] [+]redis blocked: RedisClient's class-capture slot 7 (isolationPool) is never rebound, so js_class_capture_value returns undefined — indirect 'extends <expression>' cap-arg split, cf #5957[/+] on Sep 19, 2026
  7. proggeramlug commented on Sep 19, 2026

    @proggeramlug
    ContributorAuthor

    Retitling again — the second correction to this issue's framing, and the WeakMap was a symptom, not the cause.

    The history of this issue's diagnosis is worth keeping, because each step was a real narrowing:

    1. Originally filed (by me) as a native # private-field brand check. Wrong — @redis/client ships tsc's
      downlevel emission, so the throw is tsc's own __classPrivateFieldGet helper, and there are no native #
      fields anywhere in the compiled tree.
    2. Then framed as WeakMap.has(receiver) returning false for a legitimately-added instance. True but
      downstream
      — the WeakMap lookup fails because the value it is handed is already undefined.
    3. Now pinned to the actual mechanism (gdb on the real binary, --debug-symbols, --trace hir):
      RedisClient.connect()'s first statement references _RedisClient_isolationPool, which lowers to
      ClassCaptureValue{class_name:"RedisClient", index:7, prefer_fallback:true} →
      js_param_or_class_capture_value(...). A live gdb call of js_class_capture_value(<RedisClient class_id>, 7)
      at the crash point returns undefined: the class's decl-site capture snapshot for that slot is empty. A
      breakpoint trace shows RedisClient's constructor-rebind sequence calls only indices 0–6 (7 calls) before
      control leaves, while its methods need up to index 21. Slot 7 (isolationPool) is never rebound.

    The liveness hypothesis is ruled out by measurement, not by argument: PERRY_GEN_GC=0 and the default moving
    collector fail identically on the real harness. So this is not from-space staleness or a reclaimed weak entry.

    Probable family: #5957 (closed)

    This looks like the same dispatcher defect — a user-arg / cap-arg count split in js_super_construct_apply and
    its siblings, for a class reached through an indirect extends <expression> chain. #5957's case was a mixin
    function; redis's is RedisClient.extend() / attachExtensions' dynamic subclass pattern (extends _a). Whoever
    picks this up should read #5957 first and check whether its fix covered only the mixin shape.

    Not reduced to a minimal repro — and that is the hard part

    Four increasingly faithful standalone attempts all passed: double-reference-in-one-statement; plus a native
    EventEmitter base and a dynamic-subclass factory; plus the exact 22-variable declaration order; plus a second
    nested factory-class construction mid-constructor. The bug appears to need either the real ~543-module compile
    scale or some detail of the real files not yet isolated.

    So this needs someone with the class-capture/rebind machinery in their head, working against the real compile
    rather than a reduction. Instrumentation (now logging every WeakMap hit with a sequence number) and all four
    ruled-out repros are pushed on wip/10660-redis-private-weakmap-diag.

    Status

    redis is the one package of five whose binding is NOT deletable. axios, pg, mysql2 and ws all have fixes with
    removal PRs open (#10677, #10679, #10680, and ws via #10668). connect() still throws immediately, so this issue is
    the sole remaining gate.

  8. proggeramlug commented on Sep 21, 2026

    @proggeramlug
    ContributorAuthor

    Fixed in v0.5.1632 (merge train 252, 841b605c97) by #10876 — fix(compile): retain classes using multiline var bindings. Regression test crates/perry/src/commands/compile/cjs_wrap/tests/hoist_scanner.rs.

    Train validation: 173 gap fixtures across seven areas with zero regressions, re-run against the current base after v0.5.1631 changed canonical_dir's path resolution. All ratchets, cargo fmt, and cargo check --workspace --all-targets under -D warnings clean.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    package-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindings

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions