diff --git a/packages/compiler/src/backend/executable-features.ts b/packages/compiler/src/backend/executable-features.ts index 553643470..a565965f1 100644 --- a/packages/compiler/src/backend/executable-features.ts +++ b/packages/compiler/src/backend/executable-features.ts @@ -3,11 +3,13 @@ import { type IrModule } from "../ir/ir.js"; import { moduleRuntimeFeatures } from "../ir/runtime-features.js"; import { hasForeignFfiCallback } from "./ffi-callbacks.js"; import type { NativeLinkFeatures } from "./native-link-info.js"; +import { youngNurseryRequested } from "./young-nursery.js"; export function executableLinkFeatures(mod: IrModule): NativeLinkFeatures { const features = moduleRuntimeFeatures(mod); return { ...(mod.workers ? { workers: true } : {}), + ...(youngNurseryRequested() && mod.lib === undefined ? { youngNursery: true } : {}), regex: features.regex, copying: features.copying, textDecoderLegacy: features.legacyTextDecoder, diff --git a/packages/compiler/src/backend/llvm/classes.test.ts b/packages/compiler/src/backend/llvm/classes.test.ts index 309d8f071..d1dec3f97 100644 --- a/packages/compiler/src/backend/llvm/classes.test.ts +++ b/packages/compiler/src/backend/llvm/classes.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "vitest"; -import { F64, STRING, VOID, type IrModule } from "../../ir/ir.js"; +import { DYN, F64, STRING, VOID, type IrModule } from "../../ir/ir.js"; import { buildClassGraph, classMembershipIntervals, emitClassMembershipHelper } from "./classes.js"; import { emitLlvmModule } from "./emitter.js"; import { @@ -316,3 +316,52 @@ test("unconstructed narrowing layouts may omit abstract slots while live classes }); expect(() => emitLlvmModule(mod)).toThrow("no implementation of run for Dormant"); }); + +test("the young-in nursery flag adds the nursery entry to traced constructors only", () => { + const linked = { kind: "object" as const, className: "Linked" }; + const mod: IrModule = { + irVersion: 16, + sourceFile: loc.file, + entry: "main", + functions: [{ name: "main", params: [], returnType: VOID, locals: [], body: [], loc }], + classes: [ + { name: "Scalar", fields: [{ name: "value", type: F64 }], methods: [], loc }, + // A dyn field can reach anything: the class carries a collector header. + { + name: "Linked", + fields: [ + { name: "next", type: linked }, + { name: "hook", type: DYN }, + ], + methods: [], + loc, + }, + ], + }; + const helper = (llvm: string, name: string): string => { + const start = llvm.indexOf(`define internal ptr @${name}(`); + expect(start).toBeGreaterThanOrEqual(0); + return llvm.slice(start, llvm.indexOf("\n}\n", start)); + }; + const saved = process.env["SCRIPTC_YOUNG_NURSERY"]; + try { + delete process.env["SCRIPTC_YOUNG_NURSERY"]; + const off = emitLlvmModule(mod, { pointerBits: 64 }); + expect(off).not.toContain("scr_yin"); + + process.env["SCRIPTC_YOUNG_NURSERY"] = "1"; + const on = emitLlvmModule(mod, { pointerBits: 64 }); + const linkedNew = helper(on, mangleClassNew("Linked")); + expect(linkedNew).toContain("@scr_yin_skip"); + expect(linkedNew).toContain("call void @scr_yin_enter_slow(ptr %o)"); + expect(linkedNew).toContain("store i8 3, ptr %ye.genp"); + // An acyclic class has no collector header and never enters. + expect(helper(on, mangleClassNew("Scalar"))).not.toContain("scr_yin"); + // Targets without the inline allocator call the whole entry. + const narrow = emitLlvmModule(mod, { pointerBits: 32 }); + expect(helper(narrow, mangleClassNew("Linked"))).toContain("call void @scr_yin_enter(ptr %o)"); + } finally { + if (saved === undefined) delete process.env["SCRIPTC_YOUNG_NURSERY"]; + else process.env["SCRIPTC_YOUNG_NURSERY"] = saved; + } +}); diff --git a/packages/compiler/src/backend/llvm/classes.ts b/packages/compiler/src/backend/llvm/classes.ts index 717b08d3b..f5a882bbf 100644 --- a/packages/compiler/src/backend/llvm/classes.ts +++ b/packages/compiler/src/backend/llvm/classes.ts @@ -52,6 +52,7 @@ import { retainBody, traceAdapter, type ShapeHost, + youngEntryLines, } from "./shapes.js"; /** One virtual method slot of a hierarchy: the ROOT-MOST declaring class @@ -851,6 +852,11 @@ export function emitClassShapes( ); } nw.push(...undefFieldInits(host, meta, nullable)); + // Young instances: program-owned traced classes. Classes rooted at a + // runtime base (errors, emitters, events, streams) carry runtime state + // and stay with trial deletion, like every runtime family. + if (traced && !lifetime && (!meta.hierarchy || meta.root.def.runtime !== true)) + nw.push(...youngEntryLines(host)); nw.push(` ret ptr ${meta.hierarchy ? "%o.vt" : "%o"}`, `}`, ``); defs.push(...nw); diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 494b7a6c4..9e2d3524e 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -11,6 +11,7 @@ import { RECORD_SOURCES_GATE, WEAK_KEYS_GATE, } from "./alloc.js"; +import { youngNurseryRequested } from "../young-nursery.js"; import { hasLoneSurrogate, llvmBytes as llBytes, wtf8Bytes } from "../literals.js"; import { InternalCompilerError } from "../../errors.js"; import { createHash } from "node:crypto"; @@ -1463,6 +1464,7 @@ export class LlEmitter { ...(this.rcMode !== undefined ? { rcMode: this.rcMode } : {}), ...(this.rcHelpersNoinline ? { rcHelpersNoinline: true } : {}), nextCycKind: () => FIRST_EMITTED_CYC_KIND + this.cycKinds++, + ...(youngNurseryRequested() && mod.lib === undefined ? { youngNursery: true } : {}), unionsById: this.unionsById, declare: (decl) => this.declare(decl), needOom: () => this.needOom(), diff --git a/packages/compiler/src/backend/llvm/shapes.ts b/packages/compiler/src/backend/llvm/shapes.ts index d49df20b2..21f4fcb34 100644 --- a/packages/compiler/src/backend/llvm/shapes.ts +++ b/packages/compiler/src/backend/llvm/shapes.ts @@ -29,6 +29,7 @@ import { mangleRecordTrace, } from "../mangle.js"; import { + CYC_HDR, cycTypeDescriptor, emitObjectAlloc, emitObjectFree, @@ -58,6 +59,10 @@ import { import { recordMayKeepSource } from "./record-sources.js"; import { LlvmUnsupportedError } from "./unsupported.js"; +/** SCR_CYC_MAX_TYPES (scr_rc.h): the registry's size, and the length of + * the young-in nursery's per-id skip countdown. */ +const CYC_MAX_TYPES = 32768; + /** What the tables need from the emitter: the extern-declaration ledger * and the module-wide cycle/shape indexes. */ export interface ShapeHost { @@ -119,6 +124,67 @@ export interface ShapeHost { readonly tracedUnions: Set; /** The zeroed `%ScrUnion` an inline store reads for a NULL dummy. */ unionNoneRef(): string; + /** The young-in nursery (backend/young-nursery.ts): `new` hands each + * traced program-owned instance to scr_yin_enter. Absent: off. */ + readonly youngNursery?: boolean; +} + +/** The nursery entry of a new traced instance in `%o`, after its count is + * stored; nothing when the nursery is off. Where the allocator is inlined + * (64-bit, process-global state) the entry is inline too: a type that is + * skipping the nursery (its scr_yin_skip countdown is nonzero) counts down + * and stays out; otherwise, while the nursery has room (n < lim in ScrYin, + * scr_rc.h), list the object at index n and mark its header young + * (generation SCR_CYC_YOUNG, `buf` the buffered bit and the index), and + * call scr_yin_enter_slow when it has none (it collects the nursery + * first). Other targets call scr_yin_enter, which does all of it. + * Labels use the `ye.` prefix; the lines end in an open block. */ +export function youngEntryLines(host: ShapeHost): string[] { + if (host.youngNursery !== true) return []; + if (host.inlineAlloc !== true || host.threadLocalAlloc === true) { + host.declare(`declare void @scr_yin_enter(ptr)`); + return [` call void @scr_yin_enter(ptr %o) ; the young-in nursery`]; + } + host.declare(`@scr_yin = external global { ptr, i64, i64, i64 }`); + host.declare(`@scr_yin_skip = external global [${CYC_MAX_TYPES} x i32]`); + host.declare(`declare void @scr_yin_enter_slow(ptr)`); + return [ + ` %ye.idp = getelementptr inbounds i8, ptr %o, i64 -${CYC_HDR} ; the young-in nursery`, + ` %ye.id16 = load i16, ptr %ye.idp ; ScrCycHdr.id`, + ` %ye.id = zext i16 %ye.id16 to i64`, + ` %ye.sp = getelementptr inbounds [${CYC_MAX_TYPES} x i32], ptr @scr_yin_skip, i64 0, i64 %ye.id`, + ` %ye.s = load i32, ptr %ye.sp`, + ` %ye.skip = icmp ne i32 %ye.s, 0`, + ` br i1 %ye.skip, label %ye.skipped, label %ye.enter`, + `ye.skipped: ; a type that does not die young: counted down, stays out`, + ` %ye.s1 = sub i32 %ye.s, 1`, + ` store i32 %ye.s1, ptr %ye.sp`, + ` br label %ye.done`, + `ye.enter:`, + ` %ye.v = load ptr, ptr @scr_yin`, + ` %ye.np = getelementptr inbounds i8, ptr @scr_yin, i64 8`, + ` %ye.n = load i64, ptr %ye.np`, + ` %ye.lp = getelementptr inbounds i8, ptr @scr_yin, i64 16`, + ` %ye.l = load i64, ptr %ye.lp`, + ` %ye.room = icmp ult i64 %ye.n, %ye.l`, + ` br i1 %ye.room, label %ye.fast, label %ye.slow`, + `ye.fast:`, + ` %ye.slot = getelementptr inbounds ptr, ptr %ye.v, i64 %ye.n`, + ` store ptr %o, ptr %ye.slot`, + ` %ye.n1 = add i64 %ye.n, 1`, + ` store i64 %ye.n1, ptr %ye.np`, + ` %ye.genp = getelementptr inbounds i8, ptr %o, i64 -5`, + ` store i8 3, ptr %ye.genp ; ScrCycHdr.gen = SCR_CYC_YOUNG`, + ` %ye.bufp = getelementptr inbounds i8, ptr %o, i64 -4`, + ` %ye.i = trunc i64 %ye.n to i32`, + ` %ye.buf = or i32 %ye.i, -2147483648`, + ` store i32 %ye.buf, ptr %ye.bufp ; ScrCycHdr.buf = buffered bit | index`, + ` br label %ye.done`, + `ye.slow:`, + ` call void @scr_yin_enter_slow(ptr %o)`, + ` br label %ye.done`, + `ye.done:`, + ]; } /** What class emission asks of lifetime regions (LifetimeRegions in @@ -1622,6 +1688,7 @@ export function emitRecordShapes( ` store ptr %ovf, ptr %ovfp`, ); } + if (traced) nw.push(...youngEntryLines(host)); nw.push(` ret ptr %o`, `}`, ``); defs.push(...nw); diff --git a/packages/compiler/src/backend/native-link-info-core.ts b/packages/compiler/src/backend/native-link-info-core.ts index 8f69cea57..3beb84a20 100644 --- a/packages/compiler/src/backend/native-link-info-core.ts +++ b/packages/compiler/src/backend/native-link-info-core.ts @@ -16,6 +16,9 @@ import type { NativeTargetSpec } from "./targets.js"; export interface NativeLinkFeatures { workers?: boolean; + /** The young-in nursery (backend/young-nursery.ts): the SCR_YIN + * variant of the collector unit. Absent when off. */ + youngNursery?: boolean; regex: boolean; copying: boolean; textDecoderLegacy: boolean; diff --git a/packages/compiler/src/backend/native/contracts.ts b/packages/compiler/src/backend/native/contracts.ts index 5e981fe44..875a723bc 100644 --- a/packages/compiler/src/backend/native/contracts.ts +++ b/packages/compiler/src/backend/native/contracts.ts @@ -46,6 +46,8 @@ export interface CcOptions { frameworks?: readonly string[]; /** Isolated native Worker contexts; every runtime object must use TLS. */ workers?: boolean; + /** Compile the runtime with SCR_YIN (backend/young-nursery.ts). */ + youngNursery?: boolean; regex?: boolean; /** The program uses one of the copying/typed-array bridge intrinsics diff --git a/packages/compiler/src/backend/native/executable.ts b/packages/compiler/src/backend/native/executable.ts index 549e9ebbf..6c8673fe4 100644 --- a/packages/compiler/src/backend/native/executable.ts +++ b/packages/compiler/src/backend/native/executable.ts @@ -445,6 +445,7 @@ export async function compileCInternal( : [optimization === "dev" ? "-O0" : "-O2"]), ...executableSectionFlags.compile, ...(opts.textDecoderLegacy ? ["-DSCR_TEXT_DECODER_LEGACY"] : []), + ...(opts.youngNursery ? ["-DSCR_YIN"] : []), "-fno-math-errno", // The runtime object model uses type-punned C: a hierarchy // upcast is a raw pointer cast, so one object's header (rc, vt) and @@ -643,6 +644,7 @@ export async function compileCInternal( : [optimization === "dev" ? "-O0" : "-O2"]), ...executableSectionFlags.compile, ...(opts.textDecoderLegacy ? ["-DSCR_TEXT_DECODER_LEGACY"] : []), + ...(opts.youngNursery ? ["-DSCR_YIN"] : []), "-fno-math-errno", "-fno-strict-aliasing", // the emitted object model type-puns — see buildArgs "-Wno-deprecated-declarations", diff --git a/packages/compiler/src/backend/runtime-pack-core.ts b/packages/compiler/src/backend/runtime-pack-core.ts index 039ecc209..16fbc2a36 100644 --- a/packages/compiler/src/backend/runtime-pack-core.ts +++ b/packages/compiler/src/backend/runtime-pack-core.ts @@ -80,6 +80,7 @@ export interface RuntimePackManifest { export interface RuntimeFeatureSet extends NativeLinkFeatures { workers: boolean; + youngNursery: boolean; eventTarget: boolean; nativeFetch: boolean; netEffective: boolean; @@ -266,6 +267,7 @@ export function effectiveRuntimeFeatures( return { ...features, workers: features.workers === true, + youngNursery: features.youngNursery === true, eventTarget: features.eventTarget === true, nativeFetch, netEffective, diff --git a/packages/compiler/src/backend/young-nursery.ts b/packages/compiler/src/backend/young-nursery.ts new file mode 100644 index 000000000..090a4a6b9 --- /dev/null +++ b/packages/compiler/src/backend/young-nursery.ts @@ -0,0 +1,9 @@ +/** The young-in nursery (phase 1, experimental): compile-time opt-in with + * SCRIPTC_YOUNG_NURSERY=1. Executables then allocate compiled class and + * record instances young (the emitted constructors call scr_yin_enter) and + * link the collector unit built with SCR_YIN, which collects young cycles + * in nursery collections (scr_cycle.c, "young-in nursery"). Unset, nothing + * the compiler emits or links changes. */ +export function youngNurseryRequested(env: NodeJS.ProcessEnv = process.env): boolean { + return env["SCRIPTC_YOUNG_NURSERY"] === "1"; +} diff --git a/packages/compiler/src/executable/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts index fbd7e4edd..ee9d84fcf 100644 --- a/packages/compiler/src/executable/executable-cache.ts +++ b/packages/compiler/src/executable/executable-cache.ts @@ -49,6 +49,7 @@ export interface EarlyExecutableNativeFeatures { /** Omitted is the historical release posture. */ optimization?: "dev" | "speed"; workers?: boolean; + youngNursery?: boolean; regex: boolean; copying: boolean; textDecoderLegacy: boolean; @@ -111,6 +112,8 @@ export interface EarlyExecutableCacheOptions { windowsSubsystem?: "gui"; /** Omitted unless --detached-exit is set. */ detachedExit?: true; + /** Omitted unless SCRIPTC_YOUNG_NURSERY=1 (backend/young-nursery.ts). */ + youngNursery?: true; npmStatic: readonly string[] | "auto" | null; /** Raw manifest identity: path and bytes. Native archives remain under the * stricter native cache's independent dependency validation. */ @@ -200,7 +203,8 @@ function validNativeFeatures(value: unknown): value is EarlyExecutableNativeFeat (native.optimization === undefined || native.optimization === "dev" || native.optimization === "speed") && - (native.workers === undefined || typeof native.workers === "boolean"), + (native.workers === undefined || typeof native.workers === "boolean") && + (native.youngNursery === undefined || typeof native.youngNursery === "boolean"), ); } @@ -221,6 +225,7 @@ function cacheKey(options: EarlyExecutableCacheOptions): string { ...(options.strip ? ["strip"] : []), ...(options.windowsSubsystem === "gui" ? ["windows-subsystem-gui"] : []), ...(options.detachedExit ? ["detached-exit"] : []), + ...(options.youngNursery ? ["young-nursery"] : []), options.npmStatic === null ? "" : options.npmStatic === "auto" @@ -260,6 +265,7 @@ function routeKey(options: Omit; if (features["workers"] !== undefined && typeof features["workers"] !== "boolean") return false; + if (features["youngNursery"] !== undefined && typeof features["youngNursery"] !== "boolean") + return false; return [ "regex", "copying", diff --git a/packages/compiler/src/native/sanitizer.test.ts b/packages/compiler/src/native/sanitizer.test.ts index 9047f3376..79984d6c5 100644 --- a/packages/compiler/src/native/sanitizer.test.ts +++ b/packages/compiler/src/native/sanitizer.test.ts @@ -73,6 +73,7 @@ test.skipIf(process.platform !== "linux" && process.platform !== "darwin")( systemLibraries: [], features: { workers: false, + youngNursery: false, eventTarget: false, regex: true, copying: false, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5981-young-nursery-cycles.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5981-young-nursery-cycles.ts.json new file mode 100644 index 000000000..b5e72c495 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5981-young-nursery-cycles.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5981-young-nursery-cycles.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5982-young-nursery-workers.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5982-young-nursery-workers.ts.json new file mode 100644 index 000000000..77892fe94 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5982-young-nursery-workers.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5982-young-nursery-workers.ts" + ], + "diags": [] +} diff --git a/packages/runtime-darwin-arm64/runtime-pack-matrix.mjs b/packages/runtime-darwin-arm64/runtime-pack-matrix.mjs index d4607f694..5eec9b142 100644 --- a/packages/runtime-darwin-arm64/runtime-pack-matrix.mjs +++ b/packages/runtime-darwin-arm64/runtime-pack-matrix.mjs @@ -125,6 +125,15 @@ function variantsFor(source) { defines: ["SCR_TEXT_DECODER_LEGACY"], }); } + if (source === "scr_cycle.c") { + // The young-in nursery (SCRIPTC_YOUNG_NURSERY=1): the collector with + // nursery collections; programs built without it link the default. + variants.push({ + id: "young-nursery", + when: { youngNursery: true }, + defines: ["SCR_YIN"], + }); + } return variants; } diff --git a/packages/runtime-darwin-arm64/scripts/build.mjs b/packages/runtime-darwin-arm64/scripts/build.mjs index d5c8c3720..18ee441e6 100644 --- a/packages/runtime-darwin-arm64/scripts/build.mjs +++ b/packages/runtime-darwin-arm64/scripts/build.mjs @@ -343,7 +343,7 @@ async function build() { ...(unitHelper === null ? {} : { speed_codegen: unitHelper.identity }), }, macros: { - executable: ["SCR_TEXT_DECODER_LEGACY"], + executable: ["SCR_TEXT_DECODER_LEGACY", "SCR_YIN"], excluded: ["SCR_RC_AUDIT", "SCR_ASAN_FIBERS"], sanitizer: "external-toolchain-required", }, diff --git a/packages/runtime-pack-common/library-matrix.mjs b/packages/runtime-pack-common/library-matrix.mjs index 4f14d959b..106950941 100644 --- a/packages/runtime-pack-common/library-matrix.mjs +++ b/packages/runtime-pack-common/library-matrix.mjs @@ -27,7 +27,11 @@ export function withLibraryRuntimeFlavors(matrix) { }, ].map((unit) => ({ ...unit, - variants: unit.variants.filter((variant) => !variant.defines.includes("SCR_WORKERS")), + // Libraries run neither workers nor the young-in nursery (an + // executable-only experiment). + variants: unit.variants.filter( + (variant) => !variant.defines.includes("SCR_WORKERS") && !variant.defines.includes("SCR_YIN"), + ), })); const flavors = { ...matrix.flavors }; for (const [flavor, spec] of Object.entries(matrix.flavors)) { diff --git a/packages/runtime-pack-common/runtime-pack-matrix.mjs b/packages/runtime-pack-common/runtime-pack-matrix.mjs index 96add68b6..eab680a55 100644 --- a/packages/runtime-pack-common/runtime-pack-matrix.mjs +++ b/packages/runtime-pack-common/runtime-pack-matrix.mjs @@ -115,6 +115,15 @@ function variantsFor(source) { defines: ["SCR_TEXT_DECODER_LEGACY"], }); } + if (source === "scr_cycle.c") { + // The young-in nursery (SCRIPTC_YOUNG_NURSERY=1): the collector with + // nursery collections; programs built without it link the default. + variants.push({ + id: "young-nursery", + when: { youngNursery: true }, + defines: ["SCR_YIN"], + }); + } return variants; } diff --git a/packages/runtime-pack-common/scripts/build.mjs b/packages/runtime-pack-common/scripts/build.mjs index d1de57418..1bcd7455d 100644 --- a/packages/runtime-pack-common/scripts/build.mjs +++ b/packages/runtime-pack-common/scripts/build.mjs @@ -440,7 +440,7 @@ async function build() { ...(unitHelper === null ? {} : { speed_codegen: unitHelper.identity }), }, macros: { - executable: ["SCR_TEXT_DECODER_LEGACY"], + executable: ["SCR_TEXT_DECODER_LEGACY", "SCR_YIN"], excluded: ["SCR_RC_AUDIT", "SCR_ASAN_FIBERS"], sanitizer: "external-toolchain-required", }, diff --git a/packages/runtime-pack-common/test/library-matrix.test.ts b/packages/runtime-pack-common/test/library-matrix.test.ts index e78a2260c..8badca80f 100644 --- a/packages/runtime-pack-common/test/library-matrix.test.ts +++ b/packages/runtime-pack-common/test/library-matrix.test.ts @@ -61,6 +61,26 @@ test("library pack modes preserve pure runtime features and exclude ambient even expect(matrix.runtime_units.some((unit) => unit.source === "scr_library.c")).toBe(false); }); +test("the young-in nursery collector is an executable-only variant of scr_cycle.c", () => { + const ids = (units: { source: string; variants: { id: string }[] }[]) => + units.find((unit) => unit.source === "scr_cycle.c")!.variants.map((variant) => variant.id); + expect(ids(matrix.runtime_units)).toEqual([ + "default", + "default-workers", + "young-nursery", + "young-nursery-workers", + ]); + const young = matrix.runtime_units + .find((unit) => unit.source === "scr_cycle.c") + .variants.find((variant) => variant.id === "young-nursery"); + expect(young).toMatchObject({ + when: { youngNursery: true, workers: false }, + defines: ["SCR_YIN"], + }); + for (const mode of ["library-release", "library-thread-release"]) + expect(ids(matrix.flavors[mode].runtime_units)).toEqual(["default"]); +}); + test("mobile packs ship only library modes and their permitted vendor archives", () => { const mobile = createRuntimePackMatrix({ target: { object_format: "elf" }, diff --git a/packages/runtime/src/scr_cycle.c b/packages/runtime/src/scr_cycle.c index a2e7a191b..d700e6ba8 100644 --- a/packages/runtime/src/scr_cycle.c +++ b/packages/runtime/src/scr_cycle.c @@ -444,6 +444,22 @@ static SCR_TL unsigned scr_gen_limit = SCR_CYC_OLD; #endif static size_t scr_cyc_pass(unsigned gen_limit); +#ifdef SCR_YIN +/* The young-in nursery's state (see "young-in nursery" below): the nursery + * itself (scr_rc.h), every live young object at the index its header + * records; a collection's mark worklist, dead set and deferred releases; + * per-object counts of references from other young objects, valid during a + * collection. */ +SCR_TL ScrYin scr_yin = {0}; +static SCR_TL ScrVec scr_yin_stack; +static SCR_TL ScrVec scr_yin_dead; +static SCR_TL ScrVec scr_yin_xrel; +static SCR_TL uint32_t *scr_yin_counts; +static SCR_TL size_t scr_yin_counts_cap; +static SCR_TL bool scr_yin_busy; +static void scr_yin_leave(ScrCycHdr *h, void *obj); +static size_t scr_yin_collect(void); +#endif static void scr_cyc_route_bypassed(void); /* A limited pass (scr_cyc_collect_set) takes only the candidates in this * set from the buffers up to `scr_cyc_take_gen`; NULL takes them all. */ @@ -809,6 +825,12 @@ static SCR_CYC_SCHEDULING void scr_cyc_collect_due(void) { * per turn would walk the whole live heap every turn — which is exactly the * cost the generations exist to avoid. */ void scr_cyc_collect_scheduled(void) { +#ifdef SCR_YIN + /* A collection point also collects the nursery when anything entered it + * since its last collection, so young garbage does not wait for the + * allocation trigger in a program that has gone idle. */ + if (scr_yin.n != 0) scr_yin_collect(); +#endif scr_cyc_route_bypassed(); scr_cyc_note_fall(); if (scr_cyc_buffered() == 0) { @@ -996,6 +1018,13 @@ static SCR_CYC_COMPACT void scr_cyc_route_bypassed(void) { void scr_cyc_on_dead(void *obj) { ScrCycHdr *h = scr_cyc_hdr(obj); if (!scr_cyc_is_buffered(h)) return; +#ifdef SCR_YIN + /* A young object leaves the nursery; nothing else lists it. */ + if (scr_cyc_gen(h) == SCR_CYC_YOUNG) { + scr_yin_leave(h, obj); + return; + } +#endif /* O(1) removal: swap the last entry into the hole. A buffered object's * generation never changes (promotion runs only on objects the pass has * already drained from the buffers), so `gen` names the right one. */ @@ -1026,6 +1055,18 @@ static SCR_TL ScrVec scr_pub_holes[SCR_CYC_NGENS]; uint32_t scr_cyc_pub_take(void *obj) { ScrCycHdr *h = scr_cyc_hdr(obj); +#ifdef SCR_YIN + /* A young object (its header reads as buffered) leaves the nursery, + * unbuffered: young objects never become immortal in the nursery, and so + * never reach another thread from it. The answer UINT32_MAX makes + * scr_pub_mark's recorded position 0 ("not taken"), so a refused publish + * leaves it out of every buffer, as it was. It is reachable from the + * graph being published, so it needs no candidate. */ + if (scr_cyc_gen(h) == SCR_CYC_YOUNG) { + scr_yin_leave(h, obj); + return UINT32_MAX; + } +#endif unsigned gen = scr_cyc_gen(h); uint32_t index = scr_cyc_buf_index(h); scr_roots[gen].v[index] = NULL; @@ -1102,9 +1143,19 @@ void scr_cyc_on_release(void *obj) { * two must agree on which edges are internal. Nothing to do for NULL * (unassigned slots), immortal (interned statics have no header at all), or * a generation this pass does not collect. */ +#ifdef SCR_YIN +/* Trial deletion walks a young object as a nursery-level one: structures + * that mix young objects with families that are not young (arrays, + * closures) are found by the candidates of the latter, exactly as before. + * A young survivor stays young (scan_black promotes only below OLD) and a + * young member of the dead set leaves the nursery before its teardown. */ +#define SCR_CYC_WALK_GEN(h) (scr_cyc_gen(h) == SCR_CYC_YOUNG ? SCR_CYC_NURSERY : scr_cyc_gen(h)) +#else +#define SCR_CYC_WALK_GEN(h) scr_cyc_gen(h) +#endif #define SCR_CYC_SKIP(child) \ ((child) == NULL || SCR_RC(child) == SIZE_MAX \ - || scr_cyc_gen(scr_cyc_hdr(child)) > scr_gen_limit) + || SCR_CYC_WALK_GEN(scr_cyc_hdr(child)) > scr_gen_limit) /* The walks are depth-first and recursive up to SCR_CYC_WALK_DEPTH, where * an object is queued instead (scr_pending, or scr_restore_pending for the @@ -1147,7 +1198,7 @@ static inline __attribute__((always_inline)) void scr_mark_gray(void *obj, uintp static void scr_mg_visit(void *child, void *ctx) { if (child == NULL || SCR_RC(child) == SIZE_MAX) return; ScrCycHdr *h = scr_cyc_hdr(child); - if (scr_cyc_gen(h) > scr_gen_limit) { + if (SCR_CYC_WALK_GEN(h) > scr_gen_limit) { scr_cyc_push_work(&scr_xgen_edges, scr_gray_holder); scr_cyc_push_work(&scr_xgen_edges, child); return; @@ -1172,7 +1223,12 @@ static inline __attribute__((always_inline)) void scr_mark_gray(void *obj, uintp * leaves its buffer here, in the header write that numbers it anyway. A * limited pass can gray candidates it did not take; they keep their * buffer position and are simply never counted as members. */ - if (!scr_cyc_is_buffered(h) || (scr_gen_limit == SCR_CYC_OLD && !scr_cyc_take)) + if (!scr_cyc_is_buffered(h) || (scr_gen_limit == SCR_CYC_OLD && !scr_cyc_take +#ifdef SCR_YIN + /* a young object's `buf` is its nursery index */ + && scr_cyc_gen(h) != SCR_CYC_YOUNG +#endif + )) h->buf = scr_gray_comp; if (scr_white.n < SCR_CYC_GRAY_LIST_MAX) scr_cyc_push_gray(&scr_white, obj); if (SCR_RC(obj) > 0) { @@ -1322,6 +1378,340 @@ static SCR_CYC_COMPACT void scr_xg_release(void *obj) { scr_rc_destroy(obj, scr_xg_destroy); } +#ifdef SCR_YIN +/* ── young-in nursery ─────────────────────────────────────────────────── */ + +/* Programs built with the young-in nursery (SCRIPTC_YOUNG_NURSERY=1, which + * links this unit built with SCR_YIN) allocate compiled class and record + * instances YOUNG: the emitted constructor hands each new object to + * scr_yin_enter, which lists it in the nursery and marks its header + * SCR_CYC_YOUNG with its nursery index in `buf`, under the buffered bit. + * The bit makes every release path treat the object as an existing + * candidate: a release that leaves it alive makes no call, and its death + * calls scr_cyc_on_dead, which takes it out of the nursery + * (scr_yin_leave) before the teardown runs. Young objects are never + * candidates, but trial deletion walks them as nursery-level objects when + * a candidate reaches them (SCR_CYC_WALK_GEN). + * + * Whenever the nursery holds SCR_YOUNG_NURSERY_SIZE live objects (4096 by + * default), at collection points and before every full sweep, the nursery + * is collected on its own, in time proportional to its size and without + * walking anything older: + * count for every young object, the references to it from traced + * fields of other young objects (one trace per young object); + * mark an object whose count is below its reference count is + * referenced from outside the nursery (a stack, a global, an + * older object, a runtime structure, FFI, a fiber or a coroutine + * frame: every one of them holds a counted reference); mark + * from each such object through young edges; + * sweep what is unmarked is referenced only from unmarked young + * objects, which is garbage. Marked objects join trial + * deletion's nursery generation, unbuffered; dead ones are torn + * down, giving up only the edges that leave the dead set (like + * the white set's cross-generation edges, after every teardown). + * Survivors go to trial deletion's nursery rather than past it because the + * structures that survive are mostly held by families that are not young + * (arrays, closures, maps): a dead tree whose nodes are young and whose + * child arrays are not survives the nursery collection through the + * arrays, and the arrays' own candidates reach it there. + * Soundness rests on the two invariants trial deletion already needs: every + * strong reference is counted, and a trace visits exactly the counted + * references to headered children. The counts are recomputed at every + * collection, so no store barrier is needed and none can be missed. + * + * Candidates. A release of a young object never buffers it, so a structure + * whose last outside reference was dropped while its root was young has no + * candidate, and when the structure reaches beyond the nursery (a young + * node in an array that is not young, say), the nursery collection sees + * the outside edge as a root and keeps it. So every outside root a + * collection finds (count below the reference count) becomes a candidate + * as it is promoted. That suffices: every survivor is reachable from such + * a root, so if a survivor was already garbage, so was its root, and trial + * deletion reaches the survivor from the root's candidate; garbage formed + * after the promotion has a candidate from the release that formed it, + * since survivors are no longer young. */ +static void scr_cyc_trim(ScrVec *vec); +#define SCR_YIN_DEFAULT_SIZE 4096 +#define SCR_CYC_YIN_PROMOTED SCR_CYC_NURSERY + +/* Pushes onto the collection's work vectors, inline. */ +static inline __attribute__((always_inline)) void scr_yin_push(ScrVec *vec, void *obj) { + if (__builtin_expect(vec->n == vec->cap, 0)) scr_cyc_grow(vec); + vec->v[vec->n++] = obj; + if (vec->n > vec->used) vec->used = vec->n; +} +#define SCR_YIN_MAX_SIZE ((size_t)1 << 30) + +static size_t scr_yin_size(void) { + static SCR_TL size_t cached = 0; + if (cached == 0) { + const char *env = scr_getenv("SCR_YOUNG_NURSERY_SIZE"); + long v = env ? strtol(env, NULL, 10) : 0; + cached = v > 0 ? ((size_t)v < SCR_YIN_MAX_SIZE ? (size_t)v : SCR_YIN_MAX_SIZE) + : SCR_YIN_DEFAULT_SIZE; + } + return cached; +} + +/* Collections, survivors and dead objects, printed at exit with + * SCR_YOUNG_NURSERY_STATS=1 (measurement). */ +static SCR_TL size_t scr_yin_stat[3]; +static void scr_yin_stats_print(void) { + fprintf(stderr, "scriptc young nursery: %zu collections, %zu survivors, %zu dead\n", + scr_yin_stat[0], scr_yin_stat[1], scr_yin_stat[2]); +} + +/* Types that do not die in young cycles. Only a young object that dies in + * a nursery collection gains anything there; every other one costs: one + * that dies by reference counting makes a call to leave the nursery (where + * it made none before), one that is published is moved out of it, and one + * that survives a collection is kept by it, and becomes a trial-deletion + * candidate when an outside reference holds it. Structures held by + * families that are not young (tree nodes in child arrays, objects + * captured by closures) survive, and plain data dies by reference counting. + * So each thread keeps, per registry id, how many of the type's young + * objects left the nursery (by a collection or otherwise) and how many of + * them a collection freed, and after SCR_YIN_WINDOW of them a type freed + * for fewer than one in SCR_YIN_RATIO is allocated as before (trial + * deletion's nursery) for its next SCR_YIN_PROBE allocations, then + * enters again for a fresh window: the same bypass-and-probe scheme as the + * levels' bypass above. The countdown + * (scr_yin_skip, an ABI: emitted constructors test and decrement it + * inline) only steers which objects enter the nursery. */ +#define SCR_YIN_WINDOW 1024 +#define SCR_YIN_RATIO 4 +#define SCR_YIN_PROBE (64 * SCR_YIN_WINDOW) +#ifdef SCR_WORKERS +static _Thread_local uint32_t *scr_yin_skip_tl; +static uint32_t *scr_yin_skip_table(void) { + if (!scr_yin_skip_tl) { + scr_yin_skip_tl = calloc(SCR_CYC_MAX_TYPES, sizeof *scr_yin_skip_tl); + if (!scr_yin_skip_tl) scr_cyc_oom(); + } + return scr_yin_skip_tl; +} +#else +uint32_t scr_yin_skip[SCR_CYC_MAX_TYPES] = {0}; +static uint32_t *scr_yin_skip_table(void) { return scr_yin_skip; } +#endif +typedef struct { + uint32_t met, freed; +} ScrYinType; +/* SCR_YOUNG_NURSERY_ALL=1 keeps every type young (test lanes). */ +static bool scr_yin_all(void) { + static SCR_TL int cached = -1; + if (cached < 0) { + const char *env = scr_getenv("SCR_YOUNG_NURSERY_ALL"); + cached = env != NULL && env[0] == '1'; + } + return cached != 0; +} +static SCR_TL ScrYinType *scr_yin_types; + +static __attribute__((noinline, cold)) void scr_yin_decide(uint16_t id, ScrYinType *t) { + if (t->freed * SCR_YIN_RATIO < t->met && !scr_yin_all()) scr_yin_skip_table()[id] = SCR_YIN_PROBE; + t->met = t->freed = 0; +} +static inline __attribute__((always_inline)) void scr_yin_met(uint16_t id, bool dead) { + ScrYinType *t = &scr_yin_types[id]; + t->freed += dead; + if (__builtin_expect(++t->met >= SCR_YIN_WINDOW, 0)) scr_yin_decide(id, t); +} + +void scr_yin_enter_slow(void *obj); + +/* The whole nursery entry, for targets whose constructors call it. */ +void scr_yin_enter(void *obj) { + uint32_t *skip = &scr_yin_skip_table()[scr_cyc_hdr(obj)->id]; + if (*skip != 0) { + --*skip; + return; + } + scr_yin_enter_slow(obj); +} + +/* The slow half of the nursery entry (the emitted constructors inline the + * rest: skip, or append while n < lim). The nursery holds at most + * SCR_YOUNG_NURSERY_SIZE live young objects: the object that would exceed + * that collects the nursery first, then enters. */ +void scr_yin_enter_slow(void *obj) { + size_t size = scr_yin_size(); + if (!scr_yin_types) { + scr_yin_types = calloc(SCR_CYC_MAX_TYPES, sizeof *scr_yin_types); + if (!scr_yin_types) scr_cyc_oom(); + } + if (scr_yin.n >= size) { + static SCR_TL bool stats_checked = false; + if (!stats_checked) { + stats_checked = true; + const char *env = scr_getenv("SCR_YOUNG_NURSERY_STATS"); + if (env && env[0] == '1') atexit(scr_yin_stats_print); + } + scr_yin_collect(); + } + if (scr_yin.n == scr_yin.cap) { + /* Positions are 31-bit (ScrCycHdr.buf); the size is capped below. */ + size_t cap = scr_yin.cap ? scr_yin.cap * 2 : 1024; + void **grown = realloc(scr_yin.v, cap * sizeof *grown); + if (!grown) scr_cyc_oom(); + scr_yin.v = grown; + scr_yin.cap = cap; + } + scr_yin.lim = scr_yin.cap < size ? scr_yin.cap : size; + ScrCycHdr *h = scr_cyc_hdr(obj); + scr_cyc_set_gen(h, SCR_CYC_YOUNG); + scr_cyc_set_buffered(h, (uint32_t)scr_yin.n); + scr_yin.v[scr_yin.n++] = obj; +} + +/* O(1) removal: the last young object takes the hole. */ +static void scr_yin_leave(ScrCycHdr *h, void *obj) { + size_t i = scr_cyc_buf_index(h); +#ifdef SCR_RC_AUDIT + if (i >= scr_yin.n || scr_yin.v[i] != obj) scr_trap("scriptc: young object outside the nursery\n"); +#endif + void *last = scr_yin.v[--scr_yin.n]; + scr_yin.v[i] = last; + if (last != obj) scr_cyc_set_buffered(scr_cyc_hdr(last), (uint32_t)i); + scr_cyc_clear_buffered(h); + scr_cyc_set_gen(h, SCR_CYC_NURSERY); + scr_yin_met(h->id, false); +} + +static void scr_yin_count_visit(void *child, void *ctx) { + (void)ctx; + if (child == NULL || SCR_RC(child) == SIZE_MAX) return; + ScrCycHdr *h = scr_cyc_hdr(child); + if (scr_cyc_gen(h) == SCR_CYC_YOUNG) scr_yin_counts[scr_cyc_buf_index(h)]++; +} + +/* Marked young objects are GRAY (young objects are otherwise BLACK). */ +static void scr_yin_mark_visit(void *child, void *ctx) { + (void)ctx; + if (child == NULL || SCR_RC(child) == SIZE_MAX) return; + ScrCycHdr *h = scr_cyc_hdr(child); + if (scr_cyc_gen(h) != SCR_CYC_YOUNG || scr_cyc_color(h) == SCR_CYC_GRAY) return; + scr_cyc_set_color(h, SCR_CYC_GRAY); + scr_yin_push(&scr_yin_stack, child); +} + +/* A dead object's edge: young and unmarked is dead too (nothing to give + * up); a marked young object (promoted now or soon) or an older one keeps + * the count until every teardown has run. */ +static void scr_yin_dead_visit(void *child, void *ctx) { + (void)ctx; + if (child == NULL || SCR_RC(child) == SIZE_MAX) return; + ScrCycHdr *h = scr_cyc_hdr(child); + if (scr_cyc_gen(h) == SCR_CYC_YOUNG && scr_cyc_color(h) != SCR_CYC_GRAY) return; + scr_yin_push(&scr_yin_xrel, child); +} + +/* Fully optimized, unlike trial deletion's per-pass bookkeeping: its loops + * run once per young object. */ +static __attribute__((noinline)) size_t scr_yin_collect(void) { + if (scr_yin_busy || scr_collecting) return 0; + size_t n = scr_yin.n; + if (n == 0) return 0; + /* What died outside passes so far counts toward the death rule; what this + * collection frees does not (the reading is refreshed at its end). */ + scr_cyc_note_fall(); + scr_yin_busy = true; + /* Releases made by teardowns below buffer candidates but must not start + * a trial-deletion pass while the dead set is half torn down, and take + * the call, as during a pass (scr_cyc_window_update). */ + scr_collecting = true; + scr_cyc_window_update(); + if (n > scr_yin_counts_cap) { + size_t cap = scr_yin_counts_cap ? scr_yin_counts_cap * 2 : 1024; + while (cap < n) cap *= 2; + uint32_t *counts = realloc(scr_yin_counts, cap * sizeof *counts); + if (!counts) scr_cyc_oom(); + scr_yin_counts = counts; + scr_yin_counts_cap = cap; + } + memset(scr_yin_counts, 0, n * sizeof *scr_yin_counts); + void **v = scr_yin.v; + for (size_t k = 0; k < n; k++) + scr_cyc_type(scr_cyc_hdr(v[k]))->trace(v[k], scr_yin_count_visit, NULL); +#ifdef SCR_RC_AUDIT + /* Membership and counts: every listed object is young at its index and + * alive, and holds at least the references young objects make to it + * (fewer would mean an uncounted reference). */ + for (size_t k = 0; k < n; k++) { + ScrCycHdr *h = scr_cyc_hdr(v[k]); + if (scr_cyc_gen(h) != SCR_CYC_YOUNG || !scr_cyc_is_buffered(h) || scr_cyc_buf_index(h) != k || + scr_cyc_color(h) != SCR_CYC_BLACK) + scr_trap("scriptc: corrupt young nursery entry\n"); + if (SCR_RC(v[k]) == 0 || SCR_RC(v[k]) < scr_yin_counts[k]) + scr_trap("scriptc: young object with uncounted references\n"); + } +#endif + for (size_t k = 0; k < n; k++) { + void *obj = v[k]; + ScrCycHdr *h = scr_cyc_hdr(obj); + if (scr_cyc_color(h) == SCR_CYC_GRAY || SCR_RC(obj) <= scr_yin_counts[k]) continue; + scr_cyc_set_color(h, SCR_CYC_GRAY); + scr_yin_push(&scr_yin_stack, obj); + while (scr_yin_stack.n) { + void *x = scr_yin_stack.v[--scr_yin_stack.n]; + scr_cyc_type(scr_cyc_hdr(x))->trace(x, scr_yin_mark_visit, NULL); + } + } + /* Split: promote the marked objects; record the dead ones and their + * edges to survivors and older objects, which are given up after every + * teardown (until then each holds the count it was made with, so no + * teardown's releases can free a target early). Edges between dead + * objects need nothing: both go. No block is freed before every dead + * object has been traced. */ + scr_yin_dead.n = 0; + scr_yin_xrel.n = 0; + for (size_t k = 0; k < n; k++) { + void *obj = v[k]; + ScrCycHdr *h = scr_cyc_hdr(obj); + if (scr_cyc_color(h) == SCR_CYC_GRAY) { + scr_cyc_set_color(h, SCR_CYC_BLACK); + scr_cyc_set_gen(h, SCR_CYC_YIN_PROMOTED); + scr_cyc_clear_buffered(h); + /* An outside root becomes a candidate (see "candidates" above). */ + if (SCR_RC(obj) > scr_yin_counts[k]) scr_cyc_on_release(obj); + scr_yin_met(h->id, false); + } else { + scr_yin_met(h->id, true); + scr_cyc_set_color(h, SCR_CYC_DOOMED); + scr_yin_push(&scr_yin_dead, obj); + scr_cyc_type(h)->trace(obj, scr_yin_dead_visit, NULL); + } + } + scr_yin.n = 0; + scr_yin_stat[0]++; + scr_yin_stat[1] += n - scr_yin_dead.n; + scr_yin_stat[2] += scr_yin_dead.n; + size_t freed = scr_yin_dead.n; + for (size_t i = 0; i < scr_yin_dead.n; i++) { + void *obj = scr_yin_dead.v[i]; + scr_cyc_type(scr_cyc_hdr(obj))->free_fn(obj); + } + scr_yin_dead.n = 0; + scr_xg_freed = 0; + for (size_t i = 0; i < scr_yin_xrel.n; i++) scr_xg_release(scr_yin_xrel.v[i]); + freed += scr_xg_freed; + scr_yin_xrel.n = 0; + scr_cyc_trim(&scr_yin_stack); + scr_cyc_trim(&scr_yin_dead); + scr_cyc_trim(&scr_yin_xrel); + scr_cyc_live_seen = scr_cyc_live; + scr_collecting = false; + scr_cyc_window_update(); + scr_yin_busy = false; + /* The candidates buffered above count toward trial deletion's trigger, + * which only a release checks; a program whose releases are all of + * young objects would otherwise never reach it. */ + if (scr_cyc_buffered() >= scr_cyc_trigger || scr_cyc_live >= scr_cyc_alloc_trigger) + scr_cyc_collect_due(); + return freed; +} +#endif + /* Dead sets at least this large are torn down in address order. Smaller * ones mostly stay in cache, where ordering them costs more than it saves. */ #define SCR_CYC_ORDER_MIN 16384 @@ -1430,6 +1820,9 @@ static SCR_CYC_COMPACT size_t scr_cyc_settle(unsigned gen_limit, bool doomed) { const ScrCycType *type = scr_cyc_type(h); if (gen_limit < SCR_CYC_OLD) scr_cyc_freed(type, gen_limit); else scr_cyc_mature_dead += scr_cyc_gen(h) == SCR_CYC_MATURE; +#ifdef SCR_YIN + if (scr_cyc_gen(h) == SCR_CYC_YOUNG) scr_yin_leave(h, obj); +#endif type->free_fn(obj); } scr_white.n = 0; @@ -1699,9 +2092,16 @@ void scr_cyc_route_bypassed_at(void) { scr_cyc_route_bypassed(); } size_t scr_cyc_collect_set(const ScrCycSet *set, unsigned gen) { if (scr_collecting) return 0; +#ifdef SCR_YIN + /* Young objects are invisible to trial deletion: collect them first, so + * survivors are promoted where the limited pass can walk them. */ + size_t young = scr_yin_collect(); +#else + size_t young = 0; +#endif scr_cyc_take = set; scr_cyc_take_gen = gen; - size_t freed = scr_cyc_pass(SCR_CYC_OLD); + size_t freed = young + scr_cyc_pass(SCR_CYC_OLD); scr_cyc_take = NULL; return freed; } @@ -1715,6 +2115,11 @@ void scr_collect_cycles(void) { * can have buffered anything new, so a pass that comes up empty ends * this; and since each repeat needs a strictly smaller live heap to * continue, it terminates. */ +#ifdef SCR_YIN + /* The nursery first: afterwards no young object is left (a teardown + * never allocates one), so the passes see every object. */ + scr_yin_collect(); +#endif while (scr_cyc_pass(SCR_CYC_OLD) && scr_cyc_buffered()) { } } @@ -1733,6 +2138,24 @@ void scr_cyc_context_cleanup(void) { free(scr_cyc_kinds); scr_cyc_kinds = NULL; scr_cyc_kinds_cap = 0; +#ifdef SCR_YIN + ScrVec *young[] = {&scr_yin_stack, &scr_yin_dead, &scr_yin_xrel}; + for (size_t i = 0; i < sizeof young / sizeof young[0]; i++) { + free(young[i]->v); + *young[i] = (ScrVec){0}; + } + free(scr_yin.v); + scr_yin = (ScrYin){0}; + free(scr_yin_types); + scr_yin_types = NULL; +#ifdef SCR_WORKERS + free(scr_yin_skip_tl); + scr_yin_skip_tl = NULL; +#endif + free(scr_yin_counts); + scr_yin_counts = NULL; + scr_yin_counts_cap = 0; +#endif #if SCR_CYC_INLINE_BUFFERING scr_cyc_nursery_bypassing = 0; #endif diff --git a/packages/runtime/src/scr_rc.h b/packages/runtime/src/scr_rc.h index faac6987a..eb28fb2e7 100644 --- a/packages/runtime/src/scr_rc.h +++ b/packages/runtime/src/scr_rc.h @@ -607,4 +607,36 @@ SCR_COLD void scr_throw_published_field(const char *prop, const char *owner); bool scr_throw_published_array(int op, const struct ScrArr *a, double index, double n); SCR_COLD void scr_throw_published_collection(bool set); +/* Young-in nursery (scr_cycle.c built with SCR_YIN, linked by programs + * compiled with SCRIPTC_YOUNG_NURSERY=1). A young object's header has + * generation SCR_CYC_YOUNG, above every trial-deletion level, and its + * nursery index in `buf` under SCR_CYC_BUFFERED_BIT, so release paths treat + * it as a buffered candidate (no call on a release that leaves it alive, + * scr_cyc_on_dead at its death). Emitted constructors of compiled classes + * and records call scr_yin_enter on each new object, after storing its + * count; the call may collect the nursery first. */ +#define SCR_CYC_YOUNG 3 +void scr_yin_enter(void *obj); +/* The inline entry's halves: the per-registry-id countdown of allocations + * that skip the nursery (process-wide in executables without workers, + * which are the ones that inline the entry), and the append when the + * nursery has no room. */ +extern uint32_t scr_yin_skip[SCR_CYC_MAX_TYPES]; +void scr_yin_enter_slow(void *obj); +/* The nursery: live young objects v[0..n), capacity cap. Its layout is an + * ABI: emitted constructors (llvm/shapes.ts youngEntryLines) append inline + * while n < lim, writing the header themselves, and call scr_yin_enter + * otherwise; lim stays at most the capacity and the collection size. */ +typedef struct ScrYin { + void **v; + size_t n; + size_t lim; + size_t cap; +} ScrYin; +extern SCR_TL ScrYin scr_yin; +#if UINTPTR_MAX == UINT64_MAX +_Static_assert(offsetof(ScrYin, n) == 8 && offsetof(ScrYin, lim) == 16, + "llvm/shapes.ts appends to the nursery at these offsets"); +#endif + #endif /* SCR_RC_H */ diff --git a/packages/runtime/test/test_yin.c b/packages/runtime/test/test_yin.c new file mode 100644 index 000000000..3b746a435 --- /dev/null +++ b/packages/runtime/test/test_yin.c @@ -0,0 +1,442 @@ +/* The young-in nursery (scr_cycle.c built with SCR_YIN): young objects + * allocated as emitted constructors allocate them, released as emitted + * release paths release them, and collected by nursery collections, trial + * deletion and the full sweep. Every check must hold for any nursery size + * (SCR_YOUNG_NURSERY_SIZE), including a one-object nursery that collects at + * every allocation. */ +#include "../src/scr_runtime.h" + +#include +#include +#include + +typedef struct Plain { + size_t rc; +} Plain; + +typedef struct Obj Obj; +struct Obj { + size_t rc; + Obj *a; + Obj *b; + Plain *plain; /* untraced: released by the teardown, never visited */ +}; + +static size_t freed, plain_freed, disposed; + +static void check(bool condition, const char *message) { + if (condition) return; + fprintf(stderr, "young nursery test failed: %s\n", message); + exit(EXIT_FAILURE); +} + +_Noreturn void scr_trap(const char *msg) { + fputs(msg, stderr); + exit(EXIT_FAILURE); +} + +size_t scr_cyc_pass_at(unsigned gen); + +static void plain_release(Plain *p) { + if (p && --p->rc == 0) { + plain_freed++; + free(p); + } +} + +static void obj_trace(void *o, ScrTraceVisit visit, void *ctx) { + Obj *x = o; + if (x->a) visit(x->a, ctx); + if (x->b) visit(x->b, ctx); +} + +/* The collector's teardown: untraced children only, then the block. */ +static void obj_gcfree(void *o) { + plain_release(((Obj *)o)->plain); + freed++; + scr_cyc_free(o); +} + +static ScrCycType young_type = {&obj_trace, &obj_gcfree, 0, 0}; +static ScrCycType mature_type = {&obj_trace, &obj_gcfree, 0, 0}; + +/* An emitted constructor: zeroed headered block, rc = 1, then the nursery. */ +static Obj *young_new_of(ScrCycType *type) { + Obj *o = scr_cyc_alloc(sizeof(Obj), type); + o->rc = 1; + scr_yin_enter(o); + return o; +} +static Obj *young_new(void) { return young_new_of(&young_type); } + +/* A runtime family that is not young (gen 0, trial deletion only). */ +static Obj *mature_new(void) { + Obj *o = scr_cyc_alloc(sizeof(Obj), &mature_type); + o->rc = 1; + return o; +} + +static void release(Obj *o); +static void destroy(void *p) { + Obj *o = p; + release(o->a); + release(o->b); + plain_release(o->plain); + freed++; + scr_cyc_free(o); +} + +/* An emitted release: the buffered-bit tests are what young objects rely on. */ +static void release(Obj *o) { + if (!o || o->rc == SIZE_MAX) return; + if (--o->rc == 0) { + scr_cyc_dead(o); + scr_rc_destroy(o, destroy); + return; + } + scr_cyc_released(o); +} + +static Obj *retain(Obj *o) { + if (o && o->rc != SIZE_MAX) o->rc++; + return o; +} + +/* h->a or h->b = v (v borrowed): unlink, then release the old value. */ +static void store(Obj *h, int slot, Obj *v) { + retain(v); + Obj **f = slot == 0 ? &h->a : &h->b; + Obj *old = *f; + *f = v; + release(old); +} + +static unsigned gen_of(Obj *o) { return scr_cyc_gen(scr_cyc_hdr(o)); } +static bool buffered(Obj *o) { return scr_cyc_is_buffered(scr_cyc_hdr(o)); } + +static void count_dispose(void *obj) { + (void)obj; + disposed++; +} + +/* Everything collectable is collected and the live count is back. */ +static void settle(size_t live, const char *what) { + scr_collect_cycles(); + if (scr_cyc_live != live) { + fprintf(stderr, "live %zu, expected %zu\n", scr_cyc_live, live); + check(false, what); + } +} + +/* With a nursery of a few objects, a pair's first object is promoted before + * the pair is linked, and the pair then waits for an older level's pass: + * the exact checks need room for the pair. */ +static bool small_nursery(void) { + const char *size = getenv("SCR_YOUNG_NURSERY_SIZE"); + return size != NULL && atol(size) < 4; +} + +static void check_dead_pair_and_live_ring(void) { + size_t live = scr_cyc_live, before = freed; + Obj *a = young_new(), *b = young_new(); + store(a, 0, b); + store(b, 0, a); + release(a); + release(b); + check(freed == before, "a young cycle died by reference counting"); + + enum { RING = 10 }; + Obj *head = young_new(), *last = retain(head); + for (int i = 1; i < RING; i++) { + Obj *n = young_new(); + store(last, 0, n); + release(last); + last = n; + } + store(last, 0, head); + release(last); + settle(live + RING, "dead young pair not collected or live ring freed"); + check(freed == before + 2, "dead young pair not freed exactly"); + check(gen_of(head) != SCR_CYC_YOUNG && !buffered(head), "survivor not promoted unbuffered"); + release(head); /* an older release buffers the ring (and may collect it) */ + settle(live, "promoted ring not collected after its last reference"); + check(freed == before + 2 + RING, "promoted ring not freed exactly"); +} + +static void check_rc_deaths_leave_the_nursery(void) { + enum { N = 300 }; + size_t live = scr_cyc_live, before = freed; + Obj *objs[N]; + for (int i = 0; i < N; i++) objs[i] = young_new(); + /* Pairs (i, i+1) for even i; every third object dies by RC first. */ + for (int i = 0; i + 1 < N; i += 2) { + store(objs[i], 0, objs[i + 1]); + store(objs[i + 1], 0, objs[i]); + } + size_t rc_dead = 0; + for (int i = 0; i < N; i += 6) { + /* break the pair, then drop both: plain RC deaths of young objects */ + store(objs[i], 0, NULL); + store(objs[i + 1], 0, NULL); + release(objs[i]); + release(objs[i + 1]); + objs[i] = objs[i + 1] = NULL; + rc_dead += 2; + } + check(freed == before + rc_dead, "young RC deaths not freed at once"); + /* Keep every fourth surviving pair alive from outside; drop the rest. */ + Obj *kept[N]; + size_t nkept = 0, dropped = 0; + for (int i = 0; i < N; i += 2) { + if (!objs[i]) continue; + if ((i / 2) % 4 == 0) kept[nkept++] = objs[i]; + else { + release(objs[i]); + dropped += 2; + } + release(objs[i + 1]); + } + settle(live + 2 * nkept, "young pairs not collected after RC deaths"); + check(freed == before + rc_dead + dropped, "dead pairs not freed exactly"); + for (size_t k = 0; k < nkept; k++) { + check(gen_of(kept[k]) != SCR_CYC_YOUNG, "kept pair not promoted"); + release(kept[k]); + } + settle(live, "kept pairs not collected after release"); +} + +static void check_dead_set_gives_up_outgoing_edges(void) { + size_t live = scr_cyc_live, before = freed, plain_before = plain_freed; + Obj *m = mature_new(); /* held from outside, and by the dead set */ + Obj *s = young_new(); /* survivor held from outside, and by the dead set */ + Obj *d1 = young_new(), *d2 = young_new(); + Plain *p = calloc(1, sizeof *p); + p->rc = 1; + d1->plain = p; + store(d1, 0, d2); + store(d2, 0, d1); + store(d1, 1, m); + store(d2, 1, s); + release(d1); + release(d2); + check(m->rc == 2 && s->rc == 2, "setup counts"); + settle(live + 2, "dead set with outgoing edges not collected"); + check(freed == before + 2, "only the dead pair is freed"); + check(plain_freed == plain_before + 1, "untraced child of the dead set not released"); + check(m->rc == 1 && s->rc == 1, "dead set's outgoing edges not given up exactly once"); + check(gen_of(s) != SCR_CYC_YOUNG, "survivor not promoted"); + release(m); + release(s); + settle(live, "outside objects not freed at their last release"); +} + +static void check_older_holders_keep_young_alive(void) { + size_t live = scr_cyc_live, before = freed; + Obj *m = mature_new(); + Obj *y1 = young_new(), *y2 = young_new(); + store(y1, 0, y2); + store(y2, 0, y1); + store(m, 0, y1); + release(y1); + release(y2); + settle(live + 3, "young cycle held by an older object was freed"); + check(freed == before, "nothing may be freed while the holder lives"); + release(m); /* m dies by RC; its release of y1 buffers the promoted cycle */ + settle(live, "cycle not collected after its older holder died"); + check(freed == before + 3, "holder and cycle not freed exactly"); +} + +static void check_trial_deletion_releases_into_the_nursery(void) { + size_t live = scr_cyc_live, before = freed; + Obj *m1 = mature_new(), *m2 = mature_new(); + Obj *y = young_new(), *z = young_new(); + store(m1, 0, m2); + store(m2, 0, m1); + store(m1, 1, y); /* y's only reference: dies when the white set goes */ + store(m2, 1, z); /* z keeps an outside reference */ + release(y); + release(m1); + release(m2); + /* A trial-deletion pass alone walks the young objects at the nursery + * level: y joins the white set (and leaves the nursery), z survives. */ + scr_cyc_pass_at(SCR_CYC_OLD); + check(freed == before + 3, "white set and its only-held young child not freed"); + check(z->rc == 1, "outside-held young child damaged"); + check(small_nursery() || gen_of(z) == SCR_CYC_YOUNG, "trial deletion promoted a young survivor"); + release(z); + settle(live, "nursery inconsistent after trial deletion freed young objects"); + check(freed == before + 4, "last young object not freed"); + + /* A young cycle held only by a dead older cycle: one pass frees all. */ + before = freed; + m1 = mature_new(); + m2 = mature_new(); + Obj *y1 = young_new(), *y2 = young_new(); + store(y1, 0, y2); + store(y2, 0, y1); + store(m1, 0, m2); + store(m2, 0, m1); + store(m1, 1, y1); + release(y1); + release(y2); + release(m1); + release(m2); + scr_cyc_pass_at(SCR_CYC_OLD); + check(freed == before + 4, "mixed dead structure not freed by one pass"); + settle(live, "nursery inconsistent after a mixed dead structure"); +} + +/* A dead structure whose root died young and that reaches beyond the + * nursery: the nursery collection keeps it (an older object holds a young + * member) and must leave a candidate, or nothing short of a full sweep + * would ever reach it. */ +static void check_dead_structures_beyond_the_nursery(void) { + size_t live = scr_cyc_live, before = freed; + Obj *r = young_new(); + Obj *a = mature_new(); /* a family that is not young: an array, say */ + Obj *n = young_new(); + r->a = a; /* moved in, never released: a is no candidate */ + store(a, 0, n); + store(n, 0, r); + release(n); + release(r); /* the last outside reference, dropped while r is young */ + for (int i = 0; i < 1024 && freed == before; i++) scr_cyc_collect_scheduled(); + check(freed == before + 3, "dead structure reaching beyond the nursery never collected"); + settle(live, "structure beyond the nursery"); +} + +/* A type whose objects keep surviving stops entering the nursery for a + * while, then is met again. */ +static void check_types_that_do_not_die_young(void) { + enum { KEPT = 5000, PROBE = 64 * 1024 }; + static ScrCycType kept_type = {&obj_trace, &obj_gcfree, 0, 0}; + size_t live = scr_cyc_live; + Obj **kept = malloc(KEPT * sizeof *kept); + for (int i = 0; i < KEPT; i++) kept[i] = young_new_of(&kept_type); + scr_collect_cycles(); /* meets them all: none dies */ + Obj *out = young_new_of(&kept_type); + check(gen_of(out) != SCR_CYC_YOUNG && !buffered(out), "surviving type still enters the nursery"); + release(out); + bool met_again = false; + for (int i = 0; i < 2 * PROBE && !met_again; i++) { + Obj *probe = young_new_of(&kept_type); + met_again = gen_of(probe) == SCR_CYC_YOUNG; + release(probe); + } + check(met_again, "surviving type never met again"); + for (int i = 0; i < KEPT; i++) release(kept[i]); + free(kept); + settle(live, "surviving type left objects behind"); +} + +static void check_publication_takes_young_objects(void) { + size_t live = scr_cyc_live; + Obj *y1 = young_new(), *y2 = young_new(), *y3 = young_new(); + /* scr_pub_mark takes an object whose header reads as buffered, and + * records the answer plus one as its position (0: nothing to restore). */ + if (buffered(y1)) { + check((uint32_t)(scr_cyc_pub_take(y1) + 1) == 0 || small_nursery(), "young take recorded"); + scr_cyc_pub_fill(); + } + check(gen_of(y1) != SCR_CYC_YOUNG && !buffered(y1), "published young object not taken"); + y1->rc = SIZE_MAX; /* published: immortal from here on */ + /* y3 took y1's nursery slot: its death and y2's must still find theirs. */ + release(y3); + release(y2); + settle(live + 1, "nursery inconsistent after a publish took a young object"); + /* A refused publish restores nothing for a young object: it stays out of + * the nursery and every buffer, reachable from the refused graph. */ + Obj *y4 = young_new(); + uint32_t recorded = scr_cyc_pub_take(y4) + 1; + check(recorded == 0, "young take recorded a position"); + scr_cyc_pub_fill(); + check(gen_of(y4) != SCR_CYC_YOUNG && !buffered(y4), "refused publish left the object young"); + release(y4); + settle(live + 1, "refused young object not freed"); +} + +static void check_deep_young_structures(void) { + enum { DEPTH = 200000 }; + static ScrCycType deep_type = {&obj_trace, &obj_gcfree, 0, 0}; + size_t live = scr_cyc_live, before = freed; + /* A long live chain (deep marking), and a long dead chain closing a cycle. */ + Obj *keep = young_new_of(&deep_type), *at = retain(keep); + for (int i = 1; i < DEPTH; i++) { + Obj *n = young_new_of(&deep_type); + store(at, 0, n); + release(at); + at = n; + } + release(at); + Obj *head = young_new_of(&deep_type); + at = retain(head); + for (int i = 1; i < DEPTH; i++) { + Obj *n = young_new_of(&deep_type); + store(at, 0, n); + release(at); + at = n; + } + store(at, 0, head); + release(at); + release(head); + settle(live + DEPTH, "deep young structures"); + check(freed == before + DEPTH, "dead chain not freed exactly"); + release(keep); /* an acyclic chain: freed by reference counting */ + check(freed == before + 2 * DEPTH, "live chain not freed at its release"); + settle(live, "deep young structures left objects behind"); +} + +static void check_collection_points_and_limited_passes(void) { + size_t live = scr_cyc_live, before = freed; + if (small_nursery()) return; + Obj *a = young_new(), *b = young_new(); + store(a, 0, b); + store(b, 0, a); + release(a); + release(b); + scr_cyc_collect_scheduled(); /* a collection point collects the nursery */ + check(freed == before + 2, "collection point left young garbage"); + a = young_new(); + b = young_new(); + store(a, 0, b); + store(b, 0, a); + release(a); + release(b); + void *slots[4] = {0}; + ScrCycSet none = {slots, 3}; + check(scr_cyc_collect_set(&none, SCR_CYC_OLD) >= 2, "limited pass skipped the nursery"); + check(freed == before + 4, "limited pass left young garbage"); + check(scr_cyc_live == live, "limited pass live count"); +} + +static void check_weak_observers_see_young_deaths(void) { + size_t live = scr_cyc_live, before = freed; + disposed = 0; + scr_weak_dispose_hook = count_dispose; + Obj *a = young_new(), *b = young_new(), *c = young_new(); + store(a, 0, b); + store(b, 0, a); + release(a); + release(b); + release(c); /* RC death */ + settle(live, "weak observer case"); + scr_weak_dispose_hook = NULL; + check(freed == before + 3 && disposed == 3, "weak dispose hook missed a young death"); +} + +int main(void) { + check_dead_pair_and_live_ring(); + check_rc_deaths_leave_the_nursery(); + check_dead_set_gives_up_outgoing_edges(); + check_older_holders_keep_young_alive(); + check_trial_deletion_releases_into_the_nursery(); + check_dead_structures_beyond_the_nursery(); + check_types_that_do_not_die_young(); + check_publication_takes_young_objects(); + check_deep_young_structures(); + check_collection_points_and_limited_passes(); + check_weak_observers_see_young_deaths(); + const char *size = getenv("SCR_YOUNG_NURSERY_SIZE"); + printf("young nursery checks passed: size=%s\n", size ? size : "default"); + return 0; +} diff --git a/packages/runtime/test/yin.test.ts b/packages/runtime/test/yin.test.ts new file mode 100644 index 000000000..d36e3f087 --- /dev/null +++ b/packages/runtime/test/yin.test.ts @@ -0,0 +1,80 @@ +import { execFile } from "node:child_process"; +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { expect, test } from "vitest"; + +const execFileAsync = promisify(execFile); +const testDir = import.meta.dirname; + +// The young-in nursery variant of the collector (SCR_YIN), with the RC +// audit's membership and count checks, at nursery sizes from one object +// (a collection at every allocation) to the default. +test("young-in nursery collects young cycles at every nursery size", async () => { + const buildDir = join(testDir, "build"); + await mkdir(buildDir, { recursive: true }); + const bin = join(buildDir, "test_yin"); + await execFileAsync("clang", [ + "-std=c11", + "-O1", + "-Wall", + "-Wextra", + "-fsanitize=address,undefined", + "-DSCR_CYC_TESTING", + "-DSCR_YIN", + "-DSCR_RC_AUDIT", + "-o", + bin, + join(testDir, "test_yin.c"), + join(testDir, "../src/scr_cycle.c"), + join(testDir, "../src/scr_alloc.c"), + ]); + const baseEnv = { + ...process.env, + ASAN_OPTIONS: "detect_leaks=0:halt_on_error=1", + UBSAN_OPTIONS: "halt_on_error=1", + }; + delete baseEnv.SCR_YOUNG_NURSERY_SIZE; + delete baseEnv.SCR_CYCLE_THRESHOLD; + for (const [size, threshold] of [ + [undefined, undefined], + ["1", undefined], + ["2", undefined], + ["7", undefined], + ["64", "1"], + ["1", "1"], + ] as const) { + const env = { ...baseEnv }; + if (size !== undefined) env.SCR_YOUNG_NURSERY_SIZE = size; + if (threshold !== undefined) env.SCR_CYCLE_THRESHOLD = threshold; + const run = await execFileAsync(bin, [], { env }); + expect(run.stdout).toBe(`young nursery checks passed: size=${size ?? "default"}\n`); + } +}); + +// Trial deletion is unchanged in the young-nursery build: the whole cycle +// suite passes against it (it allocates no young objects). +test("trial deletion checks pass against the young-nursery collector", async () => { + const buildDir = join(testDir, "build"); + await mkdir(buildDir, { recursive: true }); + const bin = join(buildDir, "test_cycle_yin"); + await execFileAsync("clang", [ + "-std=c11", + "-O1", + "-Wall", + "-Wextra", + "-fsanitize=address,undefined", + "-DSCR_CYC_TESTING", + "-DSCR_YIN", + "-o", + bin, + join(testDir, "test_cycle.c"), + join(testDir, "../src/scr_cycle.c"), + join(testDir, "../src/scr_alloc.c"), + ]); + const env = { ...process.env, ASAN_OPTIONS: "detect_leaks=0:halt_on_error=1" }; + delete env.SCR_CYCLE_THRESHOLD; + delete env.SCR_CYCLE_GROWTH_CAP; + const run = await execFileAsync(bin, [], { env }); + expect(run.stdout).toBe("cycle collection checks passed: threshold=256 growth_cap=16\n"); +}); diff --git a/tests/corpus/5981-young-nursery-cycles.ts b/tests/corpus/5981-young-nursery-cycles.ts new file mode 100644 index 000000000..caa59b2a4 --- /dev/null +++ b/tests/corpus/5981-young-nursery-cycles.ts @@ -0,0 +1,178 @@ +// Young cycles of classes and records, dropped at once or kept from every +// kind of outside holder: globals, older objects, arrays, maps, closures, +// weak-map values, error objects, generator and async frames. Constructors +// that throw after linking `this` into a cycle leave a partly built object +// reachable from its peer. Churn between the steps runs collections (with +// the young-in nursery, nursery collections; with SCR_YOUNG_NURSERY_SIZE=1, +// one per allocation). Whatever is still reachable keeps its contents; the +// sanitized lane's RC audit fails if anything leaks or is freed early. + +class Pair { + peer: Pair | null = null; + label: string; + n: number; + constructor(label: string, n: number) { + this.label = label; + this.n = n; + } +} + +interface Cell { + value: number; + next: Cell | null; + owner: Pair | null; +} + +class Fragile { + other: Fragile | null = null; + stage = 0; + name: string; + constructor(name: string, peer: Fragile | null, fail: boolean) { + this.name = name; + this.other = peer; + if (peer) peer.other = this; + this.stage = 1; + if (fail) throw new Error(`${name} failed at stage ${this.stage}`); + this.stage = 2; + } +} + +class Holder extends Error { + held: Pair | null = null; +} + +function pair(label: string, n: number): Pair { + const a = new Pair(`${label}.a`, n); + const b = new Pair(`${label}.b`, n + 1); + a.peer = b; + b.peer = a; + return a; +} + +function ring(size: number, base: number): Cell { + const first: Cell = { value: base, next: null, owner: null }; + let last = first; + for (let i = 1; i < size; i++) { + const cell: Cell = { value: base + i, next: null, owner: null }; + last.next = cell; + last = cell; + } + last.next = first; + return first; +} + +function ringSum(start: Cell): number { + let total = start.value; + for (let c = start.next; c !== null && c !== start; c = c.next) total += c.value; + return total; +} + +function churn(rounds: number): number { + let acc = 0; + for (let i = 0; i < rounds; i++) { + acc = (acc + pair("t", i).peer!.n) % 1000003; + acc = (acc + ringSum(ring(5, i))) % 1000003; + } + return acc; +} + +function show(p: Pair | null): string { + return p === null ? "null" : `${p.label}:${p.n}<->${p.peer?.label ?? "none"}`; +} + +// Dropped at once, in bulk. +console.log("churn", churn(20000)); + +// Kept from a global, an older object, an array, a map and a closure. +const kept = pair("global", 1); +const old = new Pair("old", 0); +console.log("churn", churn(5000)); +old.peer = pair("from-old", 2); +const list: Pair[] = []; +const byName = new Map(); +for (let i = 0; i < 50; i++) { + list.push(pair(`list${i}`, i)); + byName.set(`map${i}`, pair(`map${i}`, i)); +} +const captured = pair("closure", 3); +const read = (): string => show(captured.peer); +const cells: Cell[] = []; +for (let i = 0; i < 20; i++) { + const c = ring(4, i * 10); + c.owner = pair(`cell${i}`, i); + cells.push(c); +} +console.log("churn", churn(30000)); +console.log(show(kept), show(kept.peer)); +console.log(show(old.peer), show(old.peer!.peer)); +console.log(show(list[17]!), show(byName.get("map33")!.peer)); +console.log(read()); +console.log(ringSum(cells[7]!), show(cells[7]!.owner), ringSum(cells[19]!.next!)); + +// A weak map whose value refers back to its young key keeps the key while +// the key is held; dropping the key leaves the pair to the collector. +const weak = new WeakMap(); +const key = pair("key", 7); +weak.set(key, key.peer!); +for (let i = 0; i < 200; i++) { + const k = pair(`weak${i}`, i); + weak.set(k, k.peer!); +} +console.log("churn", churn(20000)); +console.log(weak.has(key), show(weak.get(key) ?? null)); + +// Error objects (runtime-rooted classes) holding young cycles. +const errors: Holder[] = []; +for (let i = 0; i < 10; i++) { + const e = new Holder(`holder ${i}`); + e.held = pair(`err${i}`, i); + errors.push(e); +} +console.log("churn", churn(10000)); +console.log(errors[4]!.message, show(errors[4]!.held)); + +// Constructors that throw after linking into a cycle. +const survivors: Fragile[] = []; +for (let i = 0; i < 100; i++) { + const peer = new Fragile(`peer${i}`, null, false); + try { + new Fragile(`victim${i}`, peer, i % 3 === 0); + } catch (e) { + if (i < 6) console.log((e as Error).message); + } + if (i % 10 === 0) survivors.push(peer); +} +console.log("churn", churn(10000)); +for (const s of survivors.slice(0, 4)) + console.log(s.name, s.stage, s.other?.name, s.other?.stage, s.other?.other === s); + +// Generator frames hold young objects across collections. +function* pairs(count: number): Generator { + for (let i = 0; i < count; i++) { + const p = pair(`gen${i}`, i); + churn(500); + yield p; + } +} +let genTotal = 0; +for (const p of pairs(30)) genTotal += p.peer!.n; +console.log("generator", genTotal); + +// Async frames hold young objects across awaits. +async function hold(n: number): Promise { + const a = pair(`async${n}`, n); + const cell = ring(3, n); + cell.owner = a; + await null; + churn(3000); + await new Promise((resolve) => setTimeout(resolve, 1)); + churn(3000); + return `${show(a)} ${show(cell.owner)} ${ringSum(cell)}`; +} +async function main(): Promise { + const pending: Promise[] = [hold(1), hold(2), hold(3)]; + for (const p of pending) console.log(await p); + console.log("churn", churn(10000)); + console.log(show(kept), show(list[49]!), read()); +} +void main(); diff --git a/tests/corpus/5982-young-nursery-workers.ts b/tests/corpus/5982-young-nursery-workers.ts new file mode 100644 index 000000000..64c3b30f5 --- /dev/null +++ b/tests/corpus/5982-young-nursery-workers.ts @@ -0,0 +1,81 @@ +import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads"; +import { publish } from "@scriptc/threads"; + +// Every thread builds and drops young cycles of its own while it publishes +// graphs of freshly built objects and posts them. Publishing takes young +// objects out of the thread's nursery before they become shared, so the +// receiving thread reads them intact while both threads keep collecting. +// Receivers publish() what they receive (a no-op in scriptc) and read only +// data, so Node.js prints the same. + +class Node { + parent: Node | null = null; + next: Node | null = null; + children: Node[] = []; + label: string; + constructor(label: string) { + this.label = label; + } +} + +function graph(prefix: string, width: number): Node { + const root = new Node(`${prefix}root`); + let last: Node | null = null; + for (let i = 0; i < width; i++) { + const child = new Node(`${prefix}${i}`); + child.parent = root; + if (last !== null) last.next = child; + last = child; + root.children.push(child); + } + if (last !== null) last.next = root.children[0]!; + return root; +} + +function churn(rounds: number): number { + let acc = 0; + for (let i = 0; i < rounds; i++) { + const a = new Node(`a${i}`); + const b = new Node(`b${i}`); + a.next = b; + b.next = a; + acc = (acc + a.next.label.length + b.next.label.length) % 1000003; + } + return acc; +} + +function describe(root: Node): string { + let out = root.label; + for (const c of root.children) out += ` ${c.label}^${c.parent?.label}>${c.next?.label}`; + return out; +} + +if (isMainThread) { + const replies: string[] = []; + let pending = 3; + for (let w = 0; w < 3; w++) { + const shared = publish(graph(`m${w}.`, 3)); + const worker = new Worker(new URL(import.meta.url), { workerData: { id: w, shared } }); + worker.on("message", (message: unknown) => { + const received = message as { id: number; graph: Node; churned: number }; + publish(received.graph); + replies[w] = `worker ${received.id}: ${describe(received.graph)} churn=${received.churned}`; + }); + worker.on("exit", () => { + if (--pending === 0) { + for (const line of replies) console.log(line); + console.log(`main churn ${churn(20000)}`); + } + }); + } + console.log(`main churn ${churn(20000)}`); +} else { + const { id, shared } = workerData as { id: number; shared: Node }; + publish(shared); + const churned = churn(30000); + const fresh = graph(`w${id}.`, 4); + fresh.children[1]!.children.push(new Node(`w${id}.leaf`)); + publish(fresh); + churn(10000); + parentPort!.postMessage({ id, graph: fresh, churned: (churned + describe(shared).length) % 1000003 }); +}