Repository navigation
redis: EventEmitter validation throws "argument must be an instance of EventEmitter. Received type string ('error')" on connect #11042
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 22, 2026 Root mechanism verified empirically via instrumentation, not hypothesised. No fix yet — the investigating session was cut off by local disk exhaustion.
Confirmed still reproducing on
origin/main2f854511e9, realredis@6.1.0against a live server.The call chain, traced with temporary
eprintln!instrumentation- HIR lowers
client.on("error", cb)as a fully genericCall{ callee: PropertyGet{ object: LocalGet(client), property: "on" } }— never aNativeMethodCall. It reachescrate::object::js_native_call_method(native_call_method.rs). - There, the "native-module namespace reached as a dynamic value" fast path fires, because the receiver's
ObjectHeader.class_idis0xfffffffe—NATIVE_MODULE_CLASS_ID, the sentinelnative_module.rs:604reserves exclusively for synthetic required-native-module namespace objects. A redis client object is carrying it. - That routes to
dispatch_native_module_method, which reads field 0 as a module-name string — it decoded to"events"— and callsnm_dispatch_events→js_events_native_dispatch("on", args)→js_events_on(args[0], args[1], args[2])with the caller's own arguments and the receiver never consulted. js_events_onvalidates its first parameter as anEventEmitterand receives the string"error"(confirmed:target_bitscarriesSTRING_TAG). Hence the reported error.
The defect is generic, not redis-specific
native_call_method.rstreatsclass_id == NATIVE_MODULE_CLASS_IDalone as proof that an object is a genuine native-module namespace, then feeds the caller's method-call arguments straight into the matching receiver-less static free function. Any object incorrectly carrying that class_id will misroute for any ofevents.on/once/listenerCount/getMaxListeners/setMaxListeners/getEventListeners/addAbortListener— theROUTED_TO_STDLIB_BRIDGEset — not just.on.Ruled out: GC and rooting
Despite superficially matching the stale-pointer-after-move family, this is deterministic. The same crash is byte-identical under
PERRY_GEN_GC=0(full mark-sweep, no moving collector) and underPERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1(122 forced collections, 136,810 objects moved). A compile-time/logic defect, not a rooting bug.Where the bad class_id comes from — narrowed, not pinned
RedisClient.factory()→attachConfig({BaseClass: _a, …})in@redis/client/dist/lib/commander.js, which doesconst RESP = …, Class = class extends BaseClass {};— a comma-declarator anonymous class expression with fully dynamic heritage (BaseClassis a parameter) — thenreturn (options) => Object.create(new Client(options));. Observed:js_register_class_parent_dynamicis emitted for"Class", with a normal small class_id.js_object_mark_class— which tags a valueShapeObjectKind::Class, required fornew'sis_class_object_valuefast path — is never called for it.- An unfiltered dump of every
Expr::ClassExprFreshacross all 615 compiled modules (31 total nodes) contains no entry for"Class". Its value never goes through that lowering at all, unlike every other named class expression in the package includingRedisClientitself.
Minimal reproduction failed — stated explicitly
Seven hand-built variants all matched Node exactly: bare
class X extends node_events_1.EventEmitter,Object.create(new X()), a two-levelattachConfig-shaped dynamic-heritage factory, in both CJS-wrap and ESM entry forms. The trigger needs something specific to the real@redis/clientgraph — most plausibly the memoized-factory cache, the comma-operator anonymous class expression, and a module-levelrequire("node:events")namespace singleton all being live in the same module. Same shape as #10758, where a synthetic reproduction of the identical pattern also resolved correctly.Housekeeping for whoever resumes
Debug-only
eprintln!instrumentation is left uncommitted on the host clone/root/claude-fix-11042—git checkout --these before reuse:perry-ext-events/src/module_on.rs,perry-runtime/src/object/{native_call_method,native_module_dispatch}.rs,perry-runtime/src/object/class_registry/construct.rs, andperry-codegen/src/expr/static_field_meta.rs(currently unfiltered, prints for everyClassExprFresh). Intermediate probe binaries, logs and.lltraces are at/root/claude-fix-11042-repro.- HIR lowers
- added a commit that references this issue
on Sep 23, 2026 Root cause and fix: #11122.
attachConfig'sClass = class extends BaseClass {}was one shared class across evaluations.RedisClient.factoryevaluates it a second time (forRedisClientMultiCommand) beforenew Client(options)runs, so the client ran the Multi class's constructor and lost its EventEmitter ancestry..onthen resolved toevents.on(emitter, name). So theNATIVE_MODULE_CLASS_IDreceiver was a genuineeventsnamespace object, and the fast path was correct for what it received.With #11122,
redis@6.1.0gets pastclient.on("error", …). It then stops on two independent, pre-existing defects:- Subclass field initializer 'new events_1()' with a private-field parent throws 'Cannot initialize a private field twice' (blocks redis createClient) #11120:
createClient({ socket })fails withCannot initialize a private field twice on the same object. The trigger islinked-list.js'sevents = new events_1.default()over a parent with private fields. It has a package-free repro. - redis: RedisClient.parseURL throws 'Value of URL.prototype.hostname called on an incompatible receiver' (new node_url_1.URL(...) in @redis/client) #11121:
createClient({ url })fails inRedisClient.parseURLwithValue of URL.prototype.hostname called on an incompatible receiver. It reproduces with no server.
- Subclass field initializer 'new events_1()' with a private-field parent throws 'Cannot initialize a private field twice' (blocks redis createClient) #11120:
- added a commit that references this issue
on Sep 23, 2026
redis@6.1.0's original compile blocker (#10660, class-capture slot never rebound) is fixed — the package now compiles from real source (perry.compilePackages: ["redis", "@redis/client", "@redis/bloom", "@redis/json", "@redis/search", "@redis/time-series"]) and links. Running the compiled binary against a live redis server throws immediately:Node runs the same fixture cleanly end-to-end.
Reproduction
{ "dependencies": { "redis": "6.1.0" }, "perry": { "compilePackages": ["redis", "@redis/client", "@redis/bloom", "@redis/json", "@redis/search", "@redis/time-series"], "allow": { "compilePackages": ["redis", "@redis/client", "@redis/bloom", "@redis/json", "@redis/search", "@redis/time-series"] } } }fixture.ts:Expected (Node 26.5.1)
(plus further hSet/rPush/incr output in the full fixture — the point is it never throws)
Actual (Perry, current main)
Notes
events.once(emitter, name)argument-validation message exactly, but a grep of@redis/client's shipped JS forevents.once(/ a destructured{ once }from"events"turned up nothing — the only.once(call site in the client is an ordinaryEventEmitter.prototype.onceinstance-method call (commands-queue.js:this.#waitingForReply.events.once("empty", onEmpty)), which would not produce this message. That suggests the throw is not coming from redis's own call sites but from Perry'seventsmodule emulation (or thenet/socket setup path) internally invoking somethingonce-shaped with the "emitter" and event-name arguments swapped or shifted, given the received value is the literal string'error'— the event name redis's own client registers viaclient.on("error", ...)early increateClient/connect().