Repository navigation
Closed
Labels
package-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
Description
Found by the package audit (compiling real npm packages from source instead of Perry's native bindings) on
Perry e6dcb62 (v0.5.1587), Linux x64. A mixin/factory such as function withCommands(Base) { return class extends Base {} }
works when it is declared in the entry module. Declared in any other module, every call returns the same class
object, re-parented to the most recent Base. Instances of an earlier result run the wrong parent constructor and
lose the parent's prototype.
Reproduction
factory.ts
export function withCommands(Base: any) { return class extends Base {}; }main.ts
import { withCommands } from "./factory.ts";
class A { constructor() { console.log(" A constructor"); } hello() { return "A.hello"; } }
class B { constructor() { console.log(" B constructor"); } }
function localWithCommands(Base: any) { return class extends Base {}; }
const LocalA = localWithCommands(A), LocalB = localWithCommands(B);
const la = new LocalA();
console.log("factory in main.ts: LocalA !== LocalB:", LocalA !== LocalB, "| instanceof A:", la instanceof A, "| typeof hello:", typeof la.hello);
const ClientA = withCommands(A), ClientB = withCommands(B);
const ca = new ClientA();
console.log("factory in factory.ts: ClientA !== ClientB:", ClientA !== ClientB, "| instanceof A:", ca instanceof A, "| typeof hello:", typeof ca.hello);node main.ts
PERRY_NO_AUTO_OPTIMIZE=1 perry compile main.ts -o out && ./out # same output with default auto-optimizeExpected (Node 26.5.1)
A constructor
factory in main.ts: LocalA !== LocalB: true | instanceof A: true | typeof hello: function
A constructor
factory in factory.ts: ClientA !== ClientB: true | instanceof A: true | typeof hello: function
Actual (Perry)
A constructor
factory in main.ts: LocalA !== LocalB: true | instanceof A: true | typeof hello: function
B constructor
factory in factory.ts: ClientA !== ClientB: false | instanceof A: false | typeof hello: undefined
Impact
- redis 6.1.0 / @redis/client 6.1.0:
attachConfig(dist/lib/commander.js:5-8,Class = class extends BaseClass {})
is called fromRedisClient.factorywithBaseClass: RedisClient(dist/lib/client/index.js:98). It is called
again right away withBaseClass: RedisClientMultiCommandthroughClient.prototype.Multi = multi_command_1.default.extend(config)
(index.js:107→dist/lib/client/multi-command.js:54). The laternew Client(options)(index.js:112) then runs
theRedisClientMultiCommandconstructor instead ofRedisClient's.createClient()returns an object that is not
instanceof RedisClientand has noconnect, soclient.connect()fails. The same factory also serves
client/pool.js:62,cluster/index.js:83,cluster/multi-command.js:58andsentinel/index.js:70,239. - Any mixin/factory-class pattern in a library module is affected. Mixins are common in ORMs, Effect/zod-style
$constructorfactories and command builders.
Notes
- Reached on redis only after working around Class methods nested in a function or CommonJS module see a stale snapshot of enclosing
vars assigned after the class (TypeScriptvar _a; … _a = Xemit) #10485 (the TS_aalias in static methods). With that workaround,
instrumentingRedisClient.factoryshowed thatattachConfigreturned a class whosegetPrototypeOfis
RedisClient, butnew Client(options)never entered theRedisClientconstructor and produced
instanceof RedisClient === false. A 3-file CJS model of the redis code (commander.cjs/client.cjs/multi.cjs)
printsMultiCommand ctor ranunder Perry andRedisClient ctor ranunder Node. - It does not depend on CJS vs TS, destructured (
({ BaseClass })) vs positional parameters, or whether the classes
have fields or constructors. The only deciding factor found is that the factory is declared outside the entry
module: a factory in a non-entry.cjsmodule fails the same way, while the same factory in the entry module works. new ClientA()is correct if it runs before the secondwithCommands(B)call (instanceof A: true,A constructor).
The same expression run after that call givesB constructor/instanceof A: false, and
Object.getPrototypeOf(ClientA) === Aturnsfalse. The class object is re-parented in place.- Suspected cause (inferred, not instrumented): in non-entry modules the class expression is not lowered as a fresh
per-evaluation class object (ClassExprFresh). It stays a single hoisted template class, whose dynamic parent is
re-registered on every evaluation (js_register_class_parent_dynamic, which overwritesCLASS_DYNAMIC_PARENT_VALUE
keyed by class id:crates/perry-runtime/src/object/class_registry/parent_static.rs:90-110).super()resolves
through that table (js_fetch_or_value_super,crates/perry-runtime/src/object/global_this/fetch_globals.rs:642). - Related closed issues: perry-hir: class expressions hoisted to a single shared class — no per-evaluation identity or static-field state (blocks effect Schema #1758) #1772 (class expressions hoisted to a single shared class with no per-evaluation identity;
this looks like the non-entry-module remainder or a regression), Mixin factory that directly returns a dynamic-parent class expression loses its binding (regression) #5952, Two levels of dynamic parent (mixin of a mixin) with an own constructor SIGSEGVs #9079, SIGSEGV: factory returningclass D extends <param>, chained two levels, then instantiated (zod v4 $constructor shape) #9364.
Metadata
Metadata
Assignees
Labels
package-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
Activity