You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
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
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.
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
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
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).
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.
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:
--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:
repro_10660_double_ref.ts — the same WeakMap referenced twice in one statement, inside an async method, set validly in a sync constructor. PASS.
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.
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.
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.
#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.
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:
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.
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.
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.
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.
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.
Activity
[-]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[/+]proggeramlug commentedon Sep 19, 2026
Correcting this issue's framing — it is not a native
#private-field bug. I filed it that way from the errortext; the diagnosis shows otherwise.
@redis/client's shippeddist/*.jsuses TypeScript's downlevel private-field emission:__classPrivateFieldGet/__classPrivateFieldSetplus module-scopeWeakMap/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
#fieldsanywhere.
So the actual defect is:
WeakMap.prototype.has(receiver)returnsfalsefor an instance that was legitimatelyadded.
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 sameobject 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(aPERRY_DEBUG_WEAKMAP=1-gatedeprintln!injs_weakmap_hasdistinguishing 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:
WeakMapunder GC pressureWeakSetprivate-method patternClassExprFreshsubclass created via a factorySo it is not the obvious shape of any of those.
Two hypotheses worth separating
.has()is a different object than the one.set()recorded (a copy, are-boxed handle, a proxy/wrapper, or a re-materialized instance).
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.pyexists preciselybecause 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 whetherPERRY_GEN_GC=0(non-moving collector) makes it pass — thatdifferential 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.
wip(#10660): extend WeakMap debug instrumentation + rule out 4 more r…
proggeramlug commentedon Sep 19, 2026
Ran the differential the brief asked for first, plus a deeper trace. Summary for whoever picks this up next.
Differential: IDENTITY, not liveness
Both fail identically — same
[weakmap-debug] has MISS ... entries_len=0line, same relativepointer 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 onjs_weakmap_has, second hit) gives a real backtrace:This is
RedisClient.connect()'s first statement:--trace hir --focus RedisClientshows this lowers to aClassCaptureValue { 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). Percrates/perry-runtime/src/object/class_constructors.rs, that function returnsthis.__perry_cap_10…whenever it's notundefined, and only then falls back to the class'sglobal 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. Thedecl-site snapshot for this slot is empty.
js_param_or_class_capture_valueshows RedisClient's own ctor-rebindsequence only ever calls indices 0..6 (7 calls, all real non-
undefinedparams) beforecontrol 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 thisconstruction 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, itfalls 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 adifferent 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:repro_10660_double_ref.ts— the same WeakMap referenced twice in one statement, inside anasyncmethod, set validly in a sync constructor. PASS.repro_10660_native_base.ts— adds a nativeEventEmitterbase + construction through aClient.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.repro_10660_full_shape.ts— same as linux compilation and README #2 but with the exact 22-variable declaration orderfrom the real
dist/lib/client/index.js(all the_RedisClient_*WeakMap/WeakSet/methodnames, in the same order), so
_isolationPoollands at the same capture index. PASS.repro_10660_nested_factory.ts— adds a second, unrelated class with its ownextend()-factory pattern, constructed from inside the first class's constructor (mirrorsObject.defineProperty(this, "multi", { value: this.MULTI }), which really does construct aRedisClientMultiCommandviajs_super_construct_applywhileRedisClient's own constructoris 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_applyand siblings inclass_constructors.rs) specifically for classesreached through an indirect/dynamic
extends <expression>chain (there: a mixin functionextends WithSuffix(Logged); here:extends _awhere_ais a plain variable alias forRedisClient). #5957's fix covered the arg-forwarding-count shapes it enumerated; this looks likea 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-diagnow carries theextended 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 notdeletable yet — this is the blocking issue for it.
[-]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[/+]proggeramlug commentedon Sep 19, 2026
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:
#private-field brand check. Wrong —@redis/clientships tsc'sdownlevel emission, so the throw is tsc's own
__classPrivateFieldGethelper, and there are no native#fields anywhere in the compiled tree.
WeakMap.has(receiver)returningfalsefor a legitimately-added instance. True butdownstream — the WeakMap lookup fails because the value it is handed is already
undefined.--debug-symbols,--trace hir):RedisClient.connect()'s first statement references_RedisClient_isolationPool, which lowers toClassCaptureValue{class_name:"RedisClient", index:7, prefer_fallback:true}→js_param_or_class_capture_value(...). A live gdb call ofjs_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. Abreakpoint 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=0and the default movingcollector 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_applyandits siblings, for a class reached through an indirect
extends <expression>chain. #5957's case was a mixinfunction; redis's is
RedisClient.extend()/attachExtensions' dynamic subclass pattern (extends _a). Whoeverpicks 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
EventEmitterbase and a dynamic-subclass factory; plus the exact 22-variable declaration order; plus a secondnested 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 isthe sole remaining gate.
proggeramlug commentedon Sep 21, 2026
Fixed in v0.5.1632 (merge train 252,
841b605c97) by #10876 —fix(compile): retain classes using multiline var bindings. Regression testcrates/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, andcargo check --workspace --all-targetsunder-D warningsclean.