Skip to content

redis: EventEmitter validation throws "argument must be an instance of EventEmitter. Received type string ('error')" on connect #11042

Description

@proggeramlug

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>

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:

const { createClient } = require("redis");

async function main() {
  const client = createClient({ url: "redis://127.0.0.1:6379" });
  client.on("error", (err: any) => console.log("Redis Client Error", err));
  await client.connect();

  await client.set("key", "value");
  const value = await client.get("key");
  console.log("get key:", value);
}
main();
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.

Activity

  1. added
    package-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindings
    on Sep 22, 2026
  2. proggeramlug commented on Sep 23, 2026

    @proggeramlug
    ContributorAuthor

    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/main 2f854511e9, real redis@6.1.0 against a live server.

    The call chain, traced with temporary eprintln! instrumentation

    1. 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).
    2. 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.
    3. 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.
    4. 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_ID alone 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_dynamic is 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.

  3. proggeramlug commented on Sep 23, 2026

    @proggeramlug
    ContributorAuthor

    Root cause and fix: #11122.

    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:

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