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: EventEmitter validation throws "argument must be an instance of EventEmitter. Received type string ('error')" on connect #11042
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:
redis version: 6.1.0
Uncaught (in promise) TypeError: The "emitter" argument must be an instance of EventEmitter. Received type string ('error')
at <anonymous>
npm install
perry compile fixture.ts -o fixture && ./fixture # against a live redis server
Expected (Node 26.5.1)
get key: value
(plus further hSet/rPush/incr output in the full fixture — the point is it never throws)
Actual (Perry, current main)
Uncaught (in promise) TypeError: The "emitter" argument must be an instance of EventEmitter. Received type string ('error')
Notes
The error text matches Node's events.once(emitter, name) argument-validation message exactly, but a grep of @redis/client's shipped JS for events.once( / a destructured { once } from "events" turned up nothing — the only .once( call site in the client is an ordinary EventEmitter.prototype.once instance-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's events module emulation (or the net/socket setup path) internally invoking something once-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 via client.on("error", ...) early in createClient/connect().
Not yet bisected to an exact call site; filing as a reproduction for triage.
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, real redis@6.1.0 against a live server.
The call chain, traced with temporary eprintln! instrumentation
HIR lowers client.on("error", cb) as a fully generic Call{ callee: PropertyGet{ object: LocalGet(client), property: "on" } } — never a NativeMethodCall. It reaches crate::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_id is 0xfffffffe — NATIVE_MODULE_CLASS_ID, the sentinel native_module.rs:604 reserves 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 calls nm_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_on validates its first parameter as an EventEmitter and receives the string "error" (confirmed: target_bits carries STRING_TAG). Hence the reported error.
The defect is generic, not redis-specific
native_call_method.rs treats class_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 of events.on / once / listenerCount / getMaxListeners / setMaxListeners / getEventListeners / addAbortListener — the ROUTED_TO_STDLIB_BRIDGE set — 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 under PERRY_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 does const RESP = …, Class = class extends BaseClass {}; — a comma-declarator anonymous class expression with fully dynamic heritage (BaseClass is a parameter) — then return (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 value ShapeObjectKind::Class, required for new's is_class_object_value fast path — is never called for it.
An unfiltered dump of every Expr::ClassExprFresh across 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 including RedisClient itself.
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-level attachConfig-shaped dynamic-heritage factory, in both CJS-wrap and ESM entry forms. The trigger needs something specific to the real @redis/client graph — most plausibly the memoized-factory cache, the comma-operator anonymous class expression, and a module-level require("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, and perry-codegen/src/expr/static_field_meta.rs (currently unfiltered, prints for every ClassExprFresh). Intermediate probe binaries, logs and .ll traces are at /root/claude-fix-11042-repro.
attachConfig's Class = class extends BaseClass {} was one shared class across evaluations. RedisClient.factory evaluates it a second time (for RedisClientMultiCommand) before new Client(options) runs, so the client ran the Multi class's constructor and lost its EventEmitter ancestry. .on then resolved to events.on(emitter, name). So the NATIVE_MODULE_CLASS_ID receiver was a genuine events namespace object, and the fast path was correct for what it received.
With #11122, redis@6.1.0 gets past client.on("error", …). It then stops on two independent, pre-existing defects:
Activity
proggeramlug commentedon Sep 23, 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!instrumentationclient.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).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.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.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.fix: keep per-call-site factory specialization for bare fresh class e…
proggeramlug commentedon 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: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.createClient({ url })fails inRedisClient.parseURLwithValue of URL.prototype.hostname called on an incompatible receiver. It reproduces with no server.fix(hir): give each evaluation of a dynamic-heritage class expression…