From 4100cd3c047601e5a13f593f99a40894be3d8611 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 21 Oct 2026 22:08:30 -0500 Subject: [PATCH] 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 }