diff --git a/docs/content/docs/contracts.mdx b/docs/content/docs/contracts.mdx index e8cafa2a5..d16e269b8 100644 --- a/docs/content/docs/contracts.mdx +++ b/docs/content/docs/contracts.mdx @@ -33,6 +33,11 @@ Contract types are type aliases that mean nothing to TypeScript or Node.js but a No chain of references leads from an object of the class or type back to itself. Its objects carry no cycle-collector header and are freed by reference counting alone. + + ProgramLifetime + Instances of the class are kept until the program exits. + Instances are immortal from construction: no reference counting, no cycle collection, never freed. + @@ -117,6 +122,8 @@ scriptc recognizes a contract where it is written as the annotation of a declara
Parameters, variables, class instance fields and function results.
Acyclic
Classes (class C implements Acyclic), interfaces (interface I extends Acyclic) and object type aliases (type T = Acyclic<...> over an object type).
+
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. @@ -251,6 +258,37 @@ $ scriptc build graph.ts 1 error. ``` +## 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 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"; + +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 (SC7008): + +- 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. + +A class can be both `ProgramLifetime` and `Acyclic`. `Acyclic` is still checked against the references the program writes, so a chain of references that passes through a program-lifetime instance and leads back is still a cycle (SC7007). + +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. + ## Diagnostics Each failed contract names the declaration and the reason: @@ -349,5 +387,9 @@ $ scriptc build reject.ts SC7007 An Acyclic class or type can lie on a reference cycle; the message shows the cycle. + + SC7008 + 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 e0c472112..5fb32a984 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, and `Acyclic` only on classes, interfaces and object type aliases; other aliases, interface members and nested type positions are reported. In programs that use worker threads, 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, `Acyclic` only on classes, interfaces and object type aliases, and `ProgramLifetime` only in the `implements` clause of a class declaration; other aliases, interface members and nested type positions are reported. In programs that use worker threads, 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.test.ts b/packages/compiler/src/backend/cycle-analysis.test.ts index 3ce624d23..ac09b4642 100644 --- a/packages/compiler/src/backend/cycle-analysis.test.ts +++ b/packages/compiler/src/backend/cycle-analysis.test.ts @@ -691,6 +691,43 @@ test("library builds keep the mutable-graph analysis", () => { 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"]); +}); + test("explanations name a cycle through a mutable edge, or the intrinsic field reached", () => { const mod = module(); mod.records = [ diff --git a/packages/compiler/src/backend/cycle-analysis.ts b/packages/compiler/src/backend/cycle-analysis.ts index 1a9b6e4ab..3b5d1ca21 100644 --- a/packages/compiler/src/backend/cycle-analysis.ts +++ b/packages/compiler/src/backend/cycle-analysis.ts @@ -110,6 +110,13 @@ function analyzeCycles( return cur; }; const unitOfClass = (name: string): string => `object:${rootOf(name)}`; + // 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 ?? []); + const programLifetime = (className: string): boolean => + lifetimeRoots.size > 0 && lifetimeRoots.has(rootOf(className)); const mutability = computeCycleMutability(mod, unitOfClass); const shapeDefs = [ ...classes.map((c) => ({ @@ -219,6 +226,7 @@ function analyzeCycles( case "object": case "record": case "union": { + if (t.kind === "object" && programLifetime(t.className)) break; const dependency = t.kind === "union" ? unions.get(t.unionId) @@ -251,6 +259,7 @@ function analyzeCycles( } }; 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..da8fcf958 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,141 @@ 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 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 = { + 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) and the + // program-lifetime count 2^63, not SIZE_MAX (which means published). + const nw = body(mangleClassNew(cls)); + 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 + // 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"); + 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 9f7794925..3e60fd1b6 100644 --- a/packages/compiler/src/backend/llvm/classes.ts +++ b/packages/compiler/src/backend/llvm/classes.ts @@ -305,6 +305,40 @@ 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` 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 @@ -432,6 +466,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 +511,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 +550,7 @@ export function emitClassShapes( dispatchName, traced, `release ${cls.name}`, + lifetime, ), ``, ); @@ -579,6 +615,7 @@ export function emitClassShapes( freeBody, `release ${cls.name}`, bounded, + lifetime, ), ``, ); @@ -603,10 +640,25 @@ export function emitClassShapes( ), ``, ); + // 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, 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 ? 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 + // 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/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index 56b18e2b4..e6529c0b1 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 e45799f18..870413515 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -770,6 +770,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. */ @@ -858,6 +861,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 = @@ -1043,6 +1047,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 2a3b983e7..08d24d682 100644 --- a/packages/compiler/src/backend/llvm/shapes.ts +++ b/packages/compiler/src/backend/llvm/shapes.ts @@ -731,8 +731,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:`, @@ -752,6 +758,19 @@ export function retainBody(host: ShapeHost, fnName: string, comment = ""): strin ]; } +/** 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)` : ""}`, + `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 { @@ -772,6 +791,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)`); @@ -786,7 +806,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`, @@ -826,8 +846,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 db2517580..3e1570917 100644 --- a/packages/compiler/src/contracts/check.ts +++ b/packages/compiler/src/contracts/check.ts @@ -18,6 +18,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; 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"; @@ -245,6 +248,7 @@ class ContractChecker { } run(): ContractCheckResult { + this.checkProgramLifetime(); this.checkNoThrow(); this.checkAcyclic(); const int32 = new Int32Contracts(this); @@ -263,6 +267,53 @@ 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); 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[] = []; + 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( + "SC7008", + 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( + "SC7008", + 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); + } + if (roots.length === 0) return; + this.record("programLifetime", roots); + } + /* ── Acyclic ── */ private checkAcyclic(): void { diff --git a/packages/compiler/src/frontend/contract-types.ts b/packages/compiler/src/frontend/contract-types.ts index 21004bb71..bd396dacd 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 class markers (Acyclic, + * ProgramLifetime) in heritage clauses. 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,14 @@ import * as ts from "./ts7/adapter.js"; export const CONTRACT_TYPES_MODULE = "@scriptc/types"; -export type ContractMarker = "Int32" | "NoThrow" | "Immutable" | "Acyclic"; -const MARKERS: ReadonlySet = new Set(["Int32", "NoThrow", "Immutable", "Acyclic"]); +export type ContractMarker = "Int32" | "NoThrow" | "Immutable" | "Acyclic" | "ProgramLifetime"; +const MARKERS: ReadonlySet = new Set([ + "Int32", + "NoThrow", + "Immutable", + "Acyclic", + "ProgramLifetime", +]); /** One written contract: what it promises and where the annotation is. */ export interface ContractNote { @@ -53,6 +60,8 @@ export interface ContractAnnotations { fields: Map; /** Acyclic classes and object types. */ acyclic: AcyclicNote[]; + /** ProgramLifetime classes (`implements ProgramLifetime`) by class key. */ + classes: Map; diagnostics: ScrDiagnostic[]; /** Number of annotations found (for timing and tests). */ count: number; @@ -74,7 +83,7 @@ function markerOf(checker: ts.TypeChecker, ref: ts.TypeReferenceNode): ContractM } /** The marker a heritage clause names (`implements Acyclic`, - * `implements sc.Acyclic`). */ + * `implements sc.ProgramLifetime`). */ function markerOfHeritage( checker: ts.TypeChecker, node: ts.ExpressionWithTypeArguments, @@ -143,6 +152,7 @@ export function collectContractAnnotations( bindings: new Map(), fields: new Map(), acyclic: [], + classes: new Map(), diagnostics: [], count: 0, }; @@ -167,6 +177,7 @@ export function collectContractAnnotations( if (marker === null) return; out.count++; if (marker === "Acyclic") placeAcyclicAlias(out, checker, node, resolveUnit); + else if (marker === "ProgramLifetime") unsupported(out, node, marker); else place(out, checker, node, marker); }, kinds, @@ -192,7 +203,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", @@ -200,12 +213,16 @@ 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`, ), ); } -/** `class C implements Acyclic` and `interface I extends Acyclic`. */ +/** The heritage positions of the class markers: `class C implements + * Acyclic`, `interface I extends Acyclic` and `class C implements + * ProgramLifetime` (a class declaration only). */ function placeHeritage( out: ContractAnnotations, checker: ts.TypeChecker, @@ -215,6 +232,22 @@ function placeHeritage( ): void { const clause = node.parent; const owner = clause?.parent; + if (marker === "ProgramLifetime") { + if ( + clause === undefined || + !ts.isHeritageClause(clause) || + clause.token !== ts.SyntaxKind.ImplementsKeyword || + owner === undefined || + !ts.isClassDeclaration(owner) + ) + return unsupported(out, node, marker); + out.classes.set(contractLocKey(locOf(owner)), { + marker, + loc: locOf(node), + name: nameText(owner.name), + }); + return; + } if ( marker !== "Acyclic" || clause === undefined || diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index f7409c7fa..0338e26d7 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -1010,6 +1010,12 @@ 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 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[]; } export type IrFfiValueParamClass = diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4633-contract-program-lifetime.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4633-contract-program-lifetime.ts.json new file mode 100644 index 000000000..a9a4276fc --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4633-contract-program-lifetime.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/4633-contract-program-lifetime.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4634-contract-program-lifetime-publish.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4634-contract-program-lifetime-publish.ts.json new file mode 100644 index 000000000..29a8cd44c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4634-contract-program-lifetime-publish.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/4634-contract-program-lifetime-publish.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/contract-program-lifetime-positions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/contract-program-lifetime-positions.ts.json new file mode 100644 index 000000000..e0a6d1576 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/contract-program-lifetime-positions.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/contract-program-lifetime-positions.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/contract-program-lifetime.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/contract-program-lifetime.ts.json new file mode 100644 index 000000000..03783709b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/contract-program-lifetime.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/contract-program-lifetime.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_console.c b/packages/runtime/src/scr_console.c index 360f4f2ad..f45502e2a 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 260d810c9..6157b7af5 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -5446,8 +5446,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 diff --git a/packages/types/README.md b/packages/types/README.md index 2723c9f8b..6761bd940 100644 --- a/packages/types/README.md +++ b/packages/types/README.md @@ -22,5 +22,6 @@ function isLetter(ch: number): NoThrow { - `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. - `Acyclic` on a class (`class C implements Acyclic`), interface (`interface I extends Acyclic`) or object type alias (`type T = Acyclic<{ ... }>`): no chain of references can lead from such an object back to itself. Its objects carry no cycle-collector header. +- `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-SC7007) 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-SC7008) 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 ae833f020..88612471f 100644 --- a/packages/types/index.d.ts +++ b/packages/types/index.d.ts @@ -54,3 +54,14 @@ export type Acyclic = T; * only this package declares, so the clause adds no members and refers to * nothing else in the program. */ export interface AcyclicObject {} + +/** 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. + * Instances can be published to other threads (`@scriptc/threads`) like any + * other class instance. */ +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..ff1748c64 --- /dev/null +++ b/tests/corpus/4633-contract-program-lifetime.ts @@ -0,0 +1,191 @@ +// 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 { Acyclic, 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); + +// A class can be both: Acyclic is still checked against the fields as +// written, and the instances are never freed. +class Interned implements Acyclic, ProgramLifetime { + readonly text: string; + readonly next: Interned | null; + constructor(text: string, next: Interned | null) { + this.text = text; + this.next = next; + } +} +let interned: Interned | null = null; +for (const word of ["alpha", "beta", "gamma"]) interned = new Interned(word, interned); +const words: string[] = []; +for (let w = interned; w !== null; w = w.next) words.push(w.text); +console.log(words.join(" ")); 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-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.ts b/tests/diagnostics/contract-program-lifetime.ts new file mode 100644 index 000000000..1e2ccf198 --- /dev/null +++ b/tests/diagnostics/contract-program-lifetime.ts @@ -0,0 +1,31 @@ +// 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 { Acyclic, ProgramLifetime } from "@scriptc/types"; + +class Base { + id = 0; +} + +class Derived extends Base implements ProgramLifetime { + name = "d"; +} + +class Failure extends Error implements ProgramLifetime {} + +// Acyclic is checked on the reference graph as written: a reference +// through a ProgramLifetime instance still closes a cycle. +class Tree implements ProgramLifetime { + holder: Holder | undefined = undefined; +} + +class Holder implements Acyclic { + readonly tree: Tree; + constructor(tree: Tree) { + this.tree = tree; + } +} + +const tree = new Tree(); +tree.holder = new Holder(tree); +console.log(new Derived().name, new Base().id, new Failure("x").message, tree.holder.tree === tree); 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.ts.txt b/tests/harness/__snapshots__/contract-program-lifetime.ts.txt new file mode 100644 index 000000000..519f62b7c --- /dev/null +++ b/tests/harness/__snapshots__/contract-program-lifetime.ts.txt @@ -0,0 +1,26 @@ +contract-program-lifetime.ts:10:39 - error SC7008: '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 SC7008: '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 + +contract-program-lifetime.ts:22:25 - error SC7007: 'Holder' is declared Acyclic but can lie on a reference cycle: Holder.tree → Tree.holder → Holder (Tree.holder can be assigned after construction, as at contract-program-lifetime.ts:30:1) + + 21 | + 22 | class Holder implements Acyclic { + | ^~~~~~~ + 23 | readonly tree: Tree; + + hint: assign the reference only in the constructor (before `this` escapes), so a new object can only point to older ones, or remove Acyclic \ No newline at end of file