From d5688d7dc5efc2f1449aca0f16f634606f507443 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 21 Oct 2026 22:02:39 -0500 Subject: [PATCH 1/7] Give shared helpers the location of their earliest use --- .../compiler/src/frontend/lowering/lowerer.ts | 100 ++++++++++++++---- .../coverage-explain-dynamic.txt | 2 +- 2 files changed, 79 insertions(+), 23 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 870e21b1f..ee8ba7ecb 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -36,7 +36,8 @@ import { import { identityPreservingWidening } from "./coercions/identity.js"; import { scanDivergences } from "./divergences.js"; import { applyKeyOrderWitnesses, KeyOrderNotes } from "./key-order-witness.js"; -import { observeRecordLiteralRebuilds } from "../../ir/traverse.js"; +import { exprFunctionReference } from "../../ir/function-references.js"; +import { everyStmtList, observeRecordLiteralRebuilds } from "../../ir/traverse.js"; import { checkedClassAssertion } from "./class-assertions.js"; import { narrowStoredClassValue, narrowGenericClassValue } from "./class-unions.js"; import { @@ -3266,11 +3267,13 @@ export class Lowerer { if (typeof helper === "string") this.sharedHelperNames.add(helper); else this.sharedHelpers.add(helper); } - /** Gives each shared helper the location of the entry file's start: the - * helpers marked by shareHelper, and those interned by key in the helper - * tables. */ - private settleSharedHelperLocations(): void { - const loc: SrcLoc = { file: this.entry.fileName, start: 0, end: 0 }; + /** Gives each shared helper (those marked by shareHelper, and those + * interned by key in the helper tables) one location for all its nodes: + * the earliest site in `functions` that uses it, by file and position, so + * reports such as --explain-dynamic point at a use whatever order bodies + * lowered in. A helper used only by other shared helpers takes their + * location; one with no use takes the entry file's start. */ + private settleSharedHelperLocations(functions: readonly IrFunction[]): void { const interned = new Set([...this.sharedHelperNames, ...this.classMethodValueHelpers]); for (const table of [ this.equalityHelpers, @@ -3293,21 +3296,68 @@ export class Lowerer { for (const name of table.values()) interned.add(name); for (const fn of [...this.liftedFns, ...this.implicitFns]) if (interned.has(fn.name)) this.sharedHelpers.add(fn); - const seen = new Set(); - const visit = (node: unknown): void => { - if (typeof node !== "object" || node === null || seen.has(node)) return; - seen.add(node); - if (Array.isArray(node)) { - for (const item of node) visit(item); - return; + if (this.sharedHelpers.size === 0) return; + const shared = new Map([...this.sharedHelpers].map((fn) => [fn.name, fn])); + const earlier = (left: SrcLoc, right: SrcLoc): boolean => + left.file < right.file || (left.file === right.file && left.start < right.start); + // The earliest use of each shared helper outside the shared helpers, and + // which shared helpers each shared helper uses. + const uses = new Map(); + const usedBy = new Map>(); + for (const fn of functions) { + const inShared = shared.has(fn.name) ? fn.name : undefined; + everyStmtList(fn.body, { + stmt: () => true, + expr: (expr) => { + const name = expr.kind === "call" ? expr.callee : exprFunctionReference(expr); + if (name === undefined || name === inShared || !shared.has(name)) return true; + if (inShared !== undefined) { + let users = usedBy.get(name); + if (users === undefined) usedBy.set(name, (users = new Set())); + users.add(inShared); + } else { + const current = uses.get(name); + if (current === undefined || earlier(expr.loc, current)) uses.set(name, expr.loc); + } + return true; + }, + }); + } + // Helpers reached only through other shared helpers take the earliest + // location among those, to a fixed point. + for (let changed = true; changed;) { + changed = false; + for (const [name, users] of usedBy) { + for (const user of users) { + const loc = uses.get(user); + const current = uses.get(name); + if (loc !== undefined && (current === undefined || earlier(loc, current))) { + uses.set(name, loc); + changed = true; + } + } } - const record = node as Record; - for (const key of Object.keys(record)) { - if (key === "loc") record[key] = loc; - else if (key !== "type") visit(record[key]); - } - }; - for (const fn of this.sharedHelpers) visit(fn); + } + const fallback: SrcLoc = { file: this.entry.fileName, start: 0, end: 0 }; + for (const fn of this.sharedHelpers) { + const use = uses.get(fn.name) ?? fallback; + const loc: SrcLoc = { file: use.file, start: use.start, end: use.end }; + const seen = new Set(); + const visit = (node: unknown): void => { + if (typeof node !== "object" || node === null || seen.has(node)) return; + seen.add(node); + if (Array.isArray(node)) { + for (const item of node) visit(item); + return; + } + const record = node as Record; + for (const key of Object.keys(record)) { + if (key === "loc") record[key] = loc; + else if (key !== "type") visit(record[key]); + } + }; + visit(fn); + } } /** The first lifted function named `name`, the one * `liftedFns.find((fn) => fn.name === name)` returns. */ @@ -7401,7 +7451,7 @@ export class Lowerer { // Lambdas lifted while lowering any of the above (plus synthetic // array-HOF loop functions, which ride the same list), and the // implicit-any instances lowered eagerly at their first call sites. - this.settleSharedHelperLocations(); + this.settleSharedHelperLocations([...functions, ...this.liftedFns, ...this.implicitFns]); functions.push(...this.liftedFns); functions.push(...this.implicitFns); functions.push(...runtimeClassConstructs(this, functions)); @@ -8116,7 +8166,13 @@ export class Lowerer { ]); observeRecordLiteralRebuilds(null); for (const finalize of this.shapeOrderHelperFinalizers) finalize(); - this.settleSharedHelperLocations(); + this.settleSharedHelperLocations([ + ...orderedUnits, + ...initFunctions, + ...instanceFunctions, + ...this.liftedFns, + ...this.implicitFns, + ]); // Lifted and implicit functions are created in lowering order; they // are assembled by name. const functions = [ diff --git a/tests/harness/__snapshots__/coverage-explain-dynamic.txt b/tests/harness/__snapshots__/coverage-explain-dynamic.txt index f168988e6..6e561634d 100644 --- a/tests/harness/__snapshots__/coverage-explain-dynamic.txt +++ b/tests/harness/__snapshots__/coverage-explain-dynamic.txt @@ -1,8 +1,8 @@ dynamic tier (tier assignment, report-only; --tier=auto) 4 functions already operate on dynamic values (1 statement) readName tests/coverage-fixtures/tier-demotions.ts:15:1: %dyn.error.stringConstructor at tests/coverage-fixtures/tier-demotions.ts:17:10 - %dyn.error.stringConstructor.0 tests/coverage-fixtures/tier-demotions.ts:17:10: dynCall at tests/coverage-fixtures/tier-demotions.ts:17:10 %dyn.class.property.0 tests/coverage-fixtures/tier-demotions.ts:17:17: %dyn.class.readPropertiesOrEmpty at tests/coverage-fixtures/tier-demotions.ts:17:17 + %dyn.error.stringConstructor.0 tests/coverage-fixtures/tier-demotions.ts:17:10: dynCall at tests/coverage-fixtures/tier-demotions.ts:17:10 %method.value:%%Error.toString tests/coverage-fixtures/tier-demotions.ts:17:10: dyn.this at tests/coverage-fixtures/tier-demotions.ts:17:10 2 demotion requests (refusals and fences the dynamic tier would replace) From fc551c4a8bf8fad4294d7de85d1a045005309d39 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 20 Oct 2026 19:25:56 -0700 Subject: [PATCH 2/7] Key weak collections by net sockets and servers, and carry the socket in undici client diagnostics Co-Authored-By: Claude Opus 5.5 --- packages/compiler/shims/undici/core/client.ts | 8 +- packages/compiler/src/ir/ir.ts | 4 +- .../corpus/6620-weak-keys-net-sockets.ts.json | 6 ++ packages/runtime/src/scr_json.c | 3 +- packages/runtime/src/scr_net.c | 2 + tests/corpus/6620-weak-keys-net-sockets.ts | 81 +++++++++++++++++++ tests/fixtures/npm/cases/undici5/main.ts | 35 +++++++- tests/fixtures/npm/cases/undici7/main.ts | 35 +++++++- 8 files changed, 166 insertions(+), 8 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6620-weak-keys-net-sockets.ts.json create mode 100644 tests/corpus/6620-weak-keys-net-sockets.ts diff --git a/packages/compiler/shims/undici/core/client.ts b/packages/compiler/shims/undici/core/client.ts index f391e6d27..ddc1ae22e 100644 --- a/packages/compiler/shims/undici/core/client.ts +++ b/packages/compiler/shims/undici/core/client.ts @@ -370,7 +370,7 @@ export class Client extends DispatcherBase { localAddress: this.__localAddress, }; if (channels.beforeConnect.hasSubscribers) { - channels.beforeConnect.publish({ connectParams }); + channels.beforeConnect.publish({ connectParams, connector: this.__connector }); } let settled = false; const onConnected = (err: Error | null, socket: Socket | null): void => { @@ -428,7 +428,7 @@ export class Client extends DispatcherBase { if (this.__socket === socket) this.__onSocketClose(socket); }); if (channels.connected.hasSubscribers) { - channels.connected.publish({ connectParams }); + channels.connected.publish({ connectParams, connector: this.__connector, socket }); } this.emit("connect", this.__url, [this as Dispatcher]); this.__resume(false); @@ -447,7 +447,7 @@ export class Client extends DispatcherBase { ): void { this.__connecting = false; if (channels.connectError.hasSubscribers) { - channels.connectError.publish({ connectParams, error: err }); + channels.connectError.publish({ connectParams, connector: this.__connector, error: err }); } this.__onError(err); this.emit("connectionError", this.__url, [this as Dispatcher], err); @@ -658,7 +658,7 @@ export class Client extends DispatcherBase { } header += request.headers; if (channels.sendHeaders.hasSubscribers) { - channels.sendHeaders.publish({ request: request.diagnosticView(), headers: header }); + channels.sendHeaders.publish({ request: request.diagnosticView(), headers: header, socket }); } if (body === null) { if (contentLength === 0) socket.write(`${header}content-length: 0\r\n\r\n`); diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 3c89d4009..6ffba9cd3 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -4331,7 +4331,9 @@ function canBoxDynCompositeAt( } } default: - return false; + // Runtime handles (a socket in a diagnostics message) box by + // reference wherever the walker meets them, as at the top level. + return DYN_HANDLE_KINDS.has(t.kind); } } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6620-weak-keys-net-sockets.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6620-weak-keys-net-sockets.ts.json new file mode 100644 index 000000000..f5ef58606 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6620-weak-keys-net-sockets.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6620-weak-keys-net-sockets.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index d9c1e5fab..e6b468699 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -13769,7 +13769,8 @@ static int scr_weak_key(const ScrDyn *key, void **ptr, unsigned *kind) { if (key->v.handle.tag != SCR_DYNH_ARRAY_BUFFER && key->v.handle.tag != SCR_DYNH_SHARED_ARRAY_BUFFER && key->v.handle.tag != SCR_DYNH_WEAK_MAP && key->v.handle.tag != SCR_DYNH_WEAK_SET && key->v.handle.tag != SCR_DYNH_STDIO && key->v.handle.tag != SCR_DYNH_FETCH_REQUEST && key->v.handle.tag != SCR_DYNH_FETCH_RESPONSE && - key->v.handle.tag != SCR_DYNH_MAP && key->v.handle.tag != SCR_DYNH_SET) return -1; + key->v.handle.tag != SCR_DYNH_MAP && key->v.handle.tag != SCR_DYNH_SET && + key->v.handle.tag != SCR_DYNH_NET_SOCKET && key->v.handle.tag != SCR_DYNH_NET_SERVER) return -1; *ptr = key->v.handle.ptr; *kind = 4 + key->v.handle.tag; return 1; case SCR_DYN_TYPED_REF: if (!key->v.typed_ref.observed) return -1; diff --git a/packages/runtime/src/scr_net.c b/packages/runtime/src/scr_net.c index 765b59a05..8f51f7a60 100644 --- a/packages/runtime/src/scr_net.c +++ b/packages/runtime/src/scr_net.c @@ -678,6 +678,7 @@ void scr_net_server_release(ScrNetServer *s) { #ifdef SCR_RC_AUDIT scr_net_live--; #endif + scr_weak_dispose_inline(s); /* weak collections keyed by this socket or server */ free(s); } } @@ -724,6 +725,7 @@ void scr_net_sock_release(ScrNetSocket *s) { #ifdef SCR_RC_AUDIT scr_net_live--; #endif + scr_weak_dispose_inline(s); /* weak collections keyed by this socket or server */ free(s); } } diff --git a/tests/corpus/6620-weak-keys-net-sockets.ts b/tests/corpus/6620-weak-keys-net-sockets.ts new file mode 100644 index 000000000..65878887e --- /dev/null +++ b/tests/corpus/6620-weak-keys-net-sockets.ts @@ -0,0 +1,81 @@ +// Sockets and servers key WeakMaps and WeakSets by identity: the socket a +// connect callback sees, the one a 'connection' listener sees, and the +// same socket read back out of a diagnostics_channel message are one key. +// Entries of closed sockets stop answering once the socket is gone. +import * as net from "node:net"; +import type { Server, Socket } from "node:net"; +import { channel } from "node:diagnostics_channel"; + +interface Conn { + label: string; + bytes: number; +} + +const connections = new WeakMap(); +const seen = new WeakSet(); +const servers = new WeakMap(); +const opened = channel("example:socket:opened"); + +opened.subscribe((message) => { + const { socket, label } = message as { socket: Socket; label: string }; + console.log(`[diag] ${label} known ${connections.has(socket)}, seen ${seen.has(socket)}`); + const conn = connections.get(socket); + if (conn !== undefined) conn.label = `${conn.label}+diag`; +}); + +// Each dial waits for both ends to close, so the two sides log in order. +let serverClosed: () => void = () => {}; + +const server = net.createServer((socket) => { + seen.add(socket); + connections.set(socket, { label: "server side", bytes: 0 }); + socket.on("data", (chunk) => { + const conn = connections.get(socket); + if (conn !== undefined) conn.bytes += chunk.length; + socket.end(`echo ${chunk.toString()}`); + }); + socket.on("close", () => { + const conn = connections.get(socket); + console.log("server socket closed", conn?.label, conn?.bytes); + console.log("delete", connections.delete(socket), connections.delete(socket)); + serverClosed(); + }); +}); +servers.set(server, "echo server"); + +function dial(port: number, label: string): Promise { + const bothClosed = new Promise((resolve) => { + serverClosed = resolve; + }); + const clientClosed = new Promise((resolve) => { + const client: Socket = net.connect(port, "127.0.0.1", () => { + connections.set(client, { label, bytes: 0 }); + console.log(label, "connected; has", connections.has(client), "seen", seen.has(client)); + opened.publish({ socket: client, label }); + client.write(label); + }); + let reply = ""; + client.on("data", (chunk) => { + reply += chunk.toString(); + }); + client.on("close", () => { + console.log(label, "reply", JSON.stringify(reply), "entry", connections.get(client)?.label); + resolve(); + }); + }); + return Promise.all([clientClosed, bothClosed]).then(() => undefined); +} + +server.listen(0, "127.0.0.1", async () => { + console.log("server known", servers.has(server), servers.get(server)); + const address = server.address(); + const port = typeof address === "object" && address !== null ? address.port : 0; + await dial(port, "first"); + await dial(port, "second"); + try { + connections.set(1 as unknown as Socket, { label: "number", bytes: 0 }); + } catch (err) { + console.log((err as Error).name, (err as Error).message); + } + server.close(() => console.log("closed; server still known", servers.has(server))); +}); diff --git a/tests/fixtures/npm/cases/undici5/main.ts b/tests/fixtures/npm/cases/undici5/main.ts index b51b16c65..ae39bacf9 100644 --- a/tests/fixtures/npm/cases/undici5/main.ts +++ b/tests/fixtures/npm/cases/undici5/main.ts @@ -81,6 +81,34 @@ channel("undici:request:trailers").subscribe((message) => { if (t !== undefined) console.log(` [diag] ${t.label} done after ${t.dispatched} dispatch`); }); +// A transport's connection registry: sockets the wrapped connector dialed, +// keyed weakly, and the client diagnostics messages that carry them. +const dialed = new WeakMap(); +const clientDiag = { beforeConnect: 0, connector: 0, connected: 0, sendHeaders: 0, known: 0, unknown: 0 }; +const noteSocket = (socket: Socket): void => { + const n = dialed.get(socket); + if (n === undefined) clientDiag.unknown++; + else clientDiag.known++; +}; +channel("undici:client:beforeConnect").subscribe((message) => { + const { connector } = message as { connector: unknown }; + clientDiag.beforeConnect++; + if (typeof connector === "function") clientDiag.connector++; +}); +channel("undici:client:connected").subscribe((message) => { + const { socket, connectParams } = message as { + socket: Socket; + connectParams: { protocol: string; hostname: string }; + }; + clientDiag.connected++; + if (connectParams.protocol === "http:" && connectParams.hostname === "127.0.0.1") noteSocket(socket); +}); +channel("undici:client:sendHeaders").subscribe((message) => { + const { socket, headers } = message as { socket: Socket; headers: string }; + clientDiag.sendHeaders++; + if (headers.length > 0) noteSocket(socket); +}); + /** A transport-style Agent: one Pool per origin built by the factory, a * wrapped connector, and a Client interceptor that marks dispatches. */ class TracingAgent extends Agent { @@ -102,7 +130,9 @@ class TracingAgent extends Agent { pipelining: 1, connect(options, callback) { opened++; - return connect(options, callback); + const socket = connect(options, callback); + dialed.set(socket, opened); + return socket; }, interceptors: { Client: [ @@ -198,6 +228,9 @@ server.listen(0, "127.0.0.1", async () => { await r.body.text(); } console.log("connections opened:", agent.connections); + console.log( + `client diagnostics: ${clientDiag.beforeConnect} before connect (${clientDiag.connector} with a connector), ${clientDiag.connected} connected, ${clientDiag.sendHeaders} send headers; ${clientDiag.known} carry a dialed socket, ${clientDiag.unknown} another`, + ); console.log("stats:", agent.statsFor(origin)); console.log("== Pool with a connection limit"); diff --git a/tests/fixtures/npm/cases/undici7/main.ts b/tests/fixtures/npm/cases/undici7/main.ts index f39e3031d..28cfd8353 100644 --- a/tests/fixtures/npm/cases/undici7/main.ts +++ b/tests/fixtures/npm/cases/undici7/main.ts @@ -115,6 +115,34 @@ channel("undici:request:trailers").subscribe((message) => { if (t !== undefined) console.log(` [diag] ${t.label} done after ${t.dispatched} dispatch`); }); +// A transport's connection registry: sockets the wrapped connector dialed, +// keyed weakly, and the client diagnostics messages that carry them. +const dialed = new WeakMap(); +const clientDiag = { beforeConnect: 0, connector: 0, connected: 0, sendHeaders: 0, known: 0, unknown: 0 }; +const noteSocket = (socket: Socket): void => { + const n = dialed.get(socket); + if (n === undefined) clientDiag.unknown++; + else clientDiag.known++; +}; +channel("undici:client:beforeConnect").subscribe((message) => { + const { connector } = message as { connector: unknown }; + clientDiag.beforeConnect++; + if (typeof connector === "function") clientDiag.connector++; +}); +channel("undici:client:connected").subscribe((message) => { + const { socket, connectParams } = message as { + socket: Socket; + connectParams: { protocol: string; hostname: string }; + }; + clientDiag.connected++; + if (connectParams.protocol === "http:" && connectParams.hostname === "127.0.0.1") noteSocket(socket); +}); +channel("undici:client:sendHeaders").subscribe((message) => { + const { socket, headers } = message as { socket: Socket; headers: string }; + clientDiag.sendHeaders++; + if (headers.length > 0) noteSocket(socket); +}); + /** A transport-style Agent: one Pool per origin built by the factory, a * wrapped connector, and a Client interceptor that marks dispatches. */ class TracingAgent extends Agent { @@ -136,7 +164,9 @@ class TracingAgent extends Agent { pipelining: 1, connect(options, callback) { opened++; - return connect(options, callback); + const socket = connect(options, callback); + dialed.set(socket, opened); + return socket; }, interceptors: { Client: [ @@ -232,6 +262,9 @@ server.listen(0, "127.0.0.1", async () => { await r.body.text(); } console.log("connections opened:", agent.connections); + console.log( + `client diagnostics: ${clientDiag.beforeConnect} before connect (${clientDiag.connector} with a connector), ${clientDiag.connected} connected, ${clientDiag.sendHeaders} send headers; ${clientDiag.known} carry a dialed socket, ${clientDiag.unknown} another`, + ); console.log("stats:", agent.statsFor(origin)); console.log("== Pool with a connection limit"); From 4100cd3c047601e5a13f593f99a40894be3d8611 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 21 Oct 2026 22:08:30 -0500 Subject: [PATCH 3/7] Never buffer instances of classes whose trace visits nothing A class carries the collector header when any class in its hierarchy can be on a cycle, so instances of classes whose own fields hold nothing cycle-capable were buffered as candidates and walked by every level. Such an object has no traced children and is never on a cycle. Their descriptors now name scr_cyc_no_children, and they are allocated with buf set to SCR_CYC_ACYCLIC. That value has the buffered bit set, so a release that leaves the object alive makes no call; scr_cyc_is_buffered is false for it, and an emitted death or publication mark takes the object out of a candidate buffer only when buf is below -1. A pass that walks such an object as a child restores the value when it survives. --- packages/compiler/src/backend/llvm/alloc.ts | 9 +- .../compiler/src/backend/llvm/classes.test.ts | 38 ++++++ packages/compiler/src/backend/llvm/classes.ts | 15 ++- .../compiler/src/backend/llvm/publish.test.ts | 3 + packages/compiler/src/backend/llvm/publish.ts | 5 +- packages/compiler/src/backend/llvm/shapes.ts | 15 +-- ...clic-classes-in-traced-hierarchies.ts.json | 6 + packages/runtime/src/scr_cycle.c | 30 ++++- packages/runtime/src/scr_rc.h | 32 ++++- packages/runtime/test/test_cycle.c | 33 +++++ .../acyclic-classes-in-traced-hierarchies.ts | 117 ++++++++++++++++++ ...clic-classes-in-traced-hierarchies.ts.json | 1 + 12 files changed, 279 insertions(+), 25 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/acyclic-classes-in-traced-hierarchies.ts.json create mode 100644 tests/corpus/acyclic-classes-in-traced-hierarchies.ts create mode 100644 tests/harness/corpus-costs/acyclic-classes-in-traced-hierarchies.ts.json diff --git a/packages/compiler/src/backend/llvm/alloc.ts b/packages/compiler/src/backend/llvm/alloc.ts index c28a63e62..aa6345dab 100644 --- a/packages/compiler/src/backend/llvm/alloc.ts +++ b/packages/compiler/src/backend/llvm/alloc.ts @@ -244,9 +244,12 @@ export function cycleAppendHelper(): string[] { } /** A collector header to install: the object type's constant ScrCycType - * descriptor (its trace and teardown; see cycTypeDescriptor). */ + * descriptor (its trace and teardown; see cycTypeDescriptor). `acyclic`: + * the descriptor's trace is scr_cyc_no_children, and `buf` starts out + * SCR_CYC_ACYCLIC (scr_cyc_alloc does the same on its path). */ export interface CycHeader { type: string; + acyclic?: boolean; } /** The first collector statistics index of an emitted ScrCycType @@ -391,7 +394,9 @@ function allocLines( ` %sa.cgp = getelementptr inbounds i8, ptr %sa.blk, i64 2`, ` store i16 0, ptr %sa.cgp ; ScrCycHdr.color, gen`, ` %sa.bufp = getelementptr inbounds i8, ptr %sa.blk, i64 ${CYC_BUF}`, - ` store i32 0, ptr %sa.bufp ; ScrCycHdr.buf`, + cyc.acyclic === true + ? ` store i32 -1, ptr %sa.bufp ; ScrCycHdr.buf = SCR_CYC_ACYCLIC` + : ` store i32 0, ptr %sa.bufp ; ScrCycHdr.buf`, ` %sa.live = load i64, ptr @scr_cyc_live`, ` %sa.live1 = add i64 %sa.live, 1`, ` store i64 %sa.live1, ptr @scr_cyc_live`, diff --git a/packages/compiler/src/backend/llvm/classes.test.ts b/packages/compiler/src/backend/llvm/classes.test.ts index 5daaf4300..94da472d4 100644 --- a/packages/compiler/src/backend/llvm/classes.test.ts +++ b/packages/compiler/src/backend/llvm/classes.test.ts @@ -157,6 +157,44 @@ test("ProgramLifetime hierarchies are never freed and take no reference counts", expect(body(mangleClassRelease("Owner"))).toContain("load i32, ptr %bufp"); }); +test("classes whose trace visits nothing start acyclic in a traced hierarchy", () => { + const mapper = { kind: "object" as const, className: "Mapper" }; + const mod: IrModule = { + irVersion: 17, + sourceFile: loc.file, + entry: "main", + functions: [{ name: "main", params: [], returnType: VOID, locals: [], body: [], loc }], + classes: [ + { name: "Mapper", fields: [], methods: [], loc }, + // A dyn field can reach anything: the hierarchy carries collector headers. + { + name: "Linked", + base: "Mapper", + fields: [ + { name: "next", type: mapper }, + { name: "hook", type: DYN }, + ], + methods: [], + loc, + }, + { name: "Simple", base: "Mapper", fields: [{ name: "value", type: F64 }], methods: [], loc }, + ], + }; + const llvm = emitLlvmModule(mod, { pointerBits: 64 }); + const body = (name: string): string => + new RegExp(`^define internal [a-z]+ @${name}(?:\\.i32)?\\([^]*?^}`, "m").exec(llvm)?.[0] ?? ""; + const descriptor = (cls: string): string => + llvm.split("\n").find((line) => line.startsWith(`@${mangleClassTrace(cls)}.cyctype = `)) ?? ""; + expect(descriptor("Simple")).toContain("{ ptr @scr_cyc_no_children, "); + expect(body(mangleClassNew("Simple"))).toContain("store i32 -1, ptr %sa.bufp"); + expect(descriptor("Linked")).toContain(`{ ptr @${mangleClassTrace("Linked")}, `); + expect(body(mangleClassNew("Linked"))).toContain("store i32 0, ptr %sa.bufp"); + // Releases share the hierarchy's possible-root step, which skips the + // call for SCR_CYC_ACYCLIC, and a death unbuffers only below -1. + expect(body(mangleClassRelease("Simple"))).toContain("icmp slt i32 %buf, 0"); + expect(body(mangleClassReleaseDirect("Simple"))).toContain("icmp slt i32 %dbuf, -1"); +}); + test("ProgramLifetime classes pack their fields after a 32-bit header", () => { const node = { kind: "object" as const, className: "Node" }; const leaf = { kind: "object" as const, className: "Leaf" }; diff --git a/packages/compiler/src/backend/llvm/classes.ts b/packages/compiler/src/backend/llvm/classes.ts index 03ee8caa1..7ca2216d2 100644 --- a/packages/compiler/src/backend/llvm/classes.ts +++ b/packages/compiler/src/backend/llvm/classes.ts @@ -906,6 +906,15 @@ export function emitClassShapes( })), ]; const refFields = indexedFields.filter((f) => isRefCounted(f.type)); + const tracedFields = traced + ? indexedFields.filter((f) => traceAdapter(host, f.type) !== null) + : []; + // A traced class whose trace visits nothing carries the collector header + // only because its hierarchy is traced: it is never on a cycle. Its + // descriptor names scr_cyc_no_children and its objects start out + // SCR_CYC_ACYCLIC, so releases never buffer them. + const acyclic = + traced && runtimePrefix === null && !isStreamRooted && tracedFields.length === 0; const bounded = runtimePrefix !== null || isStreamRooted || @@ -1089,11 +1098,12 @@ export function emitClassShapes( `entry:`, ]; const cycType = `@${mangleClassTrace(cls.name)}.cyctype`; + if (acyclic) host.declare(`declare void @scr_cyc_no_children(ptr, ptr, ptr)`); if (traced) defs.push( cycTypeDescriptor( cycType, - `@${mangleClassTrace(cls.name)}`, + acyclic ? "@scr_cyc_no_children" : `@${mangleClassTrace(cls.name)}`, `@${mangleClassGcFree(cls.name)}`, host.nextCycKind?.() ?? 0, ), @@ -1113,7 +1123,7 @@ export function emitClassShapes( ...emitObjectAlloc( host, sizeOf, - traced && !lifetime ? { type: cycType } : null, + traced && !lifetime ? { type: cycType, acyclic } : null, storedHeaderBytes(meta), ), ); @@ -1188,7 +1198,6 @@ export function emitClassShapes( // exactly the complement, then free (the trace/teardown complement // contract in scr_runtime.h). const indexed = indexedFields; - const tracedFields = indexed.filter((f) => traceAdapter(host, f.type) !== null); const untracedRefFields = indexed.filter( (f) => isRefCounted(f.type) && traceAdapter(host, f.type) === null, ); diff --git a/packages/compiler/src/backend/llvm/publish.test.ts b/packages/compiler/src/backend/llvm/publish.test.ts index 5a84caa96..c8008fdff 100644 --- a/packages/compiler/src/backend/llvm/publish.test.ts +++ b/packages/compiler/src/backend/llvm/publish.test.ts @@ -60,6 +60,9 @@ test("publication walkers mark and push inline, with the runtime for the rest", expect(fn).toContain("icmp ult i64 %mrc, 4294967294"); expect(fn).toContain("getelementptr inbounds { ptr, i32, i32 }, ptr %mlog, i64 %mlogged"); expect(fn).toContain("store i64 -1, ptr %o"); + // A headered object leaves its candidate buffer only when `buf` is below + // -1: SCR_CYC_ACYCLIC (-1) marks an object that is never in one. + expect(fn).toContain("icmp sge i32 %mbufw, -1"); // Anything else goes through scr_pub_mark. expect(fn).toMatch(/call zeroext i1 @scr_pub_mark\(ptr %ctx, ptr %o, i1 (true|false), i32 4\)/); // A string is a leaf, marked at once; other references go on the stack, diff --git a/packages/compiler/src/backend/llvm/publish.ts b/packages/compiler/src/backend/llvm/publish.ts index ebe9b1a64..bb7e0c8a8 100644 --- a/packages/compiler/src/backend/llvm/publish.ts +++ b/packages/compiler/src/backend/llvm/publish.ts @@ -434,12 +434,13 @@ export class PublishWalkers { let buf = classId || compact ? String(PUB_LOG_COUNT) : "0"; if (cyc) { // ScrCycHdr.buf, the word before the object: its top bit while the - // object is a buffered candidate. + // object is a buffered candidate, and -1 (SCR_CYC_ACYCLIC) for an + // object that never is one. this.host.declare(`declare i32 @scr_cyc_pub_take(ptr)`); lines.push( ` %mbufp = getelementptr inbounds i8, ptr %o, i64 -4`, ` %mbufw = load i32, ptr %mbufp`, - ` %mfree = icmp sge i32 %mbufw, 0`, + ` %mfree = icmp sge i32 %mbufw, -1`, ` br i1 %mfree, label %mput, label %mtake`, `mtake:`, ` %mix = call i32 @scr_cyc_pub_take(ptr %o)`, diff --git a/packages/compiler/src/backend/llvm/shapes.ts b/packages/compiler/src/backend/llvm/shapes.ts index 58295e42b..6de92f667 100644 --- a/packages/compiler/src/backend/llvm/shapes.ts +++ b/packages/compiler/src/backend/llvm/shapes.ts @@ -1369,7 +1369,7 @@ export function directChildReleaseLines( lines.push( ` %${tag}.bufp = getelementptr i8, ptr ${value}, ${S} -${host.cycleBufOffset}`, ` %${tag}.buf = load i32, ptr %${tag}.bufp`, - ` %${tag}.queued = icmp slt i32 %${tag}.buf, 0`, + ` %${tag}.queued = icmp slt i32 %${tag}.buf, -1 ; buffered, not SCR_CYC_ACYCLIC`, ` br i1 %${tag}.queued, label %${tag}.unbuffer, label %${tag}.teardown`, `${tag}.unbuffer:`, ` call void @scr_cyc_on_dead(ptr ${value})`, @@ -1595,8 +1595,9 @@ function requestCycleAppend(host: ShapeHost): boolean { /** The possible-cycle-root step of a release that left a headered object * alive: scr_cyc_on_release with its early return inlined. Test the sign - * of `buf` (the i32 at obj-4; negative for a queued candidate or a tenured - * object), and buffer a new candidate: through the module's + * of `buf` (the i32 at obj-4; negative for a queued candidate, a young + * object, or an object that is never a candidate, SCR_CYC_ACYCLIC), and + * buffer a new candidate: through the module's * cycleAppendHelper in optimized builds that support it, through * scr_cyc_on_release otherwise. Every emitted release of a headered object * shares this sequence (scr_runtime.h asserts the offsets); it ends by @@ -1619,16 +1620,16 @@ export function cycleRootLines(host: ShapeHost, done: string): string[] { } /** The death of a headered object: scr_cyc_on_dead with its early return - * inlined. Only an object still in a candidate buffer - * (`buffered` nonzero) has an entry to remove; the sequence continues at - * the new block `next`. */ + * inlined. Only an object still in a candidate buffer (`buf` negative and + * not SCR_CYC_ACYCLIC, which is -1) has an entry to remove; the sequence + * continues at the new block `next`. */ export function cycleDeadLines(host: ShapeHost, next: string): string[] { const S = host.sizeType; host.declare(`declare void @scr_cyc_on_dead(ptr)`); return [ ` %dbufp = getelementptr i8, ptr %o, ${S} -${host.cycleBufOffset}`, ` %dbuf = load i32, ptr %dbufp`, - ` %dqueued = icmp slt i32 %dbuf, 0`, + ` %dqueued = icmp slt i32 %dbuf, -1 ; buffered, not SCR_CYC_ACYCLIC`, ` br i1 %dqueued, label %unbuffer, label %${next}`, `unbuffer:`, ` call void @scr_cyc_on_dead(ptr %o)`, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/acyclic-classes-in-traced-hierarchies.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/acyclic-classes-in-traced-hierarchies.ts.json new file mode 100644 index 000000000..8daa576cb --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/acyclic-classes-in-traced-hierarchies.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/acyclic-classes-in-traced-hierarchies.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_cycle.c b/packages/runtime/src/scr_cycle.c index 591f544b8..5e5029e14 100644 --- a/packages/runtime/src/scr_cycle.c +++ b/packages/runtime/src/scr_cycle.c @@ -361,7 +361,15 @@ uint32_t scr_cyc_register(ScrCycType *type) { } void *scr_cyc_alloc(size_t size, ScrCycType *type) { - return scr_cyc_alloc_inline(size, type); + void *obj = scr_cyc_alloc_inline(size, type); + if (type->trace == scr_cyc_no_children) scr_cyc_hdr(obj)->buf = SCR_CYC_ACYCLIC; + return obj; +} + +void scr_cyc_no_children(void *obj, ScrTraceVisit visit, void *ctx) { + (void)obj; + (void)visit; + (void)ctx; } void scr_cyc_free(void *obj) { scr_cyc_free_inline(obj); } @@ -1175,7 +1183,13 @@ void scr_cyc_on_release(void *obj) { ScrCycHdr *h = scr_cyc_hdr(obj); /* Releasing an existing candidate cannot advance the trigger. A pass * always re-arms after its teardowns, including any candidates they add. */ - if (scr_cyc_is_buffered(h)) return; + if (scr_cyc_release_done(h)) return; + /* An object without cycle-capable children whose SCR_CYC_ACYCLIC a pass + * or the young-in nursery replaced gets it back. */ + if (scr_cyc_type(h)->trace == scr_cyc_no_children) { + h->buf = SCR_CYC_ACYCLIC; + return; + } #if SCR_CYC_INLINE_BUFFERING if (scr_cyc_append(h, obj)) return; #endif @@ -1346,14 +1360,17 @@ static void scr_scan_black(void *obj, uintptr_t depth) { ScrCycHdr *h = scr_cyc_hdr(obj); scr_cyc_set_color(h, SCR_CYC_BLACK); /* Drop markGray's component number: a survivor leaves the pass unbuffered - * unless it is a candidate that a limited pass did not take. */ - if (!scr_cyc_is_buffered(h)) scr_cyc_clear_buffered(h); + * unless it is a candidate that a limited pass did not take, and one + * without cycle-capable children leaves it SCR_CYC_ACYCLIC again. */ + const ScrCycType *type = scr_cyc_type(h); + bool acyclic = type->trace == scr_cyc_no_children; + if (!scr_cyc_is_buffered(h)) h->buf = acyclic ? SCR_CYC_ACYCLIC : 0; if (scr_cyc_gen(h) < SCR_CYC_OLD) scr_cyc_push_work(&scr_promote, obj); + if (acyclic) return; if (depth == SCR_CYC_WALK_DEPTH) { scr_cyc_push_work(&scr_restore_pending, obj); return; } - const ScrCycType *type = scr_cyc_type(h); SCR_CYC_CHILDREN(obj, type, scr_sb_array, scr_sb_visit, depth + 1); } static void scr_restore_from(void *root) { @@ -1881,7 +1898,8 @@ static SCR_CYC_COMPACT size_t scr_cyc_settle(unsigned gen_limit, bool doomed) { void *obj = scr_cands.v[i]; ScrCycHdr *h = scr_cyc_hdr(obj); if (scr_cyc_color(h) == SCR_CYC_DOOMED) continue; /* being freed below */ - if (!scr_cyc_is_buffered(h)) { + /* A candidate without cycle-capable children was never on a cycle. */ + if (!scr_cyc_is_buffered(h) && h->buf != SCR_CYC_ACYCLIC) { /* A survivor promoted into a level its type bypasses moves past * it, as a release would buffer it. */ if (scr_cyc_gen(h) == SCR_CYC_MATURE && scr_cyc_bypassed(scr_cyc_type(h), SCR_CYC_MATURE)) diff --git a/packages/runtime/src/scr_rc.h b/packages/runtime/src/scr_rc.h index 9cb88e6e8..892531965 100644 --- a/packages/runtime/src/scr_rc.h +++ b/packages/runtime/src/scr_rc.h @@ -141,6 +141,11 @@ typedef struct ScrCycType { * Emitted allocation paths read it and take the slow path while it is 0. */ uint32_t id; } ScrCycType; +/* The trace of a type that holds nothing cycle-capable: it visits nothing. + * Emitted classes whose fields hold no cycle-capable value but whose + * hierarchy is traced name it (llvm/classes.ts); their objects are + * allocated with `buf` SCR_CYC_ACYCLIC. */ +void scr_cyc_no_children(void *obj, ScrTraceVisit visit, void *ctx); /* Runtime families' kinds. Emitted descriptors count from * SCR_CYC_KIND_EMITTED (llvm/alloc.ts FIRST_EMITTED_CYC_KIND). */ enum { @@ -169,7 +174,7 @@ enum { * collector color (SCR_CYC_*) and generation (SCR_CYC_NURSERY..SCR_CYC_OLD, * the walk filter), and `buf`: SCR_CYC_BUFFERED_BIT while the object sits in * its generation's candidate buffer, with its position there below it (O(1) - * removal when rc hits 0). */ + * removal when rc hits 0), or SCR_CYC_ACYCLIC. */ typedef struct ScrCycHdr { uint16_t id; uint8_t color; @@ -177,13 +182,25 @@ typedef struct ScrCycHdr { uint32_t buf; } ScrCycHdr; #define SCR_CYC_BUFFERED_BIT 0x80000000u +/* `buf` of an object whose type's trace is scr_cyc_no_children: it has no + * cycle-capable children, so it is never on a cycle and never a candidate. + * Such a type still carries the header when it shares a hierarchy with + * cycle-capable classes (headers are uniform per hierarchy). The value has + * the buffered bit set, so a release that leaves the object alive returns + * at the buffered test and makes no call; scr_cyc_is_buffered is false for + * it. No buffer position reaches it (scr_cyc_grow caps the buffers well + * below 2^31 entries). */ +#define SCR_CYC_ACYCLIC 0xffffffffu /* This layout is an ABI, not an implementation detail. The emitted - * allocation stores the id and zeroes the rest; every emitted release of a + * allocation stores the id and zeroes the rest (`buf` SCR_CYC_ACYCLIC for a + * class whose trace is scr_cyc_no_children); every emitted release of a * headered object (llvm/shapes.ts cycleRootLines, used by the record, class * and inline RC helpers) mirrors scr_cyc_on_release's early return by - * testing the sign of `buf` as an i32 at obj-4, and the emitted inline free - * reads the generation as an i8 at obj-5. Hence the assertions. */ + * testing the sign of `buf` as an i32 at obj-4, the emitted death mirrors + * scr_cyc_on_dead's by testing `buf` below -1 (cycleDeadLines), and the + * emitted inline free reads the generation as an i8 at obj-5. Hence the + * assertions. */ _Static_assert(sizeof(ScrCycHdr) == 8, "LLVM backend expects an 8-byte cycle header"); _Static_assert(offsetof(ScrCycHdr, id) == 0 && sizeof(((ScrCycHdr *)0)->id) == 2, "the emitted allocation stores the registry id as an i16 at the block start"); @@ -215,6 +232,11 @@ static inline void scr_cyc_set_color(ScrCycHdr *h, unsigned color) { h->color = static inline unsigned scr_cyc_gen(const ScrCycHdr *h) { return h->gen; } static inline void scr_cyc_set_gen(ScrCycHdr *h, unsigned gen) { h->gen = (uint8_t)gen; } static inline bool scr_cyc_is_buffered(const ScrCycHdr *h) { + return (h->buf & SCR_CYC_BUFFERED_BIT) != 0 && h->buf != SCR_CYC_ACYCLIC; +} +/* A release that leaves the object alive has nothing to buffer: it is a + * candidate already, or it is never one (SCR_CYC_ACYCLIC). */ +static inline bool scr_cyc_release_done(const ScrCycHdr *h) { return (h->buf & SCR_CYC_BUFFERED_BIT) != 0; } static inline uint32_t scr_cyc_buf_index(const ScrCycHdr *h) { @@ -608,7 +630,7 @@ static inline bool scr_cyc_append(ScrCycHdr *h, void *obj) { * Most releases of long-lived containers take the first early return. */ static inline void scr_cyc_released(void *obj) { ScrCycHdr *h = scr_cyc_hdr(obj); - if (scr_cyc_is_buffered(h)) return; + if (scr_cyc_release_done(h)) return; #if SCR_CYC_INLINE_BUFFERING if (scr_cyc_append(h, obj)) return; #endif diff --git a/packages/runtime/test/test_cycle.c b/packages/runtime/test/test_cycle.c index 723e9519f..4e00b852e 100644 --- a/packages/runtime/test/test_cycle.c +++ b/packages/runtime/test/test_cycle.c @@ -1434,6 +1434,38 @@ static void check_death_brings_full_pass(void) { free(keep); } +/* An object whose type's trace is scr_cyc_no_children starts out + * SCR_CYC_ACYCLIC and is never buffered. A pass that reaches it from a dead + * cycle frees it with the cycle when the cycle held its last reference, and + * otherwise leaves it SCR_CYC_ACYCLIC with its outside count. */ +static void check_acyclic_objects(void) { + static ScrCycType leaf_type = { &scr_cyc_no_children, &node_free, 0 }; + size_t before = freed; + Node *leaf = scr_cyc_alloc(sizeof(*leaf), &leaf_type); + ScrCycHdr *h = scr_cyc_hdr(leaf); + check(h->buf == SCR_CYC_ACYCLIC && !scr_cyc_is_buffered(h), "acyclic object did not start acyclic"); + leaf->rc = 2; /* an outside owner and a temporary reference */ + release_live(leaf); + check(h->buf == SCR_CYC_ACYCLIC, "release buffered an acyclic object"); + + Node *first = make_ring(2); + first->other = leaf; + leaf->rc++; + release_live(first); + scr_cyc_pass_at(SCR_CYC_NURSERY); + check(freed == before + 2, "nursery pass did not free the ring"); + check(leaf->rc == 1 && h->buf == SCR_CYC_ACYCLIC, "acyclic survivor lost its count or its mark"); + + Node *second = make_ring(2); + second->next->other = leaf; + leaf->rc++; + release_live(leaf); /* the outside owner lets go: only the ring holds it */ + check(h->buf == SCR_CYC_ACYCLIC, "release buffered an acyclic object"); + release_live(second); + scr_collect_cycles(); + check(freed == before + 5, "acyclic object held only by a dead ring was not freed with it"); +} + int main(void) { check_inline_buffering(); check_old_garbage_reclaimed_while_growing(); @@ -1467,6 +1499,7 @@ int main(void) { check_limited_pass(); check_large_old_buffer_with_nursery_candidates(); check_unjudged_backlog_follows_growth(); + check_acyclic_objects(); printf("cycle collection checks passed: threshold=%zu growth_cap=%zu\n", configured_nursery_threshold(), configured_growth_cap()); return 0; diff --git a/tests/corpus/acyclic-classes-in-traced-hierarchies.ts b/tests/corpus/acyclic-classes-in-traced-hierarchies.ts new file mode 100644 index 000000000..d25def1da --- /dev/null +++ b/tests/corpus/acyclic-classes-in-traced-hierarchies.ts @@ -0,0 +1,117 @@ +import { publish } from "@scriptc/threads"; + +// Classes whose own fields hold nothing cycle-capable, in a hierarchy where +// other classes form cycles: their instances are held by dead cycles, by +// live cycles and by plain locals while collections run, and are published +// with the contexts that hold them. + +abstract class Mapper { + abstract map(n: number): number; +} + +class Offset extends Mapper { + by: number; + constructor(by: number) { + super(); + this.by = by; + } + map(n: number): number { + return n + this.by; + } +} + +class Scale extends Offset { + readonly factor: number; + constructor(by: number, factor: number) { + super(by); + this.factor = factor; + } + map(n: number): number { + return super.map(n) * this.factor; + } +} + +class Context { + readonly steps: Mapper[] = []; + mapper: Mapper | undefined = undefined; + total = 0; +} + +class ContextMapper extends Mapper { + readonly context: Context; + constructor(context: Context) { + super(); + this.context = context; + } + map(n: number): number { + let value = n; + for (const step of this.context.steps) value = step.map(value); + return value; + } +} + +class Chain extends Mapper { + readonly first: Mapper; + next: Mapper | undefined = undefined; + constructor(first: Mapper) { + super(); + this.first = first; + } + map(n: number): number { + const value = this.first.map(n); + return this.next === undefined ? value : this.next.map(value); + } +} + +function makeContext(i: number, shared: Mapper): Context { + const context = new Context(); + context.steps.push(new Offset(i % 7), shared, new Scale(1, 2)); + context.mapper = new ContextMapper(context); + context.total = context.mapper.map(i); + return context; +} + +const shared = new Scale(3, 1); +const kept: Context[] = []; +let sum = 0; +for (let i = 0; i < 20000; i++) { + const context = makeContext(i, shared); + sum += context.total; + if (i % 1000 === 0) kept.push(context); +} + +// A chain that loops back to itself, holding mappers that are also held +// outside it. +const outside: Mapper[] = []; +let loops = 0; +for (let i = 0; i < 5000; i++) { + const offset = new Offset(i); + const head = new Chain(offset); + const tail = new Chain(new Scale(i, 3)); + head.next = tail; + tail.next = undefined; + loops += head.map(1); + tail.next = head; + if (i % 500 === 0) outside.push(offset); +} + +let keptTotal = 0; +for (const context of kept) keptTotal += context.mapper!.map(1); +let outsideTotal = 0; +for (const mapper of outside) outsideTotal += mapper.map(2); + +console.log(sum, loops, kept.length, keptTotal, outside.length, outsideTotal, shared.map(4)); + +publish(kept); +let publishedTotal = 0; +for (const context of kept) publishedTotal += context.mapper!.map(3); +console.log("published", publishedTotal); +try { + shared.by = 5; + console.log("write: ok"); +} catch (e) { + console.log(`write: ${(e as Error).name}: ${(e as Error).message}`); +} +const fresh = makeContext(7, new Offset(1)); +fresh.steps.push(shared); +console.log("after", fresh.mapper!.map(2), outside[3]!.map(1)); diff --git a/tests/harness/corpus-costs/acyclic-classes-in-traced-hierarchies.ts.json b/tests/harness/corpus-costs/acyclic-classes-in-traced-hierarchies.ts.json new file mode 100644 index 000000000..bf904aec4 --- /dev/null +++ b/tests/harness/corpus-costs/acyclic-classes-in-traced-hierarchies.ts.json @@ -0,0 +1 @@ +{ "plain": 3350, "san": 3910 } From 2c5771e98cafcb2377a78ac03ca41d419ce1655b Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 21 Oct 2026 22:19:38 -0500 Subject: [PATCH 4/7] Run never-suspending async calls in place from stackless async bodies --- .../src/backend/llvm/emit-function.ts | 14 ++-- packages/compiler/src/backend/llvm/emitter.ts | 55 ++++++++++----- .../compiler/test/await-call-emission.test.ts | 68 +++++++++++++++++-- .../corpus/6953-fiberless-plain-calls.ts.json | 6 ++ tests/corpus/6953-fiberless-plain-calls.ts | 65 ++++++++++++++++++ .../6953-fiberless-plain-calls.ts.json | 1 + 6 files changed, 181 insertions(+), 28 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6953-fiberless-plain-calls.ts.json create mode 100644 tests/corpus/6953-fiberless-plain-calls.ts create mode 100644 tests/harness/corpus-costs/6953-fiberless-plain-calls.ts.json diff --git a/packages/compiler/src/backend/llvm/emit-function.ts b/packages/compiler/src/backend/llvm/emit-function.ts index 0d5533f85..7bbd3d453 100644 --- a/packages/compiler/src/backend/llvm/emit-function.ts +++ b/packages/compiler/src/backend/llvm/emit-function.ts @@ -1117,8 +1117,11 @@ export function emitAsyncAwaitCall( ``, ]; } - emitter.declare(`declare void @scr_async_inline_enter(ptr)`); - emitter.declare(`declare void @scr_async_inline_exit(ptr)`); + const framed = emitter.fiberlessFrames; + if (framed) { + emitter.declare(`declare void @scr_async_inline_enter(ptr)`); + emitter.declare(`declare void @scr_async_inline_exit(ptr)`); + } emitter.declare(`declare void @scr_await_hop()`); emitter.declare(`declare ptr @scr_promise_new()`); emitter.declare(`declare void @scr_promise_reject_pending(ptr)`); @@ -1127,11 +1130,12 @@ export function emitAsyncAwaitCall( const out = [ head, `entry:`, - ` %frame = alloca { ptr, ptr }`, - ` call void @scr_async_inline_enter(ptr %frame)`, + ...(framed + ? [` %frame = alloca { ptr, ptr }`, ` call void @scr_async_inline_enter(ptr %frame)`] + : []), retTy === "void" ? ` ${bodyCall}` : ` %r = ${bodyCall}`, ...emitter.pendingTestLines("%pend"), - ` call void @scr_async_inline_exit(ptr %frame)`, + ...(framed ? [` call void @scr_async_inline_exit(ptr %frame)`] : []), // Adopting: the then job's turn comes before the settled promise's // own reaction, whichever way the body completed. ...(adopt ? [` call void @scr_await_hop()`] : []), diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 4504ae58e..0bdbe23b2 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -1798,16 +1798,33 @@ export class LlEmitter { }); } + /** Whether a fiberless body running on its caller's stack needs the + * frame scr_async_inline_enter/exit keep: it isolates the Error.stack + * frame chain, which only stack-observing programs push, and the + * AsyncLocalStorage context, which only programs that enter stores + * change (RuntimeFeatures asyncContext), as a fresh fiber would. Other + * programs run the body with a plain call. The frame also counts the + * bodies running on a fiber's stack for the runtime's trap on a fiberless + * body that suspends. */ + get fiberlessFrames(): boolean { + return this.stackTraces || this.schedulesInContext; + } + /** The spawn wrapper of a fiberless async function: runs the body on the - * caller's stack between scr_async_inline_enter/leave and settles the - * fresh promise from its outcome — the fiber trampoline's epilogue, - * without the fiber. */ + * caller's stack (between scr_async_inline_enter/leave when + * fiberlessFrames) and settles a fresh promise from its outcome — the + * fiber trampoline's epilogue, without the fiber. */ emitFiberlessAsyncSpawn(fn: IrFunction, spawnParams: string[]): string[] { const ret = fn.returnType; const retTy = this.llType(ret); const lifted = fn.captures !== undefined; - this.declare(`declare void @scr_async_inline_enter(ptr)`); - this.declare(`declare ptr @scr_async_inline_leave(ptr)`); + const framed = this.fiberlessFrames; + if (framed) { + this.declare(`declare void @scr_async_inline_enter(ptr)`); + this.declare(`declare ptr @scr_async_inline_leave(ptr)`); + } else { + this.declare(`declare ptr @scr_promise_new()`); + } this.declare(`declare void @scr_promise_reject_pending(ptr)`); if (lifted) { this.declare(`declare ptr @scr_closure_retain_v(ptr)`); @@ -1817,15 +1834,17 @@ export class LlEmitter { const out = [ `define internal ptr @${mangleAsyncSpawn(fn.name)}(${spawnParams.join(", ")}) ${FN_ATTRS} { ; fiberless ${fn.name}`, `entry:`, - ` %frame = alloca { ptr, ptr }`, + ...(framed ? [` %frame = alloca { ptr, ptr }`] : []), // The fiber path holds +1 on a lifted environment for the body's // duration (packed, released by the trampoline); keep that lifetime. ...(lifted ? [` %env = call ptr @scr_closure_retain_v(ptr %a0)`] : []), - ` call void @scr_async_inline_enter(ptr %frame)`, + ...(framed ? [` call void @scr_async_inline_enter(ptr %frame)`] : []), retTy === "void" ? ` ${bodyCall}` : ` %r = ${bodyCall}`, ...(lifted ? [` call void @scr_closure_release(ptr %a0)`] : []), ...this.pendingTestLines("%pend"), - ` %p = call ptr @scr_async_inline_leave(ptr %frame)`, + framed + ? ` %p = call ptr @scr_async_inline_leave(ptr %frame)` + : ` %p = call ptr @scr_promise_new()`, ` br i1 %pend, label %thrown, label %clean`, `clean:`, ...this.asyncFulfillLines(ret, "%p"), @@ -1864,23 +1883,27 @@ export class LlEmitter { } /** The inline await entry of a fiberless async function (`sc_ai_*`): its - * body on the caller's stack between scr_async_inline_enter/exit, as in - * its spawn wrapper, returning the result and leaving a throw pending for - * the awaiting coroutine (emitCoroInlineAwait). */ + * body on the caller's stack (between scr_async_inline_enter/exit when + * fiberlessFrames), as in its spawn wrapper, returning the result and + * leaving a throw pending for the awaiting coroutine (emitCoroInlineAwait). */ emitAsyncInlineAwaitCall(fn: IrFunction): string[] { const retTy = this.llType(fn.returnType); const params = fn.params.map((p, i) => `${this.llType(p.type)} %a${i}`); const args = params.join(", "); - this.declare(`declare void @scr_async_inline_enter(ptr)`); - this.declare(`declare void @scr_async_inline_exit(ptr)`); + const framed = this.fiberlessFrames; + if (framed) { + this.declare(`declare void @scr_async_inline_enter(ptr)`); + this.declare(`declare void @scr_async_inline_exit(ptr)`); + } const bodyCall = `call ${retTy} @${mangleFunction(fn.name)}(${args})`; return [ `define internal ${retTy} @${mangleAsyncInlineAwaitCall(fn.name)}(${args}) ${FN_ATTRS} { ; inline await ${fn.name}`, `entry:`, - ` %frame = alloca { ptr, ptr }`, - ` call void @scr_async_inline_enter(ptr %frame)`, + ...(framed + ? [` %frame = alloca { ptr, ptr }`, ` call void @scr_async_inline_enter(ptr %frame)`] + : []), retTy === "void" ? ` ${bodyCall}` : ` %r = ${bodyCall}`, - ` call void @scr_async_inline_exit(ptr %frame)`, + ...(framed ? [` call void @scr_async_inline_exit(ptr %frame)`] : []), retTy === "void" ? ` ret void` : ` ret ${retTy} %r`, `}`, ``, diff --git a/packages/compiler/test/await-call-emission.test.ts b/packages/compiler/test/await-call-emission.test.ts index b3867c928..b206b829b 100644 --- a/packages/compiler/test/await-call-emission.test.ts +++ b/packages/compiler/test/await-call-emission.test.ts @@ -62,7 +62,9 @@ test("awaiting a direct call to a body that never suspends skips the promise", a expect(definition(llvm, "sc_f_middle")).toContain("call double @sc_aw_leaf(double"); const entry = definition(llvm, "sc_aw_leaf"); expect(entry).toContain("call double @sc_f_leaf(double %a0)"); - expect(entry).toContain("call void @scr_async_inline_exit(ptr %frame)"); + // Nothing here enters an AsyncLocalStorage store or reads a stack, so the + // body runs with a plain call (fiberlessFrames). + expect(entry).not.toContain("@scr_async_inline_enter"); // The clean result takes the await's microtask turn and returns directly; // only the throw path builds the rejected promise. const clean = entry.slice(entry.indexOf("clean:"), entry.indexOf("thrown:")); @@ -122,10 +124,10 @@ test("returning a direct call adopts its promise one microtask turn later", asyn // A body that never suspends: the adoption's then-job turn, then the // settled promise's own turn, still without materializing the promise. const entry = definition(llvm, "sc_ad_leaf"); - const exit = entry.indexOf("call void @scr_async_inline_exit(ptr %frame)"); + const body = entry.indexOf("call double @sc_f_leaf(double %a0)"); const hop = entry.indexOf("call void @scr_await_hop()"); - expect(exit).toBeGreaterThan(0); - expect(hop).toBeGreaterThan(exit); + expect(body).toBeGreaterThan(0); + expect(hop).toBeGreaterThan(body); expect(hop).toBeLessThan(entry.indexOf("clean:")); const clean = entry.slice(entry.indexOf("clean:"), entry.indexOf("thrown:")); expect(clean).toContain("call void @scr_await_hop()"); @@ -190,7 +192,7 @@ test("bodies that suspend only at their own awaits compile to stackless coroutin expect(middle).toContain("call double @sc_ai_leaf(double"); const inline = definition(llvm, "sc_ai_leaf"); expect(inline).toContain("call double @sc_f_leaf(double %a0)"); - expect(inline).toContain("call void @scr_async_inline_exit(ptr %frame)"); + expect(inline).not.toContain("@scr_async_inline_enter"); expect(middle).toContain("call zeroext i1 @scr_coro_hop_prepare(ptr"); expect(middle).toContain("call void @scr_promise_reject_pending(ptr"); expect(middle).toContain("call void @scr_coro_async_finish(ptr"); @@ -205,6 +207,58 @@ test("bodies that suspend only at their own awaits compile to stackless coroutin const entry = definition(llvm, "sc_aw_middle"); expect(entry).toContain("call ptr @sc_as_middle(double %a0)"); expect(entry).toContain("call double @scr_await_f64(ptr %p)"); - // A body that never suspends stays fiberless. - expect(definition(llvm, "sc_as_leaf")).toContain("@scr_async_inline_enter("); + // A body that never suspends stays fiberless: its spawn wrapper calls the + // body and settles a fresh promise. + const spawn = definition(llvm, "sc_as_leaf"); + expect(spawn).toContain("call double @sc_f_leaf(double %a0)"); + expect(spawn).toContain("call ptr @scr_promise_new()"); + expect(spawn).not.toContain("@scr_async_spawn"); +}); + +const framedSources = { + // Entering a store makes the frame isolate the AsyncLocalStorage context. + context: ` +import { AsyncLocalStorage } from "node:async_hooks"; +const store = new AsyncLocalStorage(); +async function leaf(x: number): Promise { + store.enterWith("leaf"); + return x + 1; +}`, + // Reading a stack makes the frame isolate the Error.stack frame chain. + stack: ` +async function leaf(x: number): Promise { + if (x < 0) console.log(new Error("negative").stack); + return x + 1; +}`, +}; + +test("fiberless bodies keep their frame where it isolates a context or a stack", async () => { + for (const [name, prefix] of Object.entries(framedSources)) { + const llvm = await emit(`${prefix} +async function slow(x: number): Promise { + await null; + return x; +} +async function coroutine(x: number): Promise { + return (await slow(x)) + (await leaf(x)); +} +async function* tick(): AsyncGenerator { + yield 0; +} +async function fibered(x: number): Promise { + for await (const t of tick()) x += t; + return await leaf(x); +} +async function main(): Promise { + const pending = leaf(1); + console.log((await coroutine(2)) + (await fibered(3)) + (await pending)); +} +void main(); +`); + for (const symbol of ["sc_ai_leaf", "sc_aw_leaf", "sc_as_leaf"]) { + expect(definition(llvm, symbol), `${name} ${symbol}`).toContain( + "call void @scr_async_inline_enter(ptr %frame)", + ); + } + } }); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6953-fiberless-plain-calls.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6953-fiberless-plain-calls.ts.json new file mode 100644 index 000000000..2ac34ff94 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6953-fiberless-plain-calls.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6953-fiberless-plain-calls.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6953-fiberless-plain-calls.ts b/tests/corpus/6953-fiberless-plain-calls.ts new file mode 100644 index 000000000..68b96e984 --- /dev/null +++ b/tests/corpus/6953-fiberless-plain-calls.ts @@ -0,0 +1,65 @@ +// Never-suspending async functions in a program that enters no +// AsyncLocalStorage store and reads no stacks: awaited from stackless and +// fiber bodies, called without an await, nested, throwing, and rejecting +// without a handler, against concurrent microtasks. +const log: string[] = []; + +async function leaf(n: number): Promise { + log.push(`leaf ${n}`); + return n + 1; +} +async function nestedLeaf(n: number): Promise { + const inner = leaf(n * 10); + void inner.then((v) => log.push(`nestedLeaf inner ${v}`)); + log.push(`nestedLeaf ${n}`); + return n; +} +async function thrower(n: number): Promise { + log.push(`thrower ${n}`); + if (n > 0) throw new TypeError(`bad ${n}`); + return n; +} +function ticker(name: string, turns: number): Promise { + let p = Promise.resolve(); + for (let i = 0; i < turns; i++) p = p.then(() => void log.push(`${name} tick ${i}`)); + return p; +} + +async function stackless(): Promise { + const a = await leaf(1); + const b = await leaf(a); + try { + await thrower(b); + } catch (e) { + log.push(`stackless caught ${(e as Error).name} ${(e as Error).message}`); + } + log.push(`stackless ${a + b}`); +} +async function* source(): AsyncGenerator { + yield 1; + yield 2; +} +async function fibered(): Promise { + let s = 0; + for await (const v of source()) s += await leaf(v); + try { + s += await thrower(5); + } catch (e) { + log.push(`fibered caught ${(e as Error).message}`); + } + log.push(`fibered ${s}`); +} +async function spawned(): Promise { + const p = leaf(7); + const q = thrower(0); + log.push("spawned before await"); + log.push(`spawned ${(await p) + (await q)}`); +} + +process.on("unhandledRejection", (reason) => console.log(`unhandled ${(reason as Error).message}`)); +async function main(): Promise { + void thrower(9); + await Promise.all([ticker("t", 6), stackless(), fibered(), spawned(), nestedLeaf(3)]); + for (const line of log) console.log(line); +} +void main(); diff --git a/tests/harness/corpus-costs/6953-fiberless-plain-calls.ts.json b/tests/harness/corpus-costs/6953-fiberless-plain-calls.ts.json new file mode 100644 index 000000000..93470e6d4 --- /dev/null +++ b/tests/harness/corpus-costs/6953-fiberless-plain-calls.ts.json @@ -0,0 +1 @@ +{ "plain": 3400, "san": 4500 } From 3e8f1bc608820219f71dcb14463d8d665e6bc1a5 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 21 Oct 2026 22:34:17 -0500 Subject: [PATCH 5/7] Renumber the fiberless plain-call corpus program to 7103 --- .../tests/corpus/6953-fiberless-plain-calls.ts.json | 6 ------ .../tests/corpus/7103-fiberless-plain-calls.ts.json | 6 ++++++ ...berless-plain-calls.ts => 7103-fiberless-plain-calls.ts} | 0 ...ain-calls.ts.json => 7103-fiberless-plain-calls.ts.json} | 0 4 files changed, 6 insertions(+), 6 deletions(-) delete mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6953-fiberless-plain-calls.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7103-fiberless-plain-calls.ts.json rename tests/corpus/{6953-fiberless-plain-calls.ts => 7103-fiberless-plain-calls.ts} (100%) rename tests/harness/corpus-costs/{6953-fiberless-plain-calls.ts.json => 7103-fiberless-plain-calls.ts.json} (100%) diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6953-fiberless-plain-calls.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6953-fiberless-plain-calls.ts.json deleted file mode 100644 index 2ac34ff94..000000000 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6953-fiberless-plain-calls.ts.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "order": [ - "/tests/corpus/6953-fiberless-plain-calls.ts" - ], - "diags": [] -} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7103-fiberless-plain-calls.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7103-fiberless-plain-calls.ts.json new file mode 100644 index 000000000..80fe9b85f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7103-fiberless-plain-calls.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7103-fiberless-plain-calls.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6953-fiberless-plain-calls.ts b/tests/corpus/7103-fiberless-plain-calls.ts similarity index 100% rename from tests/corpus/6953-fiberless-plain-calls.ts rename to tests/corpus/7103-fiberless-plain-calls.ts diff --git a/tests/harness/corpus-costs/6953-fiberless-plain-calls.ts.json b/tests/harness/corpus-costs/7103-fiberless-plain-calls.ts.json similarity index 100% rename from tests/harness/corpus-costs/6953-fiberless-plain-calls.ts.json rename to tests/harness/corpus-costs/7103-fiberless-plain-calls.ts.json From 56880e110cc87a26809ca0250873a07ec014457b Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 21 Oct 2026 23:19:57 -0500 Subject: [PATCH 6/7] Keep the slots of short known-count arrays in the array's block An array constructed with a known element count of up to 16 (array literals, copies, slices and the arrays the runtime builds) keeps its slots in its own block, as empty arrays built for appends already do, so it costs one allocation and one free. scr_arr_make_ops is inlined into its two constructors, so the size class of scr_arr_new_ops_small folds to a table index. It writes the element ops id with the other header fields and records ops past the registry in the array's ext after the storage is set up: the ext pointer precedes the dense allocation, so inline slots must move out before the ext is created. --- packages/runtime/src/scr_array.c | 62 ++++++++++++++++++++++------- packages/runtime/test/test_array.c | 64 +++++++++++++++++++++++++++++- 2 files changed, 109 insertions(+), 17 deletions(-) diff --git a/packages/runtime/src/scr_array.c b/packages/runtime/src/scr_array.c index c64b85947..c00d1b4f6 100644 --- a/packages/runtime/src/scr_array.c +++ b/packages/runtime/src/scr_array.c @@ -131,13 +131,6 @@ uint32_t scr_arr_ops_register(ScrArrOps *ops) { const ScrArrOps *scr_arr_ops_overflow(const ScrArr *a) { return scr_arr_ext_of(a)->ops; } -/* Record `ops` as the element ops of `a` (NULL for none). */ -static void scr_arr_set_ops(ScrArr *a, const ScrArrOps *ops) { - uint32_t id = scr_arr_ops_id(ops); - a->spare = (uint16_t)((a->spare & ((1u << SCR_ARR_OPS_SHIFT) - 1)) | (id << SCR_ARR_OPS_SHIFT)); - if (id == SCR_ARR_OPS_OVERFLOW) scr_arr_ext(a)->ops = ops; -} - /* Canonical array indices grow indexed storage and may leave holes. Negative, * fractional, NaN, infinity, and 2^32-1 numeric keys use the ordinary * property vector; only a missing property or a low-level read of a hole @@ -402,9 +395,10 @@ static void scr_arr_sparse_drop_prefix(ScrArr *a, size_t count) { #define SCR_ARR_MIN_GROWTH ((size_t)4) /* Inline slots: an array built by scr_arr_new_small / scr_arr_new_ops_small - * (empty literals the compiler sees appended to) keeps its first dense - * slots and states in its own block, right after the ScrArr, so it costs - * one allocation and its first elements share the header's cache lines. + * (empty literals the compiler sees appended to), or constructed with a + * known element count of up to SCR_ARR_KNOWN_INLINE_MAX, keeps its first + * dense slots and states in its own block, right after the ScrArr, so it + * costs one allocation and its first elements share the header's cache lines. * `spare` records it. Growth past the inline capacity moves the slots to an * ordinary allocation; the inline room then stays unused until the array is * freed. */ @@ -526,6 +520,30 @@ static size_t scr_arr_small_cap(size_t prefix, bool bools) { #endif } +/* The most slots a construction with a known element count (array + * literals, copies, slices and the arrays the runtime builds) keeps inline. + * Larger counts, and blocks past the small-object allocator, keep separate + * storage: an array that then grows leaves its inline room unused, which + * this bound keeps small. */ +#define SCR_ARR_KNOWN_INLINE_MAX ((size_t)16) + +/* Inline capacity of a construction of `need` elements with `prefix` bytes + * of block before the slots, rounded up to fill the block; 0 when its slots + * stay separate. */ +static size_t scr_arr_known_cap(size_t prefix, size_t need, bool bools) { + if (need == 0 || need > SCR_ARR_KNOWN_INLINE_MAX) return 0; +#if SCR_SMALL_ALLOC + size_t unit = bools ? SCR_ARR_BOOL_SLOT_BYTES : SCR_ARR_SLOT_BYTES; + size_t bytes = prefix + need * unit; + if (bytes > SCR_SA_MAX) return 0; + return (scr_sa_class_size(scr_sa_class(bytes)) - prefix) / unit; +#else + (void)prefix; + (void)bools; + return need; +#endif +} + static void scr_arr_use_inline(ScrArr *a, size_t cap) { a->data = (uint64_t *)((char *)a + SCR_ARR_INLINE_OFFSET); a->cap = (uint32_t)cap; @@ -1180,7 +1198,8 @@ static inline __attribute__((always_inline)) ScrArr *scr_arr_make(ScrElemKind el size_t initial_cap, bool small) { size_t unit = elem == SCR_ELEM_BOOL ? SCR_ARR_BOOL_SLOT_BYTES : SCR_ARR_SLOT_BYTES; - size_t inline_cap = small ? scr_arr_small_cap(SCR_ARR_INLINE_OFFSET, elem == SCR_ELEM_BOOL) : 0; + size_t inline_cap = small ? scr_arr_small_cap(SCR_ARR_INLINE_OFFSET, elem == SCR_ELEM_BOOL) + : scr_arr_known_cap(SCR_ARR_INLINE_OFFSET, initial_cap, elem == SCR_ELEM_BOOL); ScrArr *a = scr_mem_alloc(inline_cap ? SCR_ARR_INLINE_OFFSET + inline_cap * unit : sizeof(ScrArr)); if (!a) scr_arr_oom(); a->rc = 1; @@ -1310,11 +1329,17 @@ static void scr_arr_gc_free(void *a0) { scr_cyc_free_inline(a); } -static ScrArr *scr_arr_make_ops(const ScrArrOps *ops, size_t initial_cap, bool small) { +/* Inlined into each constructor like scr_arr_make: with `small` and the + * count constant, scr_arr_new_ops_small's size class folds to a table + * index. */ +static inline __attribute__((always_inline)) ScrArr *scr_arr_make_ops(const ScrArrOps *ops, + size_t initial_cap, + bool small) { ScrArr *a; bool cyc = ops->trace != NULL; + size_t prefix = SCR_ARR_INLINE_OFFSET + (cyc ? sizeof(ScrCycHdr) : 0); size_t inline_cap = - small ? scr_arr_small_cap(SCR_ARR_INLINE_OFFSET + (cyc ? sizeof(ScrCycHdr) : 0), false) : 0; + small ? scr_arr_small_cap(prefix, false) : scr_arr_known_cap(prefix, initial_cap, false); size_t size = inline_cap ? SCR_ARR_INLINE_OFFSET + inline_cap * SCR_ARR_SLOT_BYTES : sizeof(ScrArr); if (cyc) { static ScrCycType cyc_type = { &scr_arr_trace_v, &scr_arr_gc_free, SCR_CYC_KIND_ARRAY }; @@ -1328,12 +1353,19 @@ static ScrArr *scr_arr_make_ops(const ScrArrOps *ops, size_t initial_cap, bool s a->cap = 0; a->elem = SCR_ELEM_REF; a->cyc = cyc; - a->spare = 0; + /* The ops id is written with the other header fields: reading `spare` + * back after scr_arr_use_inline sets its inline flag stalls the + * store-to-load forward. */ + uint32_t id = scr_arr_ops_id(ops); + a->spare = (uint16_t)(id << SCR_ARR_OPS_SHIFT); a->filled = 0; a->data = NULL; - scr_arr_set_ops(a, ops); if (inline_cap) scr_arr_use_inline(a, inline_cap); else if (initial_cap > 0) scr_arr_grow_dense_to(a, initial_cap, SCR_ARR_GROW_EXACT); + /* After the storage: ops past the registry live in the ext, whose + * pointer precedes the dense allocation, so inline slots move out first + * (scr_arr_ext). */ + if (id == SCR_ARR_OPS_OVERFLOW) scr_arr_ext(a)->ops = ops; #ifdef SCR_RC_AUDIT scr_live_arrays++; #endif diff --git a/packages/runtime/test/test_array.c b/packages/runtime/test/test_array.c index 02da2fac2..d943acfb2 100644 --- a/packages/runtime/test/test_array.c +++ b/packages/runtime/test/test_array.c @@ -398,6 +398,8 @@ static MockRec *mock_cyc_new(int value) { static ScrArrOps mock_ops = { &mock_retain, &mock_release, NULL }; static ScrArrOps mock_cyc_ops = { &mock_cyc_retain, &mock_cyc_release, &mock_trace }; +/* First used after test_ops_overflow fills the registry: its arrays keep the ops in their ext. */ +static ScrArrOps mock_cyc_ops_late = { &mock_cyc_retain, &mock_cyc_release, &mock_trace }; static void test_ref_elements(void) { #ifdef SCR_RC_AUDIT @@ -514,6 +516,38 @@ static void test_ref_cycle(void) { #endif } +static void test_ref_known_count_cycle(ScrArrOps *ops) { +#ifdef SCR_RC_AUDIT + long arrays0 = scr_arr_live_count(); +#endif + /* A known-count construction of cycle-capable elements keeps its slots + * after the collector header: the collector traces them, and both the + * collected array and one released by its count free the one block. */ + ScrArr *arr = scr_arr_new_ops(ops, 2); + bool in_block = arr->data == (uint64_t *)((char *)arr + ((sizeof(ScrArr) + 7) & ~(size_t)7)); + /* Ops past the registry (once test_ops_overflow has filled it) live in + * the ext, which moves the slots to their own allocation. */ + bool overflow = (arr->spare >> SCR_ARR_OPS_SHIFT) == SCR_ARR_OPS_OVERFLOW; + check(arr->cap >= 2 && in_block == !overflow && scr_arr_ops(arr) == ops, + "known-count cycle-capable construction is inline unless its ops need the ext"); + MockRec *first = mock_cyc_new(1); + first->owner = (ScrArr *)scr_arr_retain(arr); + scr_arr_push_ref(arr, first); + scr_arr_push_ref(arr, mock_cyc_new(2)); + scr_arr_release(arr); + scr_collect_cycles(); + check(mock_live == 0, "cycle through an inline array collected"); + ScrArr *plain = scr_arr_new_ops(ops, 3); + for (int i = 0; i < 4; i++) scr_arr_push_ref(plain, mock_cyc_new(i)); + check(scr_arr_len(plain) == 4, "a cycle-capable inline array grows out of its block"); + scr_arr_release(plain); + scr_collect_cycles(); + check(mock_live == 0, "released inline cycle-capable array frees its elements"); +#ifdef SCR_RC_AUDIT + check(scr_arr_live_count() == arrays0, "known-count cycle: no arrays leaked"); +#endif +} + static void test_ref_truncate_cycle(void) { #ifdef SCR_RC_AUDIT long arrays0 = scr_arr_live_count(); @@ -1543,9 +1577,33 @@ static void test_inline_slots(void) { long strings0 = scr_str_live_count(); long arrays0 = scr_arr_live_count(); #endif - ScrArr *plain = scr_arr_new(SCR_ELEM_F64, 4); - check(!inline_slots(plain), "plain constructions keep separate storage"); + ScrArr *plain = scr_arr_new(SCR_ELEM_F64, 0); + check(!inline_slots(plain) && plain->data == NULL, "empty plain constructions allocate no slots"); scr_arr_release(plain); + /* A construction with a known count up to 16 slots keeps them in its own + * block; growth past them moves them out like any inline array. */ + ScrArr *known = scr_arr_new(SCR_ELEM_F64, 4); + check(inline_slots(known) && known->cap >= 4 && known->len == 0 && !scr_arr_has(known, 0) && + scr_arr_states_end(known) == (uint8_t *)(known->data + known->cap) + known->cap, + "a known-count construction places its slots in the array block"); + size_t known_cap = known->cap; + for (size_t i = 0; i <= known_cap; i++) scr_arr_push_f64(known, (double)i); + check(!inline_slots(known) && scr_arr_len(known) == known_cap + 1 && scr_arr_get_f64(known, 0) == 0 && + scr_arr_get_f64(known, (double)known_cap) == (double)known_cap, + "growth past a known-count construction moves the slots out"); + scr_arr_release(known); + ScrArr *sixteen = scr_arr_new(SCR_ELEM_STR, 16); + for (int i = 0; i < 16; i++) scr_arr_push_ref(sixteen, scr_str_new("s", 1)); + check(inline_slots(sixteen) && scr_arr_len(sixteen) == 16, "sixteen known slots stay inline"); + scr_arr_release(sixteen); + ScrArr *large = scr_arr_new(SCR_ELEM_F64, 17); + check(!inline_slots(large) && large->cap >= 17, "larger known counts keep separate storage"); + scr_arr_release(large); + ScrArr *known_flags = scr_arr_new(SCR_ELEM_BOOL, 5); + scr_arr_push_bool(known_flags, true); + check(inline_slots(known_flags) && known_flags->cap >= 5 && scr_arr_get_bool(known_flags, 0), + "known-count boolean constructions are inline too"); + scr_arr_release(known_flags); ScrArr *a = scr_arr_new_small(SCR_ELEM_F64); check(inline_slots(a) && a->cap >= 4 && a->len == 0 && scr_arr_states_end(a) == (uint8_t *)(a->data + a->cap) + a->cap, @@ -2205,7 +2263,9 @@ int main(int argc, char **argv) { test_nested_rc(); test_index_of_includes(); test_ref_elements(); + test_ref_known_count_cycle(&mock_cyc_ops); test_ops_overflow(); + test_ref_known_count_cycle(&mock_cyc_ops_late); test_ext_through_storage_changes(); test_ref_cycle(); test_ref_truncate_cycle(); From c22302cfae00dc5455c6089364d450c5f9164ffb Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 21 Oct 2026 23:22:03 -0500 Subject: [PATCH 7/7] Test runtime classes' dispatch branches before their subclasses' --- .../lowering/class-dynamic-dispatch.ts | 32 +++++++++++++++---- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts index a41751f01..c90e1135b 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts @@ -407,9 +407,27 @@ function withTypedReceiver(value: IrExpr, receiver: IrExpr): IrExpr { }); } -/** Re-adds the entries of `set` after the first `from` sorted. */ -function sortAdded(set: Set, from: number): void { - const added = [...set].slice(from).sort(); +/** Re-adds the classes of `set` after the first `from` in a fixed order: + * the program's classes by name, then the classes the runtime implements by + * name. The dispatch helpers add each class's branch in front of the + * earlier ones, so a runtime class's branch, which answers its methods for + * instances of the program's subclasses too, is tested before the + * subclasses' own branches. */ +function sortAdded(lowerer: Lowerer, set: Set, from: number): void { + const runtime = (name: string): boolean => { + const info = lowerer.classes.get(name); + return !!( + info && + (info.def.runtime || info.builtinEmitter || info.builtinStream || info.builtinError) + ); + }; + const added = [...set] + .slice(from) + .sort( + (left, right) => + Number(runtime(left)) - Number(runtime(right)) || + (left < right ? -1 : left > right ? 1 : 0), + ); for (const name of added) set.delete(name); for (const name of added) set.add(name); } @@ -932,10 +950,10 @@ export class ClassDynamicDispatch { return true; }, }); - // The classes this pass found join the tables by name, not in the order - // the scan met them. - sortAdded(this.boxed, boxedBefore); - sortAdded(this.boxedConstructors, boxedConstructorsBefore); + // The classes this pass found join the tables in a fixed order, not in + // the order the scan met them. + sortAdded(lowerer, this.boxed, boxedBefore); + sortAdded(lowerer, this.boxedConstructors, boxedConstructorsBefore); changed = this.caughtDispatch.dynamicPayloads(lowerer, caughtSites, this.boxed) || changed; if (this.boxed.size === 0 && rewrite.size === 0) return changed; const converting = this.convertingHints(lowerer);