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
Description
Activity
- addedpackage-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindingsFound by the 2026 package audit: compiling real npm packages from source instead of native bindings
on Sep 18, 2026 - 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 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#fields
anywhere.So the actual defect is:
WeakMap.prototype.has(receiver)returnsfalsefor 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(aPERRY_DEBUG_WEAKMAP=1-gated
eprintln!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:
- plain
WeakMapunder GC pressure - the
WeakSetprivate-method pattern - a dynamic
ClassExprFreshsubclass 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, andscripts/gc_runtime_root_holders.pyexists 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 underPERRY_GC_SCHEDULE_SEED+
PERRY_GC_PROTECT_FROMSPACE=1, and checking whetherPERRY_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.- plain
- added a commit that references this issue
on 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
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=0line, 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 onjs_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__connectThis 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 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). Per
crates/perry-runtime/src/object/class_constructors.rs, that function returns
this.__perry_cap_10…whenever it's notundefined, 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_valueshows RedisClient's own ctor-rebind
sequence only ever calls indices 0..6 (7 calls, all real non-undefinedparams) before
control passes to a second class's rebind sequence (all 22 indices, allundefined). Index 7
(_RedisClient_isolationPool) is never in RedisClient's own rebind/stash list for this
construction path, even thoughconnect()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 bareundefined, 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
asyncmethod, set validly in a sync constructor. PASS.repro_10660_native_base.ts— adds a nativeEventEmitterbase + construction through a
Client.extend()-style dynamic subclass (class extends _a {},_a.extend()factory),
matchingRedisClient 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 realdist/lib/client/index.js(all the_RedisClient_*WeakMap/WeakSet/method
names, in the same order), so_isolationPoollands 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
RedisClientMultiCommandviajs_super_construct_applywhileRedisClient'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_applyand siblings inclass_constructors.rs) specifically for classes
reached through an indirect/dynamicextends <expression>chain (there: a mixin function
extends WithSuffix(Logged); here:extends _awhere_ais 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-diagnow 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.- 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 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/clientships tsc's
downlevel emission, so the throw is tsc's own__classPrivateFieldGethelper, and there are no native#
fields anywhere in the compiled tree. - Then framed as
WeakMap.has(receiver)returningfalsefor a legitimately-added instance. True but
downstream — the WeakMap lookup fails because the value it is handed is alreadyundefined. - 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 ofjs_class_capture_value(<RedisClient class_id>, 7)
at the crash point returnsundefined: 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=0and 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_applyand
its siblings, for a class reached through an indirectextends <expression>chain. #5957's case was a mixin
function; redis's isRedisClient.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
EventEmitterbase 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 onwip/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.- Originally filed (by me) as a native
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.
Found by a package-compilability probe:
redis(@redis/client) now compiles from real source but fails atruntime, and this is the single blocker.
@redis/client's classes use JS private (#) fields. Something in Perry's class handling loses the associationbetween an instance and the class that declared its private fields, so a legitimate
this.#xread on an instanceof 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
redisis 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:
instanceofagainst aClassExprFreshparent resolved by sharedclass_idrather than perevaluation, fixed by pinning the constructing class onto each instance.
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_classis the tool.Reproduce:
npm i redis, apackage.jsonwithperry.compilePackagesincludingredis,@redis/clientand its@redis/*siblings, then any client construction. The probe's harness is on perrymaster at/root/claude-pkgaudit-measure/pkgtest/redis_test.ts.