Read x.constructor on EventEmitter and stream instances

`x.constructor` on a value typed as EventEmitter or a stream class
stopped at SC2020. The runtime now records each instance's Node class
name: fs streams as ReadStream and WriteStream, child process stdio as
Socket, worker stdio as ReadableWorkerStdio and WritableWorkerStdio, and
Hash/Hmac stream views as Hash and Hmac (zlib streams, the base classes
and program subclasses already did). The name is the one Node's leak
warning and argument-type errors print, so those messages now name the
same class as Node.

`x.constructor.name` reads that name (scr_emitter_class_name).
`x.constructor === C` for EventEmitter or a stream class tests the
instance's own class: C's interval, none of its direct subclasses', and
C's name, as fs, zlib, crypto, child process and worker streams share the
base classes' vtables. `x.constructor` as a value is the class object of
EventEmitter or the stream class the instance belongs to; for instances
of the other runtime classes and of program subclasses it reports SC1090
at run time.
This commit is contained in:
Chris Tate
2026-10-19 21:44:33 -05:00
parent 4bbf31b61f
commit 0b00ecfc23
17 changed files with 310 additions and 11 deletions
@@ -994,6 +994,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
// family have non-generic shapes (adapter/variadic) — special-cased in
// emitLibCall.
"emitter.new": "scr_emitter_new",
"emitter.className": "scr_emitter_class_name",
"emitter.ctor": "scr_emitter_init",
"emitter.off": "scr_emitter_off",
"emitter.offDyn": "scr_emitter_off_dyn",
@@ -0,0 +1,181 @@
import * as ts from "../../ts7/adapter.js";
import { type Lowerer } from "../lowerer.js";
import { type ClassInfo } from "../lower-classes.js";
import { isJsSourceFile, locOf } from "../../program.js";
import { varRef } from "../../../ir/build.js";
import { BOOL, STRING, type IrExpr, type IrType, type SrcLoc } from "../../../ir/ir.js";
/** `x.constructor` on an instance typed as EventEmitter or a stream class
* in a TypeScript file: the receiver's runtime ClassInfo, or null. The
* runtime records each instance's Node class name (EventEmitter, Readable,
* ReadStream, Gzip, Socket, a program subclass's name), which the
* lowerings below read with emitter.className. */
export function runtimeConstructorReceiver(
lowerer: Lowerer,
expr: ts.Expression,
): { receiver: ts.Expression; info: ClassInfo } | null {
let e = expr;
while (ts.isParenthesizedExpression(e)) e = e.expression;
if (!ts.isPropertyAccessExpression(e) || e.questionDotToken) return null;
if (e.name.text !== "constructor" || isJsSourceFile(e.getSourceFile())) return null;
const mapped = lowerer.mapTypeOf(lowerer.typeOf(e.expression));
if (mapped?.kind !== "object") return null;
const info = lowerer.classes.get(mapped.className);
if (!info?.def.runtime || (!info.builtinEmitter && info.builtinStream === undefined)) return null;
return { receiver: e.expression, info };
}
/** `x.constructor.name`: the instance's class name. */
export function lowerRuntimeConstructorName(
lowerer: Lowerer,
receiver: ts.Expression,
loc: SrcLoc,
): IrExpr {
return {
kind: "libCall",
fn: "emitter.className",
args: [lowerer.lowerExpr(receiver)],
type: STRING,
loc,
};
}
/** `x.constructor === C` for EventEmitter or a stream class C: true when
* the instance's own class is C, which needs both the vtable (C's own
* interval, not a subclass's) and the class name (fs, zlib, crypto, child
* process and worker streams share the base classes' vtables). */
export function lowerRuntimeConstructorIs(
lowerer: Lowerer,
receiver: ts.Expression,
target: ClassInfo,
negated: boolean,
loc: SrcLoc,
): IrExpr {
const value = lowerer.lowerExpr(receiver);
const local = lowerer.declareHiddenLocal("%constructorOf", value.type);
const ref = varRef(local.id, value.type, loc);
const test = exactClassTest(lowerer, ref, target, loc);
return {
kind: "seqExpr",
stmts: [{ kind: "varDecl", localId: local.id, init: value, loc }],
result: negated ? { kind: "unary", op: "!", operand: test, type: BOOL, loc } : test,
type: BOOL,
loc,
};
}
/** `x.constructor` as a value: the class object of the instance's own class
* among EventEmitter and the stream classes, as the receiver's static
* class's class value. Instances of the other runtime classes (fs, zlib,
* crypto, child process and worker streams) and of program subclasses
* report SC1090 at run time. */
export function lowerRuntimeConstructor(
lowerer: Lowerer,
access: ts.PropertyAccessExpression,
info: ClassInfo,
): IrExpr {
const loc = locOf(access);
const resultT: IrType = { kind: "classval", className: info.def.name };
const value = lowerer.lowerExpr(access.expression);
const local = lowerer.declareHiddenLocal("%constructorOf", value.type);
const ref = varRef(local.id, value.type, loc);
let result: IrExpr = refusalCall(lowerer, resultT, loc);
for (const candidate of valueClassesUnder(info)) {
lowerer.defineClassValueConstruct(candidate);
lowerer.defineClassValueConstruct(info);
const classRef: IrExpr = {
kind: "classRef",
className: candidate.def.name,
type: { kind: "classval", className: candidate.def.name },
loc,
};
result = {
kind: "ternary",
cond: exactClassTest(lowerer, ref, candidate, loc),
then: candidate === info ? classRef : { kind: "upcast", value: classRef, type: resultT, loc },
else_: result,
type: resultT,
loc,
};
}
return {
kind: "seqExpr",
stmts: [{ kind: "varDecl", localId: local.id, init: value, loc }],
result,
type: resultT,
loc,
};
}
/** `info` and its runtime descendants whose values construct. */
function valueClassesUnder(info: ClassInfo): ClassInfo[] {
const out: ClassInfo[] = [];
const visit = (c: ClassInfo): void => {
if (!c.def.runtime) return;
if (c.valueCtorParams !== undefined) out.push(c);
for (const s of c.subclasses) visit(s);
};
visit(info);
return out;
}
/** The instance is of class `target` itself: inside its interval, outside
* each direct subclass's (runtime or program), and named as it. */
function exactClassTest(lowerer: Lowerer, ref: IrExpr, target: ClassInfo, loc: SrcLoc): IrExpr {
let test: IrExpr = {
kind: "instanceOf",
value: ref,
className: target.def.name,
type: BOOL,
loc,
};
for (const sub of target.subclasses) {
test = {
kind: "logical",
op: "&&",
left: test,
right: {
kind: "unary",
op: "!",
operand: { kind: "instanceOf", value: ref, className: sub.def.name, type: BOOL, loc },
type: BOOL,
loc,
},
type: BOOL,
loc,
};
}
const name: IrExpr = {
kind: "strEq",
negated: false,
left: { kind: "libCall", fn: "emitter.className", args: [ref], type: STRING, loc },
right: { kind: "strLit", value: target.def.name.slice(1), type: STRING, loc },
type: BOOL,
loc,
};
return { kind: "logical", op: "&&", left: test, right: name, type: BOOL, loc };
}
/** The run-time refusal for an instance whose class has no class value. */
function refusalCall(lowerer: Lowerer, type: IrType, loc: SrcLoc): IrExpr {
const name = `%constructor.refusal:${type.kind === "classval" ? type.className : ""}`;
if (!lowerer.liftedFns.some((fn) => fn.name === name)) {
lowerer.liftedFns.push({
name,
params: [],
locals: [],
returnType: type,
body: [
{
kind: "runtimeFence",
code: "SC1090",
message:
"reading 'constructor' of an fs, zlib, crypto, child process or worker stream, or of an instance of a program subclass of EventEmitter or a stream class, is not supported yet",
loc,
},
],
loc,
});
}
return { kind: "call", callee: name, args: [], type, loc };
}
@@ -66,6 +66,10 @@ import { lowerAbstractEquality } from "../abstract-equality.js";
import { lowerInExpression } from "./relational.js";
import { lowerDynTypeofTest, lowerErrorCodeTypeofTest } from "./typeof.js";
import { builtinClassRefOf, sameBuiltinClass } from "../builtins/class-values.js";
import {
lowerRuntimeConstructorIs,
runtimeConstructorReceiver,
} from "../builtins/runtime-constructor.js";
import { tryLowerIndexedComparison } from "../indexed-comparison.js";
import { lowerUnionEquality, tagEqualityMayMissAlias } from "../strict-equality.js";
import { withUnitArm } from "../../type-mapper.js";
@@ -1322,6 +1326,24 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr
lowerUnionTypeofTest(lowerer, expr, loc);
if (test) return test;
}
// `x.constructor === Readable` for an instance of EventEmitter or a
// stream class: the instance's own class, compared without a class value.
if (
op === ts.SyntaxKind.EqualsEqualsEqualsToken ||
op === ts.SyntaxKind.ExclamationEqualsEqualsToken
) {
for (const [side, other] of [
[expr.left, expr.right],
[expr.right, expr.left],
] as const) {
const ctor = runtimeConstructorReceiver(lowerer, side);
const target = ctor ? builtinClassRefOf(lowerer, other) : null;
if (ctor && (target?.kind === "emitter" || target?.kind === "stream")) {
const negated = op === ts.SyntaxKind.ExclamationEqualsEqualsToken;
return lowerRuntimeConstructorIs(lowerer, ctor.receiver, target.info, negated, loc);
}
}
}
// `C === EventEmitter` between two builtin class references: the
// classes are compile-time values, so the answer folds.
if (
@@ -76,6 +76,11 @@ import { lifetimeRegionMemberOf } from "../../experimental-import.js";
import { lowerInternTableProperty } from "../intern-tables.js";
import { lowerThreadSlotsProperty } from "../thread-slots.js";
import { builtinClassRefOf, runtimeClassValueOfType } from "../builtins/class-values.js";
import {
lowerRuntimeConstructor,
lowerRuntimeConstructorName,
runtimeConstructorReceiver,
} from "../builtins/runtime-constructor.js";
import { findGenericMethodOn, lowerStaticFieldRead } from "../lower-classes.js";
import { lowerClassPrototypeData } from "../class-prototypes.js";
import { lowerEnumAccess } from "../lower-enums.js";
@@ -290,6 +295,16 @@ export function lowerPropertyAccessExpr(
const ref = builtinClassRefOf(lowerer, expr.expression);
if (ref !== null) return { kind: "strLit", value: ref.name, type: STRING, loc };
}
// `x.constructor.name` and `x.constructor` on an instance of
// EventEmitter or a stream class (builtins/runtime-constructor.ts).
if (expr.name.text === "name" && !expr.questionDotToken) {
const ctor = runtimeConstructorReceiver(lowerer, expr.expression);
if (ctor) return lowerRuntimeConstructorName(lowerer, ctor.receiver, loc);
}
{
const ctor = runtimeConstructorReceiver(lowerer, expr);
if (ctor) return lowerRuntimeConstructor(lowerer, expr, ctor.info);
}
// `C.name` through a runtime-provided class VALUE (a `typeof Readable`
// parameter): the class object's stored name.
if (
@@ -3499,6 +3499,9 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
* throws err when unhandled. count/countFn/names/listeners/getMax/
* setMax/setDefaultMax/getDefaultMax are the introspection surface. */
"emitter.new": { argTypes: [], result: VOID },
/** `x.constructor.name` of an emitter or stream (a borrowed object of
* EventEmitter's hierarchy): the +1 class name the instance records. */
"emitter.className": { argTypes: [null], result: STRING },
"emitter.ctor": { argTypes: [null], result: VOID },
"emitter.on": { argTypes: [null, STRING, null, BOOL, BOOL], result: VOID },
"emitter.off": { argTypes: [null, STRING, null], result: VOID },
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/5983-runtime-constructor.ts"
],
"diags": []
}
+2 -2
View File
@@ -6458,7 +6458,7 @@ static ScrDyn *scr_child_bridge_readable(ScrChildStream *reader, ScrDyn **cached
return NULL;
}
if (!*cached) {
ScrStream *stream = scr_child_stream_ops->readable();
ScrStream *stream = scr_child_stream_ops->readable("Socket");
scr_child_stream_on_data(reader, scr_child_bridge_callback(stream, (void *)scr_child_bridge_data),
scr_child_bridge_data, false);
scr_child_stream_on_end(reader, scr_child_bridge_callback(stream, (void *)scr_child_bridge_end), false);
@@ -6578,7 +6578,7 @@ static ScrDyn *scr_child_bridge_writable(ScrChildWriter *writer, ScrDyn **cached
return NULL;
}
if (!*cached) {
ScrStream *stream = scr_child_stream_ops->writable(
ScrStream *stream = scr_child_stream_ops->writable("Socket",
scr_child_writer_bridge_callback(writer, (void *)scr_child_bridge_write), scr_child_bridge_write,
scr_child_writer_bridge_callback(writer, (void *)scr_child_bridge_final), scr_child_bridge_final);
*cached = scr_child_writable_box(stream);
+6 -2
View File
@@ -253,9 +253,13 @@ void scr_child_dyn_install(void);
void scr_child_on_dyn(ScrChild *child, ScrStr *event, ScrDyn *listener); /* borrowed */
/* Optional native-stream bridge. The table avoids a stream dependency
* in programs that only use the child-process surface. */
/* readable/writable take the stream's class name (the constructor name
* Node reports: Socket for child stdio, ReadableWorkerStdio and
* WritableWorkerStdio for worker stdio). */
typedef struct ScrChildStreamOps {
ScrStream *(*readable)(void);
ScrStream *(*writable)(ScrClosure *, ScrStreamChunkInv, ScrClosure *, ScrStreamPlainInv);
ScrStream *(*readable)(const char *cls);
ScrStream *(*writable)(const char *cls, ScrClosure *, ScrStreamChunkInv, ScrClosure *,
ScrStreamPlainInv);
void *(*retain)(void *);
void (*release)(void *);
ScrTraceFn trace;
+1
View File
@@ -71,6 +71,7 @@ void scr_crypto_hash_stream_set(ScrCryptoHash *hash, void *stream, void (*releas
void scr_crypto_hash_stream_detach(void *hash);
void scr_crypto_hash_stream_close(ScrCryptoHash *hash);
bool scr_crypto_hash_stream_usable(const ScrCryptoHash *hash);
bool scr_crypto_hash_is_hmac(const ScrCryptoHash *hash);
void scr_crypto_hash_stream_hold(ScrCryptoHash *hash);
void scr_crypto_hash_stream_unhold(ScrCryptoHash *hash);
ScrStream *scr_crypto_hash_stream_piped(ScrCryptoHash *hash);
@@ -324,6 +324,13 @@ ScrEmitter *scr_emitter_new(void) {
return em;
}
/* x.constructor.name of an emitter, a stream, or an instance of an emitted
* subclass: the class name every allocation stamps (EventEmitter,
* Readable, ReadStream, Gzip, Socket, the program class's name). +1. */
ScrStr *scr_emitter_class_name(const ScrEmitter *em) {
return scr_str_new(em->cls, strlen(em->cls));
}
/* super() into the emitter prefix of an emitted subclass: the allocation
* already zeroed reg and stamped the concrete class's display name, so
* construction has nothing left to do — the call exists so the super()
@@ -38,6 +38,7 @@ extern SCR_TL ScrVt scr_emitter_vt; /* main() stamps pre/post */
extern SCR_TL double scr_emitter_default_max;
ScrEmitter *scr_emitter_new(void); /* +1, collector-headered */
ScrStr *scr_emitter_class_name(const ScrEmitter *em); /* +1 */
/* Internal owner hook; observes listener presence without running script. */
void scr_emitter_observe(ScrEmitter *em,
void (*observe)(void *, const ScrStr *, bool), void *context);
+1
View File
@@ -562,6 +562,7 @@ static ScrStream *scr_fss_new(const ScrDyn *path, const ScrDyn *opts_in, bool re
scr_dyn_release(eo);
}
if (!s) goto fail;
s->cls = readable ? "ReadStream" : "WriteStream";
bool unknown_encoding = false;
if (!readable) {
/* setDefaultEncoding(options.encoding) after the Writable constructor:
+2
View File
@@ -8473,6 +8473,8 @@ void scr_crypto_hash_stream_close(ScrCryptoHash *hash) {
if (stream) stream_release(stream);
}
bool scr_crypto_hash_is_hmac(const ScrCryptoHash *hash) { return hash->hmac; }
bool scr_crypto_hash_stream_usable(const ScrCryptoHash *hash) {
if (!hash->stream_closed) return true;
static const char msg[] = "Stream use of a Hash or Hmac after its stream closed has no lowering";
+10 -5
View File
@@ -6350,15 +6350,19 @@ void scr_stream_install(void) {
static void scr_stream_child_read(ScrClosure *cb, ScrStream *s, double size) {
(void)cb; (void)s; (void)size;
}
static ScrStream *scr_stream_child_readable(void) {
return scr_stream_new_readable(-1, true, true,
static ScrStream *scr_stream_child_readable(const char *cls) {
ScrStream *s = scr_stream_new_readable(-1, true, true,
scr_closure_new((void *)scr_stream_child_read, 0), scr_stream_child_read,
NULL, NULL);
s->cls = cls;
return s;
}
static ScrStream *scr_stream_child_writable(ScrClosure *write, ScrStreamChunkInv write_inv,
ScrClosure *final_cb, ScrStreamPlainInv final_inv) {
return scr_stream_new_writable(-1, true, true, write, write_inv,
static ScrStream *scr_stream_child_writable(const char *cls, ScrClosure *write,
ScrStreamChunkInv write_inv, ScrClosure *final_cb, ScrStreamPlainInv final_inv) {
ScrStream *s = scr_stream_new_writable(-1, true, true, write, write_inv,
final_cb, final_inv, NULL, NULL);
s->cls = cls;
return s;
}
const ScrChildStreamOps scr_stream_child_ops = {
scr_stream_child_readable, scr_stream_child_writable,
@@ -6578,6 +6582,7 @@ ScrStream *scr_crypto_hash_stream(ScrCryptoHash *hash) {
scr_hash_stream_step(box), scr_hash_stream_transform,
scr_hash_stream_step(box), scr_hash_stream_flush,
scr_hash_stream_step(box), scr_hash_stream_destroy);
s->cls = scr_crypto_hash_is_hmac(hash) ? "Hmac" : "Hash";
/* The steps keep the box alive as long as the stream. */
scr_crypto_hash_stream_set(hash, scr_stream_retain(s), scr_stream_release_v, box);
scr_box_release(box);
+3 -2
View File
@@ -122,7 +122,7 @@ static ScrClosure *scr_worker_stdin_callback(ScrWorkerThread *thread, void *fire
}
static ScrStream *scr_worker_stdin_new(ScrWorkerThread *thread) {
return scr_worker_stdio_ops->writable(
return scr_worker_stdio_ops->writable("WritableWorkerStdio",
scr_worker_stdin_callback(thread, (void *)scr_worker_stdin_write), scr_worker_stdin_write,
scr_worker_stdin_callback(thread, (void *)scr_worker_stdin_final), scr_worker_stdin_final);
}
@@ -1350,7 +1350,8 @@ static ScrDyn *scr_worker_new_options(double root, ScrStr *filename, ScrDyn *opt
for (int i = 0; i < 2; i++) {
view->stdio_captured[i] = (capture >> i) & 1;
/* without the stream unit nothing can read the stream: the output is dropped */
if (view->stdio_captured[i] && scr_worker_stdio_ops) view->stdio[i] = scr_worker_stdio_ops->readable();
if (view->stdio_captured[i] && scr_worker_stdio_ops)
view->stdio[i] = scr_worker_stdio_ops->readable("ReadableWorkerStdio");
}
view->stdin_captured = capture & 4;
if (view->stdin_captured && scr_worker_stdio_ops) view->stdin_stream = scr_worker_stdin_new(thread);
+48
View File
@@ -0,0 +1,48 @@
// x.constructor on instances of EventEmitter and the stream classes:
// .name is the class Node reports (fs, zlib and program subclasses
// included), === compares with the instance's own class, and the value
// is the class object for EventEmitter and the stream classes.
import { EventEmitter } from "node:events";
import { Duplex, PassThrough, Readable, Transform, Writable } from "node:stream";
import * as fs from "node:fs";
import * as zlib from "node:zlib";
class Bus extends EventEmitter {}
const emitter = new EventEmitter();
const bus: EventEmitter = new Bus();
console.log("emitter", emitter.constructor === EventEmitter, emitter.constructor.name);
console.log("subclass", bus.constructor === EventEmitter, bus.constructor.name);
const streams: Readable[] = [new Readable(), new Duplex(), new Transform(), new PassThrough()];
for (const s of streams) {
console.log(
"stream",
s.constructor.name,
s.constructor === Readable,
s.constructor === Duplex,
s.constructor === Transform,
s.constructor === PassThrough,
s.constructor !== Readable,
);
}
const from = Readable.from(["a"]);
console.log("from", from.constructor.name, from.constructor === Readable);
const readStream: Readable = fs.createReadStream(import.meta.filename);
const writeStream: Writable = fs.createWriteStream("/dev/null");
console.log("fs", readStream.constructor.name, readStream.constructor === Readable);
console.log("fs", writeStream.constructor.name, writeStream.constructor === Writable);
readStream.destroy();
writeStream.destroy();
const gzip: Transform = zlib.createGzip();
const inflate: Transform = zlib.createInflateRaw();
console.log("zlib", gzip.constructor.name, inflate.constructor.name, gzip.constructor === Transform);
const kinds: EventEmitter[] = [new EventEmitter(), new Writable(), new PassThrough()];
for (const k of kinds) {
const Kind = k.constructor as typeof EventEmitter;
console.log("value", Kind.name, Kind === EventEmitter, k instanceof Kind, new Kind() instanceof EventEmitter);
}
@@ -0,0 +1 @@
{ "plain": 3500, "san": 6900 }