From 5cc3babc737b420836c0573989eb4f44d5b300ed Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 14:33:04 -0700 Subject: [PATCH 1/3] Add the ProgramLifetime contract: classes whose instances live until exit skip reference counting and cycle collection `class Node implements ProgramLifetime` (@scriptc/types, import type only, erased for Node) promises that instances of the class and of its subclasses are kept until the program exits. scriptc then creates them immortal: `new` stores rc = SIZE_MAX and allocates no collector header, the class's retain and release entry points are empty (so reference counting on every static type of the hierarchy folds away), and the cycle analysis treats the hierarchy as a leaf, which untraces types that only reached it. Immortal objects are already part of every RC, collector, weak-collection and publication contract (static literals, published graphs). The promise only changes memory use; SC7007 refuses it where something else could observe it: on a class that is not the root of its hierarchy (including built-in bases), in library builds, and on classes whose instances can be published to other threads. SC7006 reports the marker outside a class's implements clause. tsc-ts with nine roots marked (Node, NodeList, FlowNode, Symbol, Type, Signature, IndexInfo, SymbolLinks, NodeLinks): single-threaded wall -25.8% Excalidraw, -22.2% TypeORM, -25.9% self-check, -28.5% Sentry; peak RSS -6.5% to -16.5%; identical diagnostics. --- docs/content/docs/contracts.mdx | 39 ++++ docs/content/docs/limitations.mdx | 2 +- .../src/backend/cycle-analysis.test.ts | 37 ++++ .../compiler/src/backend/cycle-analysis.ts | 9 + .../compiler/src/backend/llvm/classes.test.ts | 53 +++++- packages/compiler/src/backend/llvm/classes.ts | 31 +++- packages/compiler/src/backend/llvm/dyn.ts | 2 + packages/compiler/src/backend/llvm/emitter.ts | 5 + packages/compiler/src/backend/llvm/shapes.ts | 26 ++- packages/compiler/src/contracts/check.ts | 67 +++++++ .../compiler/src/frontend/contract-types.ts | 72 ++++++- packages/compiler/src/ir/ir.ts | 5 + .../test/ts7/baselines/order-parity.json | 24 +++ packages/types/README.md | 3 +- packages/types/index.d.ts | 11 ++ .../corpus/4633-contract-program-lifetime.ts | 175 ++++++++++++++++++ .../contract-program-lifetime-positions.ts | 16 ++ .../contract-program-lifetime-publish.ts | 18 ++ .../diagnostics/contract-program-lifetime.ts | 16 ++ ...contract-program-lifetime-positions.ts.txt | 17 ++ .../contract-program-lifetime-publish.ts.txt | 8 + .../contract-program-lifetime.ts.txt | 17 ++ 22 files changed, 638 insertions(+), 15 deletions(-) create mode 100644 tests/corpus/4633-contract-program-lifetime.ts create mode 100644 tests/diagnostics/contract-program-lifetime-positions.ts create mode 100644 tests/diagnostics/contract-program-lifetime-publish.ts create mode 100644 tests/diagnostics/contract-program-lifetime.ts create mode 100644 tests/harness/__snapshots__/contract-program-lifetime-positions.ts.txt create mode 100644 tests/harness/__snapshots__/contract-program-lifetime-publish.ts.txt create mode 100644 tests/harness/__snapshots__/contract-program-lifetime.ts.txt diff --git a/docs/content/docs/contracts.mdx b/docs/content/docs/contracts.mdx index bb91e341e..d37532a79 100644 --- a/docs/content/docs/contracts.mdx +++ b/docs/content/docs/contracts.mdx @@ -28,6 +28,11 @@ Contract types are type aliases that mean nothing to TypeScript or Node.js but a No code writes to the value, through any reference. A module constant built from literals becomes immortal data: never reference counted, never traced by the collector. + + ProgramLifetime + Instances of the class are kept until the program exits. + Instances are immortal from construction: no reference counting, no cycle collection, never freed. + @@ -110,6 +115,8 @@ scriptc recognizes a contract where it is written as the annotation of a declara
Parameters, variables, class instance fields and function results whose type is a plain number.
Immutable<T>
Parameters, variables, class instance fields and function results.
+
ProgramLifetime
+
The implements clause of a class declaration: class Node implements ProgramLifetime.
A contract anywhere else (a type alias, an interface member, an element or type argument such as `Int32[]`, a function type) would promise something scriptc does not check, so the build reports it with SC7006. @@ -144,6 +151,34 @@ scriptc refuses an `Int32` slot whose writes it cannot all see (SC7003): a param A value stored into an `Immutable` slot must be fresh: a literal, a `new` expression whose constructor keeps `this` to itself, or a function that returns what it builds. Values that reach dynamic code, or functions scriptc cannot see, are refused. +## ProgramLifetime + +`ProgramLifetime` marks a class whose instances a program keeps until it exits, such as the nodes of a syntax tree, the symbols of a symbol table or interned types. scriptc then never reference counts, collects or frees instances of the class and of its subclasses: they are created immortal, every retain and release on them compiles to nothing, and the cycle collector neither walks them nor needs to trace the objects that point to them. Node.js behavior is unchanged; only memory use differs, because an instance the program drops is never reclaimed. + +```ts:tree.ts +import type { ProgramLifetime } from "@scriptc/types"; + +abstract class SyntaxNode implements ProgramLifetime { + parent: SyntaxNode | undefined = undefined; +} + +class Identifier extends SyntaxNode { + readonly text: string; + constructor(text: string) { + super(); + this.text = text; + } +} +``` + +scriptc checks that the promise cannot change what the program computes (SC7007): + +- the class is the root of its hierarchy, so every value of any class type in the hierarchy is program-lifetime; built-in bases such as `Error`, `EventEmitter` and streams are refused because they hold runtime state that is released with the object; +- the build is an executable: a library's host decides when the objects it receives are released; +- no instance can be published to other threads with `@scriptc/threads`. + +Static code has no `WeakRef` or `FinalizationRegistry`, and a `WeakMap` or `WeakSet` entry whose key is never freed is never observable as removed, so nothing else can tell the difference. + ## Diagnostics Each failed contract names the declaration and the reason: @@ -238,5 +273,9 @@ $ scriptc build reject.ts SC7006 A contract is written where scriptc does not check it. + + SC7007 + A ProgramLifetime class is not the root of its hierarchy, is part of a library build, or can be published to other threads. + diff --git a/docs/content/docs/limitations.mdx b/docs/content/docs/limitations.mdx index c2860ee27..1fc8c983b 100644 --- a/docs/content/docs/limitations.mdx +++ b/docs/content/docs/limitations.mdx @@ -130,7 +130,7 @@ A program that passes its project's TypeScript check may still use unsupported o ### Contract types -[Contract types](/docs/contracts) are checked only where they are written as the annotation of a parameter, variable, class instance field or function result; aliases, interface members and nested type positions are reported. A surrounding `try`/`catch` does not discharge `NoThrow`. `Int32` refuses parameters of functions used as values and fields of classes whose instances reach dynamic code, and `Immutable` refuses values that reach dynamic code or that another reference can still change. +[Contract types](/docs/contracts) are checked only where they are written as the annotation of a parameter, variable, class instance field or function result; aliases, interface members and nested type positions are reported. A surrounding `try`/`catch` does not discharge `NoThrow`. `Int32` refuses parameters of functions used as values and fields of classes whose instances reach dynamic code, and `Immutable` refuses values that reach dynamic code or that another reference can still change. `ProgramLifetime` applies to root classes of executables only and cannot yet be combined with `@scriptc/threads` publication; instances the program drops are never reclaimed. ### three.js diff --git a/packages/compiler/src/backend/cycle-analysis.test.ts b/packages/compiler/src/backend/cycle-analysis.test.ts index ca1ae760d..7344dbe16 100644 --- a/packages/compiler/src/backend/cycle-analysis.test.ts +++ b/packages/compiler/src/backend/cycle-analysis.test.ts @@ -690,3 +690,40 @@ test("library builds keep the mutable-graph analysis", () => { mod.lib = {} as NonNullable; checkExact(mod, ["object:Tree"], ["link"]); }); + +test("ProgramLifetime hierarchies are leaves: never traced and never closing a cycle", () => { + const mod = module(); + const node: IrType = { kind: "object", className: "Node" }; + const sym: IrType = { kind: "object", className: "Sym" }; + const scope: IrType = { kind: "object", className: "Scope" }; + mod.classes = [ + { + name: "Node", + loc, + fields: [ + { name: "parent", type: node }, + { name: "symbol", type: sym }, + { name: "visit", type: funcOf([], VOID) }, + ], + }, + { name: "Name", loc, base: "Node", fields: [{ name: "text", type: STRING }] }, + { name: "Sym", loc, fields: [{ name: "declarations", type: arrayOf(node) }] }, + { + name: "Scope", + loc, + fields: [ + { name: "parent", type: scope }, + { name: "symbols", type: mapOf(STRING, sym) }, + ], + }, + ]; + mod.records = [shape("holder", [arrayOf(node), mapOf(STRING, sym)])]; + // Without the contract the intrinsic callback field makes everything that + // reaches Node traced. + check(mod, ["object:Node", "object:Name", "object:Sym", "object:Scope", "record:holder"]); + // With it, Node and Sym are never freed: edges into them cannot close a + // garbage cycle, and the collector never walks their fields. Only the + // mortal Scope cycle remains. + mod.contracts = { programLifetime: ["Node", "Sym"] }; + check(mod, ["object:Scope"]); +}); diff --git a/packages/compiler/src/backend/cycle-analysis.ts b/packages/compiler/src/backend/cycle-analysis.ts index 8b6a5b210..dd8500908 100644 --- a/packages/compiler/src/backend/cycle-analysis.ts +++ b/packages/compiler/src/backend/cycle-analysis.ts @@ -52,6 +52,13 @@ export function computeTraced(mod: IrModule): { shapes: Set; unions: Set return cur; }; const unitOfClass = (name: string): string => `object:${rootOf(name)}`; + // ProgramLifetime hierarchies (contract types) are immortal from + // construction: the collector never walks them, and a garbage cycle cannot + // pass through an object that is never freed. They are leaves: no edges + // into them, none out of them. + const lifetimeRoots = new Set(mod.contracts?.programLifetime ?? []); + const programLifetime = (className: string): boolean => + lifetimeRoots.size > 0 && lifetimeRoots.has(rootOf(className)); const mutability = computeCycleMutability(mod, unitOfClass); const shapeDefs = [ ...classes.map((c) => ({ @@ -134,6 +141,7 @@ export function computeTraced(mod: IrModule): { shapes: Set; unions: Set case "object": case "record": case "union": { + if (t.kind === "object" && programLifetime(t.className)) break; const dependency = t.kind === "union" ? unions.get(t.unionId) @@ -155,6 +163,7 @@ export function computeTraced(mod: IrModule): { shapes: Set; unions: Set } }; for (const s of shapeDefs) { + if (s.key.startsWith("object:") && programLifetime(s.key.slice("object:".length))) continue; const unitMutable = mutability.all || mutability.units.has(s.unit); for (const field of s.fields) { if (mutability.emptyPropertyBags && field.name === DYN_CLASS_PROPERTIES) continue; diff --git a/packages/compiler/src/backend/llvm/classes.test.ts b/packages/compiler/src/backend/llvm/classes.test.ts index fac2ad677..e5fc34099 100644 --- a/packages/compiler/src/backend/llvm/classes.test.ts +++ b/packages/compiler/src/backend/llvm/classes.test.ts @@ -2,7 +2,14 @@ import { expect, test } from "vitest"; import { F64, STRING, VOID, type IrModule } from "../../ir/ir.js"; import { buildClassGraph, classMembershipIntervals, emitClassMembershipHelper } from "./classes.js"; import { emitLlvmModule } from "./emitter.js"; -import { mangleClassReleaseDirect, mangleClassRelease, mangleRecordRelease } from "../mangle.js"; +import { + mangleClassNew, + mangleClassReleaseDirect, + mangleClassRelease, + mangleClassRetain, + mangleClassTrace, + mangleRecordRelease, +} from "../mangle.js"; const loc = { file: "class-membership.ts", start: 0, end: 1 }; @@ -79,6 +86,50 @@ test("releases split into an inlinable fast path and an out-of-line remainder", expect(slow).toContain("getelementptr inbounds %ScrVt, ptr %vt, i64 0, i32 2"); }); +test("ProgramLifetime hierarchies are immortal from construction and take no reference counts", () => { + const kids = { kind: "array" as const, elem: { kind: "object" as const, className: "Node" } }; + const owners = { kind: "array" as const, elem: { kind: "object" as const, className: "Owner" } }; + const mod: IrModule = { + irVersion: 15, + sourceFile: loc.file, + entry: "main", + functions: [{ name: "main", params: [], returnType: VOID, locals: [], body: [], loc }], + classes: [ + { name: "Node", fields: [{ name: "kids", type: kids }], methods: [], loc }, + { name: "Leaf", base: "Node", fields: [{ name: "kids", type: kids }], methods: [], loc }, + // A mortal class on its own cycle (array edges are mutable) that also + // holds program-lifetime nodes. + { + name: "Owner", + fields: [ + { name: "next", type: owners }, + { name: "nodes", type: kids }, + ], + methods: [], + loc, + }, + ], + contracts: { programLifetime: ["Node"] }, + }; + const llvm = emitLlvmModule(mod, { pointerBits: 64 }); + const body = (name: string): string => + new RegExp(`^define internal [a-z]+ @${name}\\([^]*?^}`, "m").exec(llvm)?.[0] ?? ""; + for (const cls of ["Node", "Leaf"]) { + // Retain and release are empty, so every inlined call site folds away. + expect(body(mangleClassRetain(cls)), cls).toMatch(/entry:\n {2}ret ptr %o\n}/); + expect(body(mangleClassRelease(cls)), cls).toMatch(/entry:\n {2}ret void\n}/); + // new: no collector header (no descriptor, no scr_cyc_alloc), rc = SIZE_MAX. + const nw = body(mangleClassNew(cls)); + expect(nw, cls).toContain("store i64 -1, ptr %o"); + expect(nw, cls).not.toContain("scr_cyc_alloc"); + expect(llvm, cls).not.toContain(`@${mangleClassTrace(cls)}.cyctype`); + } + // The mortal Owner keeps its header, counts and possible-root step. + expect(body(mangleClassNew("Owner"))).toContain("store i64 1, ptr %o"); + expect(body(mangleClassNew("Owner"))).toContain("scr_cyc_alloc"); + expect(body(mangleClassRelease("Owner"))).toContain("load i16, ptr %bufp"); +}); + function graph() { const classes = [ { name: "Root" }, diff --git a/packages/compiler/src/backend/llvm/classes.ts b/packages/compiler/src/backend/llvm/classes.ts index 9f7794925..3e5d07722 100644 --- a/packages/compiler/src/backend/llvm/classes.ts +++ b/packages/compiler/src/backend/llvm/classes.ts @@ -305,6 +305,21 @@ export interface ClassHost extends ShapeHost { * entry point unless the slot's calling convention borrows; see * LlEmitter.virtualEntry). */ virtualEntry?(implFn: string): string; + /** Roots of the hierarchies declared `implements ProgramLifetime` + * (IrContracts.programLifetime). Absent: none. */ + readonly programLifetime?: ReadonlySet; +} + +/** Whether `meta`'s instances live until the process exits (a + * ProgramLifetime hierarchy, contracts/check.ts): `new` makes them immortal + * (rc == SIZE_MAX) without a collector header, nothing frees them, and the + * class's retain and release entry points are empty, so reference counting + * on any static type of the hierarchy folds away. Immortal objects are + * already part of every RC and collector contract (static literals and + * published graphs): dynamic retains and releases skip rc == SIZE_MAX, and + * the collector skips such children before reading a header. */ +export function programLifetimeClass(host: ClassHost, meta: LlClassMeta): boolean { + return host.programLifetime?.has(meta.root.def.name) === true && meta.root.def.runtime !== true; } /** The newFn initialization stores for fields whose type ADMITS undefined @@ -432,6 +447,7 @@ export function emitClassShapes( for (const cls of emitted) { const meta = metaMap.get(cls.name)!; const struct = mangleClassStruct(cls.name); + const lifetime = programLifetimeClass(host, meta); const traced = host.tracedShapes.has(`object:${cls.name}`); const isEmitterRooted = emitterRooted(meta); const isStreamRooted = streamRooted(meta); @@ -476,7 +492,7 @@ export function emitClassShapes( // retain: NULL-tolerant, immortal-skip — layout-generic (rc at 0), so // hierarchy members need no dispatch. - defs.push(...retainBody(host, mangleClassRetain(cls.name), `retain ${cls.name}`), ``); + defs.push(...retainBody(host, mangleClassRetain(cls.name), `retain ${cls.name}`, lifetime), ``); // The field-releasing teardown body shared by both release shapes // (the public one on standalone classes, the DIRECT one on hierarchy @@ -515,6 +531,7 @@ export function emitClassShapes( dispatchName, traced, `release ${cls.name}`, + lifetime, ), ``, ); @@ -579,6 +596,7 @@ export function emitClassShapes( freeBody, `release ${cls.name}`, bounded, + lifetime, ), ``, ); @@ -603,10 +621,17 @@ export function emitClassShapes( ), ``, ); + // A program-lifetime instance is immortal from construction: no + // collector header (nothing walks or frees it) and rc == SIZE_MAX. nw.push( - ...emitObjectAlloc(host, sizeOf, traced ? { type: cycType } : null, meta.hierarchy ? 16 : 8), + ...emitObjectAlloc( + host, + sizeOf, + traced && !lifetime ? { type: cycType } : null, + meta.hierarchy ? 16 : 8, + ), ); - nw.push(` store ${host.sizeType} 1, ptr %o`); + nw.push(` store ${host.sizeType} ${lifetime ? -1 : 1}, ptr %o`); if (meta.hierarchy) { nw.push( ` %vtp = getelementptr inbounds %${struct}, ptr %o, i64 0, i32 1`, diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index b474068cc..10cd9719a 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -83,6 +83,8 @@ export interface DynHost extends WalkerHost { unitInstanceRef(unionId: string, tag: number): string; /** See ClassHost.virtualEntry. */ virtualEntry?(implFn: string): string; + /** See ClassHost.programLifetime. */ + readonly programLifetime?: ReadonlySet; /** The immortal ABSENT field-slot state of an undefined-armed union. */ absentInstanceRef(unionId: string): string; /** The shared adapter record: live array capsules read its `arrayOps`, so a diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 67b393a5e..b09e7f6a5 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -747,6 +747,9 @@ export class LlEmitter { /** Immutable module constants (contract types) whose graphs become * immortal once initialized: their reads need no reference counting. */ readonly immortalGlobals: ReadonlySet; + /** ProgramLifetime hierarchy roots (contract types): their instances are + * immortal from construction (classes.ts programLifetimeClass). */ + readonly programLifetime: ReadonlySet; private numericLocals = new Map(); private captureIds = new Set(); /** Active induction bindings retain their width independently of size_t. */ @@ -835,6 +838,7 @@ export class LlEmitter { this.initializerBindings = findInitializerBindings(mod); this.int32Slots = analyzeInt32Slots(mod); this.immortalGlobals = new Set(mod.contracts?.immortalGlobals ?? []); + this.programLifetime = new Set(mod.contracts?.programLifetime ?? []); this.finalFields = analyzeFinalFields(mod); this.sizeType = options.pointerBits === 32 ? "i32" : "i64"; this.ffiExtendNarrowIntegers = @@ -1008,6 +1012,7 @@ export class LlEmitter { }, pendingTestLines: (dest) => this.pendingTestLines(dest), virtualEntry: (implFn) => this.virtualEntry(implFn), + programLifetime: this.programLifetime, }; this.walkers = new LlWalkers(this.shapeHost); this.dyn = new LlDyn(this.shapeHost); diff --git a/packages/compiler/src/backend/llvm/shapes.ts b/packages/compiler/src/backend/llvm/shapes.ts index 7f3e28d5d..ad2d5f147 100644 --- a/packages/compiler/src/backend/llvm/shapes.ts +++ b/packages/compiler/src/backend/llvm/shapes.ts @@ -719,8 +719,14 @@ function rcMembers(shape: IrRecordShape): { index: number; type: IrType; name: s /** The NULL- and immortal-skipping retain body shared by every shape. A * retain never touches the cycle header: a buffered candidate that is * retained again stays a candidate (scr_cycle.c, markRoots). */ -export function retainBody(host: ShapeHost, fnName: string, comment = ""): string[] { +export function retainBody( + host: ShapeHost, + fnName: string, + comment = "", + immortal = false, +): string[] { const S = host.sizeType; + if (immortal) return immortalRcBody(fnName, "ptr", comment); return [ `define internal ptr @${fnName}(ptr %o) ${FN_ATTRS} {${comment ? ` ; ${comment}` : ""}`, `entry:`, @@ -740,6 +746,19 @@ export function retainBody(host: ShapeHost, fnName: string, comment = ""): strin ]; } +/** The retain or release entry point of a class whose every instance is + * immortal from creation (rc == SIZE_MAX, no collector header; see + * classes.ts programLifetimeClass): nothing to count, so every inlined call + * site folds away. */ +function immortalRcBody(fnName: string, ret: "ptr" | "void", comment: string): string[] { + return [ + `define internal ${ret} @${fnName}(ptr %o) ${FN_ATTRS} {${comment ? ` ; ${comment} (immortal)` : ""}`, + `entry:`, + ret === "ptr" ? ` ret ptr %o` : ` ret void`, + `}`, + ]; +} + /** Scalar fields and native strings cannot form recursive ownership chains. * Other reference kinds conservatively share the runtime destruction budget. */ export function needsBoundedRelease(type: IrType): boolean { @@ -760,6 +779,7 @@ export function releaseBody( freeBody: string[], comment = "", bounded = true, + immortal = false, ): string[] { const S = host.sizeType; if (bounded) host.declare(`declare void @scr_rc_destroy(ptr, ptr)`); @@ -774,7 +794,7 @@ export function releaseBody( `}`, ] : []), - ...releaseFastPath(host, fnName, `${fnName}_slow`, traced, comment), + ...releaseFastPath(host, fnName, `${fnName}_slow`, traced, comment, immortal), `define internal void @${fnName}_slow(ptr %o) noinline ${FN_ATTRS} {${comment ? ` ; ${comment}` : ""}`, `entry:`, ` %isnull = icmp eq ptr %o, null`, @@ -814,8 +834,10 @@ export function releaseFastPath( slow: string, traced: boolean, comment = "", + immortal = false, ): string[] { const S = host.sizeType; + if (immortal) return immortalRcBody(fnName, "void", comment); return [ `define internal void @${fnName}(ptr %o) ${FN_ATTRS} {${comment ? ` ; ${comment} (fast path)` : ""}`, `entry:`, diff --git a/packages/compiler/src/contracts/check.ts b/packages/compiler/src/contracts/check.ts index fc5dcc7ee..557f4aa46 100644 --- a/packages/compiler/src/contracts/check.ts +++ b/packages/compiler/src/contracts/check.ts @@ -14,6 +14,9 @@ * Immutable slot, values stored into Immutable slots are fresh (nothing * else references them), and such values never escape to code the IR * cannot see. Module constants built from fresh literals become immortal. + * - ProgramLifetime (class marker): the class is a hierarchy root in an + * executable whose instances cannot be published; its hierarchy is then + * immortal from construction (classes.ts programLifetimeClass). * * Verified facts land on the IR as `contracts` (IrContracts). */ import type { ScrDiagnostic } from "../diagnostics/diagnostic.js"; @@ -40,6 +43,7 @@ import { import { findInitializerBindings, withInitializerBindings } from "../ir/initializer-bindings.js"; import { dynamicallyStoredFamilies, functionCallFacts, Int32Slots } from "../ir/int32-slots.js"; import { computeMayThrow, type MayThrowReason } from "../backend/may-throw.js"; +import { computePublishedTypes } from "../backend/publication.js"; import { SourceLocations } from "../backend/source-locations.js"; import { contractLocKey, @@ -228,6 +232,7 @@ class ContractChecker { } run(): ContractCheckResult { + this.checkProgramLifetime(); this.checkNoThrow(); const int32 = new Int32Contracts(this); int32.run(); @@ -245,6 +250,68 @@ class ContractChecker { this.recorded = true; } + /* ── ProgramLifetime ── */ + + /** `class C implements ProgramLifetime`: a memory-only promise, so no + * program behavior depends on it except that instances are never freed. + * Checked: the class is the root of its hierarchy (every subclass, and so + * every value of any static type in the hierarchy, is then program- + * lifetime, which is what lets the backend elide reference counting on + * those static types); the build is an executable (library hosts own the + * lifetimes of the objects they receive); and no instance can be + * published to other threads (publication marks a graph immortal by its + * reference count and walks it by that mark, which program-lifetime + * objects already carry, so their fields would stay thread-local). Static + * code has no WeakRef or FinalizationRegistry; weak collections stay + * correct because an entry keyed by an object that is never freed is + * never observable as removed. */ + private checkProgramLifetime(): void { + if (this.notes.classes.size === 0) return; + const roots: string[] = []; + const notes = new Map(); + for (const [key, note] of this.notes.classes) + for (const cls of this.classesAt(key)) { + if (cls.base !== undefined) { + const base = this.classes.get(cls.base); + const baseName = base?.jsName ?? cls.base.replace(/^%(m\d+\.)?/, ""); + this.report( + "SC7007", + note.loc, + `'${note.name}' is declared ProgramLifetime but extends '${baseName}'`, + base === undefined || base.runtime === true + ? "ProgramLifetime applies to classes the program owns end to end; built-in bases (errors, emitters, streams) keep runtime state that is released with the object" + : `declare ProgramLifetime on the root class '${baseName}' instead; it applies to every subclass`, + ); + continue; + } + if (this.mod.lib !== undefined) { + this.report( + "SC7007", + note.loc, + `'${note.name}' is declared ProgramLifetime in a library build`, + "a library's host decides when the objects it receives are released; ProgramLifetime is checked in executables only", + ); + continue; + } + roots.push(cls.name); + notes.set(cls.name, note); + } + if (roots.length === 0) return; + const published = computePublishedTypes(this.mod); + if (published !== null) + for (const root of roots) { + if (!published.classGuarded(root)) continue; + const note = notes.get(root)!; + this.report( + "SC7007", + note.loc, + `'${note.name}' is declared ProgramLifetime, but its instances can be published to other threads`, + "publication is not supported for ProgramLifetime classes yet; remove ProgramLifetime or keep the instances out of the published graph", + ); + } + this.record("programLifetime", roots); + } + /* ── NoThrow ── */ private checkNoThrow(): void { diff --git a/packages/compiler/src/frontend/contract-types.ts b/packages/compiler/src/frontend/contract-types.ts index 7c622fdee..65fc7fa15 100644 --- a/packages/compiler/src/frontend/contract-types.ts +++ b/packages/compiler/src/frontend/contract-types.ts @@ -4,7 +4,8 @@ * aliases by import provenance, like threads-import.ts recognizes the * @scriptc/threads exports. TypeScript drops alias identity from inferred * types, so only annotations the program WRITES count: parameter, return, - * variable and class field types. The IR-side checks live in + * variable and class field types, and the ProgramLifetime class marker in + * a class declaration's implements clause. The IR-side checks live in * ir/contract-check.ts. */ import type { ScrDiagnostic } from "../diagnostics/diagnostic.js"; import type { SrcLoc } from "../ir/ir.js"; @@ -13,8 +14,8 @@ import * as ts from "./ts7/adapter.js"; export const CONTRACT_TYPES_MODULE = "@scriptc/types"; -export type ContractMarker = "Int32" | "NoThrow" | "Immutable"; -const MARKERS: ReadonlySet = new Set(["Int32", "NoThrow", "Immutable"]); +export type ContractMarker = "Int32" | "NoThrow" | "Immutable" | "ProgramLifetime"; +const MARKERS: ReadonlySet = new Set(["Int32", "NoThrow", "Immutable", "ProgramLifetime"]); /** One written contract: what it promises and where the annotation is. */ export interface ContractNote { @@ -36,6 +37,8 @@ export interface ContractAnnotations { bindings: Map; /** Instance fields by `${class key}\0${field name}`. */ fields: Map; + /** Class markers (`implements ProgramLifetime`) by class key. */ + classes: Map; diagnostics: ScrDiagnostic[]; /** Number of annotations found (for timing and tests). */ count: number; @@ -49,7 +52,13 @@ export function contractLocKey(loc: SrcLoc): string { * import binding of @scriptc/types (`import type { Int32 } from * "@scriptc/types"`, `import type * as sc from "@scriptc/types"`). */ function markerOf(checker: ts.TypeChecker, ref: ts.TypeReferenceNode): ContractMarker | null { - const name = ref.typeName; + return markerOfName(checker, ref.typeName); +} + +/** markerOf for a written name: `Marker`, `sc.Marker` as a type name + * (QualifiedName) or as a heritage expression (PropertyAccessExpression, + * `implements sc.ProgramLifetime`). */ +function markerOfName(checker: ts.TypeChecker, name: ts.Node): ContractMarker | null { let ident: ts.Identifier; let member: string; if (ts.isIdentifier(name)) { @@ -60,6 +69,14 @@ function markerOf(checker: ts.TypeChecker, ref: ts.TypeReferenceNode): ContractM if (!MARKERS.has(name.right.text)) return null; ident = name.left; member = name.right.text; + } else if ( + ts.isPropertyAccessExpression(name) && + ts.isIdentifier(name.expression) && + ts.isIdentifier(name.name) + ) { + if (!MARKERS.has(name.name.text)) return null; + ident = name.expression; + member = name.name.text; } else return null; const symbol = checker.getSymbolAtLocation(ident); const decl = symbol ? checker.declarationsOf(symbol)[0] : undefined; @@ -106,15 +123,26 @@ export function collectContractAnnotations( functions: new Map(), bindings: new Map(), fields: new Map(), + classes: new Map(), diagnostics: [], count: 0, }; - const kinds = new Set([ts.SyntaxKind.TypeReference]); + const kinds = new Set([ + ts.SyntaxKind.TypeReference, + ts.SyntaxKind.ExpressionWithTypeArguments, + ]); for (const sf of files) { if (sf.isDeclarationFile || !sf.text.includes(CONTRACT_TYPES_MODULE)) continue; ts.walkPreorder( sf, (node) => { + if (ts.isExpressionWithTypeArguments(node)) { + const marker = markerOfName(checker, node.expression); + if (marker === null) return; + out.count++; + placeHeritage(out, node, marker); + return; + } if (!ts.isTypeReferenceNode(node)) return; const marker = markerOf(checker, node); if (marker === null) return; @@ -133,7 +161,9 @@ function unsupported(out: ContractAnnotations, node: ts.Node, marker: ContractMa ? "NoThrow wraps a function's written return type" : marker === "Int32" ? "Int32 is checked on parameters, variables, class instance fields and function results" - : "Immutable is checked on parameters, variables, class instance fields and function results"; + : marker === "ProgramLifetime" + ? "ProgramLifetime is checked in the implements clause of a class declaration" + : "Immutable is checked on parameters, variables, class instance fields and function results"; out.diagnostics.push( contractDiag( "SC7006", @@ -141,17 +171,45 @@ function unsupported(out: ContractAnnotations, node: ts.Node, marker: ContractMa `the ${marker} contract is not checked in this position (${where})`, marker === "NoThrow" ? "write it as the function's return type annotation: function f(): NoThrow" - : `write ${marker} directly as the annotation of the declaration; type aliases, interface members and types nested in other types (arrays, unions, type arguments) are not followed`, + : marker === "ProgramLifetime" + ? "declare it on the class itself: class Node implements ProgramLifetime { ... }" + : `write ${marker} directly as the annotation of the declaration; type aliases, interface members and types nested in other types (arrays, unions, type arguments) are not followed`, ), ); } +/** A marker written in a heritage clause: only `class C implements + * ProgramLifetime` is a contract position. */ +function placeHeritage( + out: ContractAnnotations, + node: ts.ExpressionWithTypeArguments, + marker: ContractMarker, +): void { + const clause = node.parent; + const cls = clause?.parent; + if ( + marker !== "ProgramLifetime" || + clause === undefined || + !ts.isHeritageClause(clause) || + clause.token !== ts.SyntaxKind.ImplementsKeyword || + cls === undefined || + !ts.isClassDeclaration(cls) + ) + return unsupported(out, node, marker); + out.classes.set(contractLocKey(locOf(cls)), { + marker, + loc: locOf(node), + name: nameText(cls.name), + }); +} + function place( out: ContractAnnotations, checker: ts.TypeChecker, ref: ts.TypeReferenceNode, marker: ContractMarker, ): void { + if (marker === "ProgramLifetime") return unsupported(out, ref, marker); let node: ts.Node = ref; let parent = ref.parent; // NoThrow / NoThrow>: the inner marker is the result diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 4b687f438..b0805e86d 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -1006,6 +1006,11 @@ export interface IrContracts { * makes their graphs immortal once initialized, and reads through them * need no reference counting. */ immortalGlobals?: string[]; + /** Root classes declared `implements ProgramLifetime`: instances of every + * class in their hierarchies are immortal from construction (rc == + * SIZE_MAX, no collector header), their static reference counting is + * elided, and the cycle analysis treats them as leaves. */ + programLifetime?: string[]; } export type IrFfiValueParamClass = diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 1580650f6..b4d51f955 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -15878,6 +15878,12 @@ ], "diags": [] }, + "/tests/corpus/4633-contract-program-lifetime.ts": { + "order": [ + "/tests/corpus/4633-contract-program-lifetime.ts" + ], + "diags": [] + }, "/tests/diagnostics/contract-immutable.ts": { "order": [ "/tests/diagnostics/contract-immutable.ts" @@ -15908,6 +15914,24 @@ ], "diags": [] }, + "/tests/diagnostics/contract-program-lifetime-positions.ts": { + "order": [ + "/tests/diagnostics/contract-program-lifetime-positions.ts" + ], + "diags": [] + }, + "/tests/diagnostics/contract-program-lifetime-publish.ts": { + "order": [ + "/tests/diagnostics/contract-program-lifetime-publish.ts" + ], + "diags": [] + }, + "/tests/diagnostics/contract-program-lifetime.ts": { + "order": [ + "/tests/diagnostics/contract-program-lifetime.ts" + ], + "diags": [] + }, "/tests/corpus/4660-typed-rest-spread-forwarding.ts": { "order": [ "/tests/corpus/4660-typed-rest-spread-forwarding.ts" diff --git a/packages/types/README.md b/packages/types/README.md index 5436cee18..2b138e979 100644 --- a/packages/types/README.md +++ b/packages/types/README.md @@ -21,5 +21,6 @@ function isLetter(ch: number): NoThrow { - `NoThrow` on a function's return type: the function never throws. Callers need no exception checks. - `Int32` on a parameter, variable, class instance field or function result: every stored value is a signed 32-bit integer by construction (an integer literal in range, a bitwise result such as `x | 0`, `Math.imul`, another `Int32`, or arithmetic scriptc can bound). The slot is stored as a 32-bit integer. - `Immutable` on a parameter, variable, class instance field or function result: a deep read-only view, and no code writes to the value through any reference. A module constant built from literals becomes immortal data. +- `ProgramLifetime` in a class's `implements` clause: instances of the class and its subclasses are never freed, so scriptc skips reference counting and cycle collection for them. Memory only: discarded instances are not reclaimed. -scriptc proves each contract or rejects the program with a diagnostic (SC7001-SC7006) that explains why it does not hold. A program that compiles behaves exactly as under Node.js. See https://scriptc.dev/docs/contracts. +scriptc proves each contract or rejects the program with a diagnostic (SC7001-SC7007) that explains why it does not hold. A program that compiles behaves exactly as under Node.js. See https://scriptc.dev/docs/contracts. diff --git a/packages/types/index.d.ts b/packages/types/index.d.ts index 28d26ec01..a35884df0 100644 --- a/packages/types/index.d.ts +++ b/packages/types/index.d.ts @@ -39,3 +39,14 @@ export type Immutable = T extends (...args: never[]) => unknown : T extends object ? { readonly [K in keyof T]: Immutable } : T; + +/** Class marker: `class Node implements ProgramLifetime { ... }`. Instances + * of the class and of every subclass live until the process exits: scriptc + * never reference counts them, never walks them for cycles and never frees + * them. This changes memory use only; Node.js behavior is unchanged. Use it + * for objects a program keeps until it exits anyway (syntax trees, symbol + * tables, interned types): discarded instances are never reclaimed. scriptc + * accepts it only on the root class of a hierarchy, outside library builds, + * and refuses instances that are published to other threads or registered + * for finalization. */ +export interface ProgramLifetime {} diff --git a/tests/corpus/4633-contract-program-lifetime.ts b/tests/corpus/4633-contract-program-lifetime.ts new file mode 100644 index 000000000..5eba03792 --- /dev/null +++ b/tests/corpus/4633-contract-program-lifetime.ts @@ -0,0 +1,175 @@ +// ProgramLifetime contract types (@scriptc/types): instances of these +// hierarchies are never freed in scriptc, so they take no reference counts +// and the cycle collector never walks them. Node sees ordinary classes: the +// annotation is erased. Everything else (the mortal scopes, arrays, maps and +// closures around them) is still reference counted and collected. +import type { ProgramLifetime } from "@scriptc/types"; + +abstract class SyntaxNode implements ProgramLifetime { + parent: SyntaxNode | undefined = undefined; + readonly pos: number; + constructor(pos: number) { + this.pos = pos; + } + abstract describe(): string; + depth(): number { + let d = 0; + for (let p = this.parent; p !== undefined; p = p.parent) d++; + return d; + } +} + +class Name extends SyntaxNode { + readonly text: string; + symbol: Sym | undefined = undefined; + constructor(pos: number, text: string) { + super(pos); + this.text = text; + } + describe(): string { + return `Name(${this.text})`; + } +} + +class Call extends SyntaxNode { + readonly callee: Name; + readonly args: SyntaxNode[] = []; + constructor(pos: number, callee: Name) { + super(pos); + this.callee = callee; + callee.parent = this; + } + add(arg: SyntaxNode): void { + arg.parent = this; + this.args.push(arg); + } + describe(): string { + return `Call(${this.callee.describe()}, [${this.args.map((a) => a.describe()).join(", ")}])`; + } +} + +class Sym implements ProgramLifetime { + readonly name: string; + readonly declarations: SyntaxNode[] = []; + links: Links | undefined = undefined; + constructor(name: string) { + this.name = name; + } +} + +class Links implements ProgramLifetime { + resolved: Sym | undefined = undefined; + uses = 0; +} + +class Checked implements ProgramLifetime { + readonly label: string; + constructor(label: string) { + if (label === "") throw new Error("empty label"); + this.label = label; + } +} + +// A mortal structure: scopes form cycles (parent <-> children) that only the +// collector can free, and they hold program-lifetime symbols. +class Scope { + parent: Scope | undefined; + children: Scope[] = []; + symbols = new Map(); + constructor(parent: Scope | undefined) { + this.parent = parent; + parent?.children.push(this); + } + lookup(name: string): Sym | undefined { + for (let s: Scope | undefined = this; s !== undefined; s = s.parent) { + const found = s.symbols.get(name); + if (found !== undefined) return found; + } + return undefined; + } +} + +const globals = new Map(); +function declare(scope: Scope, node: Name): Sym { + let sym = scope.symbols.get(node.text); + if (sym === undefined) { + sym = new Sym(node.text); + scope.symbols.set(node.text, sym); + globals.set(`${node.pos}:${node.text}`, sym); + } + sym.declarations.push(node); + node.symbol = sym; + return sym; +} + +function parse(source: string): Call { + const words = source.split(" "); + const call = new Call(0, new Name(0, words[0]!)); + let pos = words[0]!.length + 1; + for (const w of words.slice(1)) { + call.add(new Name(pos, w)); + pos += w.length + 1; + } + return call; +} + +const trees: Call[] = []; +const seen = new WeakMap(); +let total = 0; +for (let round = 0; round < 3; round++) { + const root = new Scope(undefined); + const inner = new Scope(root); + const tree = parse(`f${round} a b c${round}`); + declare(root, tree.callee); + for (const arg of tree.args) declare(arg === tree.args[0] ? root : inner, arg as Name); + for (const arg of tree.args) { + const sym = inner.lookup((arg as Name).text)!; + sym.links ??= new Links(); + sym.links.resolved = sym; + sym.links.uses++; + seen.set(arg, (seen.get(arg) ?? 0) + 1); + } + // Discarded program-lifetime instances: never observable as freed. + for (let i = 0; i < 100; i++) new Name(i, `tmp${i}`).describe(); + trees.push(tree); + total += tree.args.length; + // The scopes (a mortal cycle) become garbage here. +} + +const describeAll = (): string => trees.map((t) => t.describe()).join(" | "); +console.log(describeAll()); +console.log(total, trees.length, globals.size); +for (const t of trees) { + const deepest = t.args[t.args.length - 1]!; + console.log(deepest.describe(), deepest.depth(), deepest.parent === t, seen.get(deepest)); +} +const a = [...globals.values()].filter((s) => s.name === "a"); +console.log(a.length, a.map((s) => s.declarations.length).join(",")); +const linked = [...globals.values()].filter((s) => s.links !== undefined); +console.log(linked.map((s) => `${s.name}:${s.links!.uses}:${s.links!.resolved === s}`).join(" ")); + +// Values move through unions, arrays and closures like any other object. +const mixed: (SyntaxNode | Sym | string)[] = [trees[0]!, globals.get("0:f0")!, "text"]; +for (const m of mixed) { + if (typeof m === "string") console.log("string", m); + else if (m instanceof SyntaxNode) console.log("node", m.describe()); + else console.log("symbol", m.name, m.declarations[0]!.describe()); +} +const counts: number[] = []; +for (const t of trees) { + const count = (): number => t.args.length + t.callee.text.length; + counts.push(count()); +} +console.log(counts.join(",")); + +// Reassigning fields after construction drops the previous value as usual. +const reparent = trees[1]!.args[0]!; +reparent.parent = trees[2]; +console.log(reparent.depth(), reparent.parent === trees[2]); + +try { + new Checked(""); +} catch (e) { + console.log("caught", (e as Error).message); +} +console.log(new Checked("ok").label); diff --git a/tests/diagnostics/contract-program-lifetime-positions.ts b/tests/diagnostics/contract-program-lifetime-positions.ts new file mode 100644 index 000000000..3c05321dc --- /dev/null +++ b/tests/diagnostics/contract-program-lifetime-positions.ts @@ -0,0 +1,16 @@ +// ProgramLifetime is a class marker: anywhere but a class's implements +// clause it would promise nothing, so the build reports it. +import type { ProgramLifetime } from "@scriptc/types"; + +interface Marked extends ProgramLifetime { + id: number; +} + +type Lifetime = ProgramLifetime; + +class Plain { + id = 2; +} + +const marked: Marked = { id: 1 }; +console.log(marked.id, new Plain().id); diff --git a/tests/diagnostics/contract-program-lifetime-publish.ts b/tests/diagnostics/contract-program-lifetime-publish.ts new file mode 100644 index 000000000..ed60aebe8 --- /dev/null +++ b/tests/diagnostics/contract-program-lifetime-publish.ts @@ -0,0 +1,18 @@ +// Publication marks a graph immortal by its reference count, which +// program-lifetime objects already carry, so their fields would stay +// thread-local: publishing them is refused. +import { publish } from "@scriptc/threads"; +import type { ProgramLifetime } from "@scriptc/types"; + +class Tree implements ProgramLifetime { + children: Tree[] = []; + label = "root"; +} + +class Config { + tree = new Tree(); + depth = 1; +} + +const shared = publish(new Config()); +console.log(shared.depth, shared.tree.label); diff --git a/tests/diagnostics/contract-program-lifetime.ts b/tests/diagnostics/contract-program-lifetime.ts new file mode 100644 index 000000000..e67bc97d6 --- /dev/null +++ b/tests/diagnostics/contract-program-lifetime.ts @@ -0,0 +1,16 @@ +// ProgramLifetime is checked on the root class of a hierarchy, which every +// subclass then inherits. Built-in bases keep runtime state that is +// released with the object. +import type { ProgramLifetime } from "@scriptc/types"; + +class Base { + id = 0; +} + +class Derived extends Base implements ProgramLifetime { + name = "d"; +} + +class Failure extends Error implements ProgramLifetime {} + +console.log(new Derived().name, new Base().id, new Failure("x").message); diff --git a/tests/harness/__snapshots__/contract-program-lifetime-positions.ts.txt b/tests/harness/__snapshots__/contract-program-lifetime-positions.ts.txt new file mode 100644 index 000000000..5deb685ba --- /dev/null +++ b/tests/harness/__snapshots__/contract-program-lifetime-positions.ts.txt @@ -0,0 +1,17 @@ +contract-program-lifetime-positions.ts:5:26 - error SC7006: the ProgramLifetime contract is not checked in this position (ProgramLifetime is checked in the implements clause of a class declaration) + + 4 | + 5 | interface Marked extends ProgramLifetime { + | ^~~~~~~~~~~~~~~ + 6 | id: number; + + hint: declare it on the class itself: class Node implements ProgramLifetime { ... } + +contract-program-lifetime-positions.ts:9:17 - error SC7006: the ProgramLifetime contract is not checked in this position (ProgramLifetime is checked in the implements clause of a class declaration) + + 8 | + 9 | type Lifetime = ProgramLifetime; + | ^~~~~~~~~~~~~~~ + 10 | + + hint: declare it on the class itself: class Node implements ProgramLifetime { ... } \ No newline at end of file diff --git a/tests/harness/__snapshots__/contract-program-lifetime-publish.ts.txt b/tests/harness/__snapshots__/contract-program-lifetime-publish.ts.txt new file mode 100644 index 000000000..ff592c41e --- /dev/null +++ b/tests/harness/__snapshots__/contract-program-lifetime-publish.ts.txt @@ -0,0 +1,8 @@ +contract-program-lifetime-publish.ts:7:23 - error SC7007: 'Tree' is declared ProgramLifetime, but its instances can be published to other threads + + 6 | + 7 | class Tree implements ProgramLifetime { + | ^~~~~~~~~~~~~~~ + 8 | children: Tree[] = []; + + hint: publication is not supported for ProgramLifetime classes yet; remove ProgramLifetime or keep the instances out of the published graph \ No newline at end of file diff --git a/tests/harness/__snapshots__/contract-program-lifetime.ts.txt b/tests/harness/__snapshots__/contract-program-lifetime.ts.txt new file mode 100644 index 000000000..536efa314 --- /dev/null +++ b/tests/harness/__snapshots__/contract-program-lifetime.ts.txt @@ -0,0 +1,17 @@ +contract-program-lifetime.ts:10:39 - error SC7007: 'Derived' is declared ProgramLifetime but extends 'Base' + + 9 | + 10 | class Derived extends Base implements ProgramLifetime { + | ^~~~~~~~~~~~~~~ + 11 | name = "d"; + + hint: declare ProgramLifetime on the root class 'Base' instead; it applies to every subclass + +contract-program-lifetime.ts:14:40 - error SC7007: 'Failure' is declared ProgramLifetime but extends 'Error' + + 13 | + 14 | class Failure extends Error implements ProgramLifetime {} + | ^~~~~~~~~~~~~~~ + 15 | + + hint: ProgramLifetime applies to classes the program owns end to end; built-in bases (errors, emitters, streams) keep runtime state that is released with the object \ No newline at end of file From 40a2c2adf20f0e5f6368e72c751552901feecefa Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 17:06:33 -0700 Subject: [PATCH 2/3] ProgramLifetime in sanitized builds: skip the RC audit's leak report and scan instances as LeakSanitizer roots Program-lifetime instances are never freed, and neither is anything they hold, so the RC audit of --sanitize builds reported every one of them, and every string, array and map they reference, as live at exit (4633-contract-program-lifetime failed the sanitized differential lane: 306 strings, 15 arrays, 338 objects). Like a program that abandons fibers, a program that keeps program-lifetime objects now skips the leak report with "scriptc RC audit skipped: N program-lifetime object(s) never freed", which the harnesses already filter. Sanitized constructors call scr_obj_lifetime_note(obj), which counts the instance and registers it with LeakSanitizer as an intentional root, as publication does for published objects: once the audit no longer exits early, LeakSanitizer would otherwise report the instances the program drops. Plain builds emit nothing. --- packages/compiler/src/backend/llvm/classes.test.ts | 5 +++++ packages/compiler/src/backend/llvm/classes.ts | 7 +++++++ packages/runtime/src/scr_console.c | 10 ++++++++++ packages/runtime/src/scr_object.c | 12 ++++++++++++ packages/runtime/src/scr_runtime.h | 5 +++++ 5 files changed, 39 insertions(+) diff --git a/packages/compiler/src/backend/llvm/classes.test.ts b/packages/compiler/src/backend/llvm/classes.test.ts index e5fc34099..cfcafb392 100644 --- a/packages/compiler/src/backend/llvm/classes.test.ts +++ b/packages/compiler/src/backend/llvm/classes.test.ts @@ -123,7 +123,12 @@ test("ProgramLifetime hierarchies are immortal from construction and take no ref expect(nw, cls).toContain("store i64 -1, ptr %o"); expect(nw, cls).not.toContain("scr_cyc_alloc"); expect(llvm, cls).not.toContain(`@${mangleClassTrace(cls)}.cyctype`); + // The RC audit (sanitize builds) learns that the program keeps + // program-lifetime objects; other builds emit nothing for it. + expect(nw, cls).toContain("call void @scr_obj_lifetime_note(ptr %o)"); } + const plain = emitLlvmModule(mod, { pointerBits: 64, objectAudit: false }); + expect(plain).not.toContain("scr_obj_lifetime_note"); // The mortal Owner keeps its header, counts and possible-root step. expect(body(mangleClassNew("Owner"))).toContain("store i64 1, ptr %o"); expect(body(mangleClassNew("Owner"))).toContain("scr_cyc_alloc"); diff --git a/packages/compiler/src/backend/llvm/classes.ts b/packages/compiler/src/backend/llvm/classes.ts index 3e5d07722..00ff42ffc 100644 --- a/packages/compiler/src/backend/llvm/classes.ts +++ b/packages/compiler/src/backend/llvm/classes.ts @@ -632,6 +632,13 @@ export function emitClassShapes( ), ); nw.push(` store ${host.sizeType} ${lifetime ? -1 : 1}, ptr %o`); + if (lifetime && audit) { + // --sanitize builds: the RC audit skips its leak report when the + // program keeps program-lifetime objects (scr_console.c), and + // LeakSanitizer scans them as roots (scr_object.c). + host.declare(`declare void @scr_obj_lifetime_note(ptr)`); + nw.push(` call void @scr_obj_lifetime_note(ptr %o)`); + } if (meta.hierarchy) { nw.push( ` %vtp = getelementptr inbounds %${struct}, ptr %o, i64 0, i32 1`, diff --git a/packages/runtime/src/scr_console.c b/packages/runtime/src/scr_console.c index bc558318d..b43d57e6d 100644 --- a/packages/runtime/src/scr_console.c +++ b/packages/runtime/src/scr_console.c @@ -43,6 +43,7 @@ extern long scr_map_live_count(void); /* scr_map.c */ extern long scr_box_live_count(void); /* scr_closure.c */ extern long scr_closure_live_count(void); /* scr_closure.c */ extern long scr_obj_live_count(void); /* scr_object.c */ +extern long scr_obj_lifetime_count(void); /* scr_object.c */ extern long scr_union_live_count(void); /* scr_union.c */ extern long scr_dyn_live_count(void); /* scr_json.c */ extern long scr_bytes_live_count(void); /* scr_bytes.c */ @@ -59,6 +60,15 @@ static void scr_rc_audit_at_exit(void) { scr_abandoned_fibers); return; } + /* ProgramLifetime instances (@scriptc/types) are never freed, and neither + * is anything they hold: a strict audit would only report that deliberate + * retention. */ + long lifetime = scr_obj_lifetime_count(); + if (lifetime > 0) { + fprintf(stderr, "scriptc RC audit skipped: %ld program-lifetime object(s) never freed\n", + lifetime); + return; + } long strings = scr_str_live_count(); long arrays = scr_arr_live_count(); long maps = scr_map_live_count(); diff --git a/packages/runtime/src/scr_object.c b/packages/runtime/src/scr_object.c index eb1bac8fb..5e5e29216 100644 --- a/packages/runtime/src/scr_object.c +++ b/packages/runtime/src/scr_object.c @@ -87,12 +87,24 @@ void scr_record_key_miss(ScrStr *k) { #ifdef SCR_RC_AUDIT static SCR_TL long scr_live_objects = 0; +/* ProgramLifetime instances (@scriptc/types) this thread created: never + * freed by design, and neither is anything they hold (see the exit audit in + * scr_console.c). */ +static SCR_TL long scr_lifetime_objects = 0; long scr_obj_live_count(void) { return scr_live_objects; } +long scr_obj_lifetime_count(void) { return scr_lifetime_objects; } void scr_obj_alloc_note(void) { scr_live_objects++; } void scr_obj_free_note(void) { scr_live_objects--; } +/* Also an intentional LeakSanitizer root, like published objects: what the + * instance holds stays reachable. */ +void scr_obj_lifetime_note(const void *obj) { + scr_lifetime_objects++; + scr_lsan_immortal(obj); +} #else #undef scr_obj_alloc_note #undef scr_obj_free_note void scr_obj_alloc_note(void) {} void scr_obj_free_note(void) {} +void scr_obj_lifetime_note(const void *obj) { (void)obj; } #endif diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index 7b8544211..db6414b6e 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -5416,8 +5416,13 @@ long scr_dyn_live_count(void); */ void scr_obj_alloc_note(void); void scr_obj_free_note(void); +/* Emitted by the RC-audit lane's ProgramLifetime constructors, after the + * allocation note: such instances are never freed (the exit audit skips its + * leak report, and LeakSanitizer scans them as roots). */ +void scr_obj_lifetime_note(const void *obj); #ifdef SCR_RC_AUDIT long scr_obj_live_count(void); +long scr_obj_lifetime_count(void); #else /* Only the RC audit lane counts live objects. Other runtimes keep the * out-of-line no-op symbols for direct-emission objects that still call From 2ccb01800470308f919edefb67cc341143c5f0c1 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 17:07:22 -0700 Subject: [PATCH 3/3] Publish ProgramLifetime graphs with @scriptc/threads: program-lifetime instances count 2^63 until published Program-lifetime instances were created with rc == SIZE_MAX, which every runtime contract reads as "published": the frozen-object guards throw on stores, postMessage and workerData share the object by pointer instead of cloning it, and the publish walker stops there. So programs that publish could not use ProgramLifetime (SC7007 refused it), and an unpublished instance posted to a worker would have been shared instead of cloned. They are now created with the midpoint count 2^63 (2^31 on 32-bit targets). Only the class's empty retain and release helpers count references on these types, so nothing ever changes or frees that count; the midpoint keeps even an unpaired generic retain or release away from 0 and SIZE_MAX. Until the program publishes an instance it is an ordinary mutable, thread-local object to every runtime contract: stores succeed, postMessage clones it. Publication marks it SIZE_MAX like any object it reaches (the visited mark, logged and restored when a refusal undoes the publish), after which the frozen guards throw Node's TypeErrors and other threads read it by pointer. No header, extra word, runtime change or new instruction: the count itself is the published mark. The cycle analysis keeps the hierarchies untraced, so the collector never visits an instance, as for every untraced class. SC7007 no longer refuses program-lifetime classes reachable from a publish() call; it still requires a root class in an executable. Without the marker nothing changes: tsc-ts best-v7 built with and without this change is byte-identical. Tests: corpus 4634-contract-program-lifetime-publish (Node vs native): program-lifetime hierarchies with parent cycles, maps and sets of them, maps keyed by them and mortal scopes around them, published on the main thread and a worker and read on the other; writes after publication throw on both threads; unpublished instances stay writable; a refused publish leaves visited instances writable; a later publish reaches published and fresh instances; an unpublished instance in workerData is cloned. With the old SIZE_MAX count the program dies at its first constructor store. classes.test.ts covers the count on both pointer widths, the walker and the frozen guard. The contract-program-lifetime-publish diagnostics fixture is removed (the program now compiles). Docs: contracts and limitations pages, @scriptc/types. --- docs/content/docs/contracts.mdx | 9 +- docs/content/docs/limitations.mdx | 2 +- .../compiler/src/backend/cycle-analysis.ts | 4 +- .../compiler/src/backend/llvm/classes.test.ts | 92 +++++- packages/compiler/src/backend/llvm/classes.ts | 40 ++- packages/compiler/src/backend/llvm/shapes.ts | 8 +- packages/compiler/src/contracts/check.ts | 34 +- packages/compiler/src/ir/ir.ts | 7 +- .../test/ts7/baselines/order-parity.json | 12 +- packages/types/index.d.ts | 6 +- .../4634-contract-program-lifetime-publish.ts | 306 ++++++++++++++++++ .../contract-program-lifetime-publish.ts | 18 -- .../contract-program-lifetime-publish.ts.txt | 8 - 13 files changed, 459 insertions(+), 87 deletions(-) create mode 100644 tests/corpus/4634-contract-program-lifetime-publish.ts delete mode 100644 tests/diagnostics/contract-program-lifetime-publish.ts delete mode 100644 tests/harness/__snapshots__/contract-program-lifetime-publish.ts.txt diff --git a/docs/content/docs/contracts.mdx b/docs/content/docs/contracts.mdx index d37532a79..962f0728e 100644 --- a/docs/content/docs/contracts.mdx +++ b/docs/content/docs/contracts.mdx @@ -153,7 +153,7 @@ A value stored into an `Immutable` slot must be fresh: a literal, a `new` expres ## ProgramLifetime -`ProgramLifetime` marks a class whose instances a program keeps until it exits, such as the nodes of a syntax tree, the symbols of a symbol table or interned types. scriptc then never reference counts, collects or frees instances of the class and of its subclasses: they are created immortal, every retain and release on them compiles to nothing, and the cycle collector neither walks them nor needs to trace the objects that point to them. Node.js behavior is unchanged; only memory use differs, because an instance the program drops is never reclaimed. +`ProgramLifetime` marks a class whose instances a program keeps until it exits, such as the nodes of a syntax tree, the symbols of a symbol table or interned types. scriptc then never reference counts, collects or frees instances of the class and of its subclasses: they are never freed, every retain and release on them compiles to nothing, and the cycle collector neither walks them nor needs to trace the objects that point to them. Node.js behavior is unchanged; only memory use differs, because an instance the program drops is never reclaimed. ```ts:tree.ts import type { ProgramLifetime } from "@scriptc/types"; @@ -174,8 +174,9 @@ class Identifier extends SyntaxNode { scriptc checks that the promise cannot change what the program computes (SC7007): - the class is the root of its hierarchy, so every value of any class type in the hierarchy is program-lifetime; built-in bases such as `Error`, `EventEmitter` and streams are refused because they hold runtime state that is released with the object; -- the build is an executable: a library's host decides when the objects it receives are released; -- no instance can be published to other threads with `@scriptc/threads`. +- the build is an executable: a library's host decides when the objects it receives are released. + +Instances can be published to other threads with `@scriptc/threads` like other class instances: `publish()` makes them immutable and shares them by reference, writes to a published instance throw the same `TypeError` as under Node.js, and instances that were not published stay writable and are cloned by `postMessage` and `workerData`. Static code has no `WeakRef` or `FinalizationRegistry`, and a `WeakMap` or `WeakSet` entry whose key is never freed is never observable as removed, so nothing else can tell the difference. @@ -275,7 +276,7 @@ $ scriptc build reject.ts SC7007 - A ProgramLifetime class is not the root of its hierarchy, is part of a library build, or can be published to other threads. + A ProgramLifetime class is not the root of its hierarchy, or is part of a library build. diff --git a/docs/content/docs/limitations.mdx b/docs/content/docs/limitations.mdx index 1fc8c983b..b31826c71 100644 --- a/docs/content/docs/limitations.mdx +++ b/docs/content/docs/limitations.mdx @@ -130,7 +130,7 @@ A program that passes its project's TypeScript check may still use unsupported o ### Contract types -[Contract types](/docs/contracts) are checked only where they are written as the annotation of a parameter, variable, class instance field or function result; aliases, interface members and nested type positions are reported. A surrounding `try`/`catch` does not discharge `NoThrow`. `Int32` refuses parameters of functions used as values and fields of classes whose instances reach dynamic code, and `Immutable` refuses values that reach dynamic code or that another reference can still change. `ProgramLifetime` applies to root classes of executables only and cannot yet be combined with `@scriptc/threads` publication; instances the program drops are never reclaimed. +[Contract types](/docs/contracts) are checked only where they are written as the annotation of a parameter, variable, class instance field or function result; aliases, interface members and nested type positions are reported. A surrounding `try`/`catch` does not discharge `NoThrow`. `Int32` refuses parameters of functions used as values and fields of classes whose instances reach dynamic code, and `Immutable` refuses values that reach dynamic code or that another reference can still change. `ProgramLifetime` applies to root classes of executables only; instances the program drops are never reclaimed. ### three.js diff --git a/packages/compiler/src/backend/cycle-analysis.ts b/packages/compiler/src/backend/cycle-analysis.ts index dd8500908..0575198a6 100644 --- a/packages/compiler/src/backend/cycle-analysis.ts +++ b/packages/compiler/src/backend/cycle-analysis.ts @@ -52,8 +52,8 @@ export function computeTraced(mod: IrModule): { shapes: Set; unions: Set return cur; }; const unitOfClass = (name: string): string => `object:${rootOf(name)}`; - // ProgramLifetime hierarchies (contract types) are immortal from - // construction: the collector never walks them, and a garbage cycle cannot + // ProgramLifetime hierarchies (contract types) are never freed: the + // collector never walks them, and a garbage cycle cannot // pass through an object that is never freed. They are leaves: no edges // into them, none out of them. const lifetimeRoots = new Set(mod.contracts?.programLifetime ?? []); diff --git a/packages/compiler/src/backend/llvm/classes.test.ts b/packages/compiler/src/backend/llvm/classes.test.ts index cfcafb392..da8fcf958 100644 --- a/packages/compiler/src/backend/llvm/classes.test.ts +++ b/packages/compiler/src/backend/llvm/classes.test.ts @@ -86,7 +86,7 @@ test("releases split into an inlinable fast path and an out-of-line remainder", expect(slow).toContain("getelementptr inbounds %ScrVt, ptr %vt, i64 0, i32 2"); }); -test("ProgramLifetime hierarchies are immortal from construction and take no reference counts", () => { +test("ProgramLifetime hierarchies are never freed and take no reference counts", () => { const kids = { kind: "array" as const, elem: { kind: "object" as const, className: "Node" } }; const owners = { kind: "array" as const, elem: { kind: "object" as const, className: "Owner" } }; const mod: IrModule = { @@ -118,9 +118,11 @@ test("ProgramLifetime hierarchies are immortal from construction and take no ref // Retain and release are empty, so every inlined call site folds away. expect(body(mangleClassRetain(cls)), cls).toMatch(/entry:\n {2}ret ptr %o\n}/); expect(body(mangleClassRelease(cls)), cls).toMatch(/entry:\n {2}ret void\n}/); - // new: no collector header (no descriptor, no scr_cyc_alloc), rc = SIZE_MAX. + // new: no collector header (no descriptor, no scr_cyc_alloc) and the + // program-lifetime count 2^63, not SIZE_MAX (which means published). const nw = body(mangleClassNew(cls)); - expect(nw, cls).toContain("store i64 -1, ptr %o"); + expect(nw, cls).toContain("store i64 -9223372036854775808, ptr %o"); + expect(nw, cls).not.toContain("store i64 -1, ptr %o"); expect(nw, cls).not.toContain("scr_cyc_alloc"); expect(llvm, cls).not.toContain(`@${mangleClassTrace(cls)}.cyctype`); // The RC audit (sanitize builds) learns that the program keeps @@ -135,6 +137,90 @@ test("ProgramLifetime hierarchies are immortal from construction and take no ref expect(body(mangleClassRelease("Owner"))).toContain("load i16, ptr %bufp"); }); +test("published ProgramLifetime instances use the published mark every other object uses", () => { + const node = { kind: "object" as const, className: "Node" }; + const leaf = { kind: "object" as const, className: "Leaf" }; + const root = { localId: "root", name: "root", type: node }; + const mod: IrModule = { + irVersion: 15, + sourceFile: loc.file, + entry: "main", + functions: [ + { + name: "main", + params: [root], + returnType: VOID, + locals: [{ id: "root", name: "root", type: node, mutable: false }], + body: [ + { + kind: "exprStmt", + expr: { + kind: "intrinsic", + name: "threads.publish", + args: [{ kind: "varRef", localId: "root", type: node, loc }], + type: node, + loc, + }, + loc, + }, + { + kind: "fieldSet", + obj: { kind: "varRef", localId: "root", type: node, loc }, + className: "Node", + field: "weight", + value: { kind: "numLit", value: 1, type: F64, loc }, + loc, + }, + ], + loc, + }, + ], + classes: [ + { + name: "Node", + fields: [ + { name: "parent", type: node }, + { name: "weight", type: F64 }, + ], + methods: [], + loc, + }, + { + name: "Leaf", + base: "Node", + fields: [ + { name: "parent", type: node }, + { name: "weight", type: F64 }, + { name: "next", type: leaf }, + ], + methods: [], + loc, + }, + ], + contracts: { programLifetime: ["Node"] }, + }; + const llvm = emitLlvmModule(mod, { pointerBits: 64 }); + // The walker marks each instance like a mortal object (SIZE_MAX, logged so + // a refusal restores the program-lifetime count); untraced, so it never + // leaves a collector buffer. + expect(llvm).toContain("call void @scr_publish(ptr"); + expect(llvm).toMatch(/@scr_pub_mark\(ptr %ctx, ptr %o, i1 false, i32 4\)/); + // The frozen-object guard of the store tests the count against SIZE_MAX: + // an unpublished instance (2^63) stays writable. + const store = + llvm.split(/^define /m).find((fn) => fn.includes("call void @scr_throw_published_field(")) ?? + ""; + expect(store).toMatch(/icmp eq i64 %\S+, -1/); + expect(store).toContain("call void @scr_publish(ptr"); + // 32-bit targets use 2^31. + const llvm32 = emitLlvmModule(mod, { pointerBits: 32 }); + const nw32 = + new RegExp(`^define internal [a-z]+ @${mangleClassNew("Leaf")}\\([^]*?^}`, "m").exec( + llvm32, + )?.[0] ?? ""; + expect(nw32).toContain("store i32 -2147483648, ptr %o"); +}); + function graph() { const classes = [ { name: "Root" }, diff --git a/packages/compiler/src/backend/llvm/classes.ts b/packages/compiler/src/backend/llvm/classes.ts index 00ff42ffc..3e60fd1b6 100644 --- a/packages/compiler/src/backend/llvm/classes.ts +++ b/packages/compiler/src/backend/llvm/classes.ts @@ -311,17 +311,36 @@ export interface ClassHost extends ShapeHost { } /** Whether `meta`'s instances live until the process exits (a - * ProgramLifetime hierarchy, contracts/check.ts): `new` makes them immortal - * (rc == SIZE_MAX) without a collector header, nothing frees them, and the - * class's retain and release entry points are empty, so reference counting - * on any static type of the hierarchy folds away. Immortal objects are - * already part of every RC and collector contract (static literals and - * published graphs): dynamic retains and releases skip rc == SIZE_MAX, and - * the collector skips such children before reading a header. */ + * ProgramLifetime hierarchy, contracts/check.ts): `new` allocates them + * without a collector header and with the reference count + * programLifetimeRc, nothing frees them, and the class's retain and release + * entry points are empty, so reference counting on any static type of the + * hierarchy folds away. The cycle analysis leaves the hierarchy untraced, so + * no trace adapter ever visits an instance (as for every untraced class). */ export function programLifetimeClass(host: ClassHost, meta: LlClassMeta): boolean { return host.programLifetime?.has(meta.root.def.name) === true && meta.root.def.runtime !== true; } +/** The reference count a program-lifetime instance is created with: the + * midpoint of the size_t range (2^63, or 2^31 on 32-bit targets). + * + * SIZE_MAX would also keep the object alive, but every runtime contract + * reads rc == SIZE_MAX as "published": immutable (the frozen-object guards + * throw on stores), shareable by pointer (postMessage and workerData deliver + * it without a clone) and already walked (publication stops there). A + * program-lifetime object is none of these until the program publishes it; + * it is mutable and owned by the thread that made it. Publication then marks + * it SIZE_MAX like any other object it reaches (and restores this count when + * a refusal undoes the publish), so the count doubles as the published and + * visited mark without a header or an extra word. + * + * Only the class's empty helpers count references on these types, so any + * count away from 0, 1 and SIZE_MAX would do; the midpoint keeps even an + * unpaired generic retain or release from ever reaching either end. */ +export function programLifetimeRc(sizeType: "i32" | "i64"): string { + return sizeType === "i32" ? "-2147483648" : "-9223372036854775808"; +} + /** The newFn initialization stores for fields whose type ADMITS undefined * (undefFieldInitLineC's LLVM twin): undefined-armed union fields start * at the interned unit instance; dyn fields start at native undefined; @@ -621,8 +640,9 @@ export function emitClassShapes( ), ``, ); - // A program-lifetime instance is immortal from construction: no - // collector header (nothing walks or frees it) and rc == SIZE_MAX. + // A program-lifetime instance is never freed: no collector header + // (nothing walks or frees it) and the program-lifetime count, which + // publication turns into SIZE_MAX (programLifetimeRc). nw.push( ...emitObjectAlloc( host, @@ -631,7 +651,7 @@ export function emitClassShapes( meta.hierarchy ? 16 : 8, ), ); - nw.push(` store ${host.sizeType} ${lifetime ? -1 : 1}, ptr %o`); + nw.push(` store ${host.sizeType} ${lifetime ? programLifetimeRc(host.sizeType) : 1}, ptr %o`); if (lifetime && audit) { // --sanitize builds: the RC audit skips its leak report when the // program keeps program-lifetime objects (scr_console.c), and diff --git a/packages/compiler/src/backend/llvm/shapes.ts b/packages/compiler/src/backend/llvm/shapes.ts index ad2d5f147..b46e8a938 100644 --- a/packages/compiler/src/backend/llvm/shapes.ts +++ b/packages/compiler/src/backend/llvm/shapes.ts @@ -746,10 +746,10 @@ export function retainBody( ]; } -/** The retain or release entry point of a class whose every instance is - * immortal from creation (rc == SIZE_MAX, no collector header; see - * classes.ts programLifetimeClass): nothing to count, so every inlined call - * site folds away. */ +/** The retain or release entry point of a class whose every instance lives + * until the process exits (no collector header; see classes.ts + * programLifetimeClass and programLifetimeRc): nothing to count, so every + * inlined call site folds away. */ function immortalRcBody(fnName: string, ret: "ptr" | "void", comment: string): string[] { return [ `define internal ${ret} @${fnName}(ptr %o) ${FN_ATTRS} {${comment ? ` ; ${comment} (immortal)` : ""}`, diff --git a/packages/compiler/src/contracts/check.ts b/packages/compiler/src/contracts/check.ts index 557f4aa46..aea4cb35a 100644 --- a/packages/compiler/src/contracts/check.ts +++ b/packages/compiler/src/contracts/check.ts @@ -15,8 +15,8 @@ * else references them), and such values never escape to code the IR * cannot see. Module constants built from fresh literals become immortal. * - ProgramLifetime (class marker): the class is a hierarchy root in an - * executable whose instances cannot be published; its hierarchy is then - * immortal from construction (classes.ts programLifetimeClass). + * executable; its hierarchy is then never freed and takes no reference + * counts (classes.ts programLifetimeClass). * * Verified facts land on the IR as `contracts` (IrContracts). */ import type { ScrDiagnostic } from "../diagnostics/diagnostic.js"; @@ -43,7 +43,6 @@ import { import { findInitializerBindings, withInitializerBindings } from "../ir/initializer-bindings.js"; import { dynamicallyStoredFamilies, functionCallFacts, Int32Slots } from "../ir/int32-slots.js"; import { computeMayThrow, type MayThrowReason } from "../backend/may-throw.js"; -import { computePublishedTypes } from "../backend/publication.js"; import { SourceLocations } from "../backend/source-locations.js"; import { contractLocKey, @@ -257,18 +256,16 @@ class ContractChecker { * Checked: the class is the root of its hierarchy (every subclass, and so * every value of any static type in the hierarchy, is then program- * lifetime, which is what lets the backend elide reference counting on - * those static types); the build is an executable (library hosts own the - * lifetimes of the objects they receive); and no instance can be - * published to other threads (publication marks a graph immortal by its - * reference count and walks it by that mark, which program-lifetime - * objects already carry, so their fields would stay thread-local). Static - * code has no WeakRef or FinalizationRegistry; weak collections stay - * correct because an entry keyed by an object that is never freed is - * never observable as removed. */ + * those static types); and the build is an executable (library hosts own + * the lifetimes of the objects they receive). Publication to other + * threads needs no check: an instance carries a reference count of its + * own until publication marks it like any other object it reaches + * (classes.ts programLifetimeRc). Static code has no WeakRef or + * FinalizationRegistry; weak collections stay correct because an entry + * keyed by an object that is never freed is never observable as removed. */ private checkProgramLifetime(): void { if (this.notes.classes.size === 0) return; const roots: string[] = []; - const notes = new Map(); for (const [key, note] of this.notes.classes) for (const cls of this.classesAt(key)) { if (cls.base !== undefined) { @@ -294,21 +291,8 @@ class ContractChecker { continue; } roots.push(cls.name); - notes.set(cls.name, note); } if (roots.length === 0) return; - const published = computePublishedTypes(this.mod); - if (published !== null) - for (const root of roots) { - if (!published.classGuarded(root)) continue; - const note = notes.get(root)!; - this.report( - "SC7007", - note.loc, - `'${note.name}' is declared ProgramLifetime, but its instances can be published to other threads`, - "publication is not supported for ProgramLifetime classes yet; remove ProgramLifetime or keep the instances out of the published graph", - ); - } this.record("programLifetime", roots); } diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index b0805e86d..cb75ed57f 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -1007,9 +1007,10 @@ export interface IrContracts { * need no reference counting. */ immortalGlobals?: string[]; /** Root classes declared `implements ProgramLifetime`: instances of every - * class in their hierarchies are immortal from construction (rc == - * SIZE_MAX, no collector header), their static reference counting is - * elided, and the cycle analysis treats them as leaves. */ + * class in their hierarchies are never freed (no collector header, a + * reference count that publication alone turns into SIZE_MAX), their + * static reference counting is elided, and the cycle analysis treats them + * as leaves. */ programLifetime?: string[]; } diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index b4d51f955..6965c0177 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -15884,6 +15884,12 @@ ], "diags": [] }, + "/tests/corpus/4634-contract-program-lifetime-publish.ts": { + "order": [ + "/tests/corpus/4634-contract-program-lifetime-publish.ts" + ], + "diags": [] + }, "/tests/diagnostics/contract-immutable.ts": { "order": [ "/tests/diagnostics/contract-immutable.ts" @@ -15920,12 +15926,6 @@ ], "diags": [] }, - "/tests/diagnostics/contract-program-lifetime-publish.ts": { - "order": [ - "/tests/diagnostics/contract-program-lifetime-publish.ts" - ], - "diags": [] - }, "/tests/diagnostics/contract-program-lifetime.ts": { "order": [ "/tests/diagnostics/contract-program-lifetime.ts" diff --git a/packages/types/index.d.ts b/packages/types/index.d.ts index a35884df0..2621866c2 100644 --- a/packages/types/index.d.ts +++ b/packages/types/index.d.ts @@ -46,7 +46,7 @@ export type Immutable = T extends (...args: never[]) => unknown * them. This changes memory use only; Node.js behavior is unchanged. Use it * for objects a program keeps until it exits anyway (syntax trees, symbol * tables, interned types): discarded instances are never reclaimed. scriptc - * accepts it only on the root class of a hierarchy, outside library builds, - * and refuses instances that are published to other threads or registered - * for finalization. */ + * accepts it only on the root class of a hierarchy, outside library builds. + * Instances can be published to other threads (`@scriptc/threads`) like any + * other class instance. */ export interface ProgramLifetime {} diff --git a/tests/corpus/4634-contract-program-lifetime-publish.ts b/tests/corpus/4634-contract-program-lifetime-publish.ts new file mode 100644 index 000000000..ff12ca483 --- /dev/null +++ b/tests/corpus/4634-contract-program-lifetime-publish.ts @@ -0,0 +1,306 @@ +import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads"; +import { publish } from "@scriptc/threads"; +import type { ProgramLifetime } from "@scriptc/types"; + +// ProgramLifetime graphs published with @scriptc/threads. scriptc never +// frees program-lifetime instances and takes no reference counts on them; +// publish() still makes them immutable and shares them by reference with +// other threads, exactly like mortal objects, while Node.js deep-freezes +// them and clones what it posts. Instances that are not published stay +// writable and are cloned by workerData and postMessage. Receivers publish() +// what they receive and read only fields, so both runtimes print the same. + +abstract class SyntaxNode implements ProgramLifetime { + readonly kind: string; + parent: SyntaxNode | undefined = undefined; + readonly pos: number; + flags = 0; + constructor(kind: string, pos: number) { + this.kind = kind; + this.pos = pos; + } + abstract describe(): string; +} + +class Name extends SyntaxNode { + readonly text: string; + symbol: Sym | undefined = undefined; + constructor(pos: number, text: string) { + super("name", pos); + this.text = text; + } + describe(): string { + return `Name(${this.text})`; + } +} + +class Call extends SyntaxNode { + readonly callee: Name; + readonly args: SyntaxNode[] = []; + constructor(pos: number, callee: Name) { + super("call", pos); + this.callee = callee; + callee.parent = this; + } + add(arg: SyntaxNode): void { + arg.parent = this; + this.args.push(arg); + } + describe(): string { + return `Call(${this.callee.describe()}, [${this.args.map((a) => a.describe()).join(", ")}])`; + } +} + +class Sym implements ProgramLifetime { + readonly name: string; + readonly declarations: SyntaxNode[] = []; + readonly members = new Map(); + uses = 0; + constructor(name: string) { + this.name = name; + } +} + +class Counter implements ProgramLifetime { + readonly label: string; + count = 0; + constructor(label: string) { + this.label = label; + } +} + +// Mortal (reference-counted, collected) classes around the program-lifetime +// objects. +class Scope { + parent: Scope | undefined; + readonly symbols = new Map(); + readonly seen = new Set(); + span: { pos: number; end: number }; + constructor(parent: Scope | undefined, pos: number) { + this.parent = parent; + this.span = { pos, end: pos + 100 }; + } +} + +class Unit { + readonly calls: Call[] = []; + readonly globals: Scope; + readonly inner: Scope; + readonly declared = new Map(); + readonly symbols: Sym[] = []; + note: string; + constructor(note: string) { + this.note = note; + this.globals = new Scope(undefined, 0); + this.inner = new Scope(this.globals, 10); + } +} + +class Bundle { + hook: () => number; + readonly first: Name; + constructor(first: Name, hook: () => number) { + this.first = first; + this.hook = hook; + } +} + +function build(prefix: string): Unit { + const unit = new Unit(`${prefix}unit`); + const log = new Sym(`${prefix}log`); + const value = new Sym(`${prefix}value`); + // A cycle through a map of program-lifetime objects. + log.members.set("self", log); + log.members.set("value", value); + value.members.set("owner", log); + for (let i = 0; i < 3; i++) { + const callee = new Name(i * 10, `${prefix}log`); + callee.symbol = log; + const call = new Call(i * 10 + 1, callee); + const arg = new Name(i * 10 + 5, `${prefix}value`); + arg.symbol = value; + call.add(arg); + if (i === 2) call.add(new Call(i * 10 + 7, new Name(i * 10 + 8, `${prefix}inner`))); + log.declarations.push(callee); + value.declarations.push(arg); + unit.calls.push(call); + unit.inner.seen.add(call); + } + unit.globals.symbols.set(log.name, log); + unit.inner.symbols.set(value.name, value); + unit.declared.set(log, log.declarations); + unit.declared.set(value, value.declarations); + unit.symbols.push(log, value); + return unit; +} + +// Field reads only: a receiver's Node.js clone has no prototype. +function node(n: SyntaxNode): string { + let out = `${n.kind}@${n.pos}`; + if (n.flags !== 0) out += `!${n.flags}`; + if (n.parent !== undefined) out += `^${n.parent.pos}`; + if (n.kind === "name") { + const name = n as Name; + out += `:${name.text}`; + if (name.symbol !== undefined) out += `->${name.symbol.name}`; + } else { + const call = n as Call; + out += `(${node(call.callee)}`; + for (const a of call.args) out += ` ${node(a)}`; + out += ")"; + } + return out; +} + +function sym(s: Sym): string { + let out = `${s.name}#${s.uses}[${s.declarations.map((d) => d.pos).join(",")}]`; + for (const [k, m] of s.members) out += ` ${k}=${m.name}`; + return out; +} + +function summarize(u: Unit): string { + let out = `${u.note}: ${u.calls.map((c) => node(c)).join(" ")}`; + for (const s of u.symbols) out += ` | ${sym(s)}`; + for (const [s, decls] of u.declared) out += ` | ${s.name}:${decls.length}`; + out += ` | globals ${[...u.globals.symbols.keys()].join(",")}`; + out += ` | inner ${[...u.inner.symbols.keys()].join(",")} seen ${u.inner.seen.size}`; + out += ` ${u.inner.span.pos}-${u.inner.span.end} up ${u.inner.parent === u.globals}`; + return out; +} + +const lines: string[] = []; +function log(line: string): void { + if (isMainThread) console.log(line); + else lines.push(line); +} + +function attempt(what: string, write: () => void): void { + try { + write(); + log(`${what} wrote`); + } catch (e) { + // A receiver's objects keep their class in scriptc and arrive as plain + // clones in Node.js, so its messages name the class differently. + const message = isMainThread ? (e as Error).message : (e as Error).message.replace(/#<\w+>/, "#"); + log(`${what} ${(e as Error).name} ${message}`); + } +} + +function writes(side: string, u: Unit): void { + const call = u.calls[1]!; + const base: SyntaxNode = call.callee; + const value = u.symbols[1]!; + attempt(`${side} node field`, () => { + call.callee.symbol = undefined; + }); + attempt(`${side} base field`, () => { + base.flags = 4; + }); + attempt(`${side} parent`, () => { + call.parent = call; + }); + attempt(`${side} symbol increment`, () => { + value.uses++; + }); + attempt(`${side} members set`, () => { + value.members.set("again", value); + }); + attempt(`${side} declarations push`, () => { + value.declarations.push(call); + }); + attempt(`${side} args index`, () => { + call.args[0] = call; + }); + attempt(`${side} declared delete`, () => { + u.declared.delete(value); + }); + attempt(`${side} seen add`, () => { + u.inner.seen.add(base); + }); + attempt(`${side} scope field`, () => { + u.inner.parent = undefined; + }); + attempt(`${side} span`, () => { + u.inner.span.end = 1; + }); + attempt(`${side} note`, () => { + u.note = "changed"; + }); +} + +if (isMainThread) { + // Unpublished instances of the same classes stay writable. + const scratch = build("s-"); + scratch.calls[0]!.flags = 3; + scratch.calls[0]!.callee.symbol = scratch.symbols[1]; + scratch.symbols[0]!.uses++; + scratch.symbols[0]!.members.set("extra", scratch.symbols[0]!); + scratch.symbols[0]!.declarations.push(scratch.calls[2]!); + console.log("scratch", summarize(scratch)); + console.log("dispatch", scratch.calls.map((c) => c.describe()).join("; ")); + + // A refused publish leaves the program-lifetime objects it already + // visited unpublished and writable. + const k = scratch.calls.length; + const bundle = new Bundle(scratch.calls[1]!.callee, () => k); + attempt("publish function", () => { + publish(bundle); + }); + bundle.first.flags = 7; + bundle.first.symbol!.uses = 5; + bundle.first.parent!.flags = 8; + bundle.hook = () => 2 * k; + console.log("after refusal", node(bundle.first.parent!), sym(bundle.first.symbol!), bundle.hook()); + + // A program-lifetime root, published directly. + const lone = publish(new Name(500, "lone")); + attempt("lone field", () => { + lone.flags = 1; + }); + console.log("lone", node(lone), publish(lone) === lone); + + const root = publish(build("")); + console.log("main", summarize(root)); + console.log("dispatch", root.calls.map((c) => c.describe()).join("; ")); + console.log("same", publish(root) === root, publish(root.calls[0]!) === root.calls[0]); + writes("main", root); + console.log("after writes", summarize(root)); + + // A later graph that reaches already published program-lifetime objects + // and new ones. + const later = new Unit("later"); + later.calls.push(root.calls[0]!, new Call(900, new Name(901, "fresh"))); + later.symbols.push(root.symbols[0]!); + later.declared.set(root.symbols[0]!, root.symbols[0]!.declarations); + publish(later); + attempt("later fresh", () => { + later.calls[1]!.flags = 2; + }); + console.log("later", summarize(later)); + + // Not published: workerData clones it, so the worker's writes stay there. + const loose = new Counter("loose"); + loose.count = 1; + const worker = new Worker(new URL(import.meta.url), { workerData: { root, label: "data", loose } }); + worker.on("message", (message: unknown) => { + const reply = message as { lines: string[]; summary: string; mine: Unit }; + publish(reply.mine); + for (const line of reply.lines) console.log(line); + console.log("from worker", reply.summary); + console.log("worker graph", summarize(reply.mine)); + loose.count++; + }); + worker.on("exit", (code: number) => { + console.log("exit", code, "loose", loose.label, loose.count); + }); +} else { + const data = workerData as { root: Unit; label: string; loose: unknown }; + const root = publish(data.root); + log(`worker ${data.label} ${summarize(root)}`); + writes("worker", root); + const loose = data.loose as { label: string; count: number }; + loose.count = 99; + log(`worker loose ${loose.label} ${loose.count}`); + const mine = publish(build("w-")); + parentPort!.postMessage({ lines, summary: summarize(root), mine }); +} diff --git a/tests/diagnostics/contract-program-lifetime-publish.ts b/tests/diagnostics/contract-program-lifetime-publish.ts deleted file mode 100644 index ed60aebe8..000000000 --- a/tests/diagnostics/contract-program-lifetime-publish.ts +++ /dev/null @@ -1,18 +0,0 @@ -// Publication marks a graph immortal by its reference count, which -// program-lifetime objects already carry, so their fields would stay -// thread-local: publishing them is refused. -import { publish } from "@scriptc/threads"; -import type { ProgramLifetime } from "@scriptc/types"; - -class Tree implements ProgramLifetime { - children: Tree[] = []; - label = "root"; -} - -class Config { - tree = new Tree(); - depth = 1; -} - -const shared = publish(new Config()); -console.log(shared.depth, shared.tree.label); diff --git a/tests/harness/__snapshots__/contract-program-lifetime-publish.ts.txt b/tests/harness/__snapshots__/contract-program-lifetime-publish.ts.txt deleted file mode 100644 index ff592c41e..000000000 --- a/tests/harness/__snapshots__/contract-program-lifetime-publish.ts.txt +++ /dev/null @@ -1,8 +0,0 @@ -contract-program-lifetime-publish.ts:7:23 - error SC7007: 'Tree' is declared ProgramLifetime, but its instances can be published to other threads - - 6 | - 7 | class Tree implements ProgramLifetime { - | ^~~~~~~~~~~~~~~ - 8 | children: Tree[] = []; - - hint: publication is not supported for ProgramLifetime classes yet; remove ProgramLifetime or keep the instances out of the published graph \ No newline at end of file