Documentation content

Experimental APIs

The @scriptc/experimental package holds APIs that are still being designed. Each export has a Node.js implementation, so a program that uses them runs unchanged under Node.js, and scriptc compiles them into the program. Their behavior may change between releases.

Add @scriptc/experimental to your project's dependencies.

LifetimeRegion

Instances of ProgramLifetime classes are never freed, which suits a program that builds one syntax tree or symbol table and exits. A long-running program, such as a compiler in watch mode, builds a new generation of these objects on every rebuild. A LifetimeRegion lets it free each generation once it is no longer needed.

rebuild.ts
import type { ProgramLifetime } from "@scriptc/types";
import { LifetimeRegion } from "@scriptc/experimental";

class SyntaxNode implements ProgramLifetime {
  readonly children: SyntaxNode[] = [];
  parent: SyntaxNode | undefined = undefined;
}

function parse(depth: number): SyntaxNode {
  const node = new SyntaxNode();
  for (let i = 0; depth > 0 && i < 4; i++) {
    const child = parse(depth - 1);
    child.parent = node;
    node.children.push(child);
  }
  return node;
}

function count(node: SyntaxNode): number {
  return node.children.reduce((sum, child) => sum + count(child), 1);
}

let previous: LifetimeRegion | undefined;
for (let build = 1; build <= 3; build++) {
  const region = new LifetimeRegion();
  const tree = region.run(() => parse(8));
  console.log(`build ${build}: ${count(tree)} nodes`);
  // The previous build's tree is no longer referenced.
  previous?.release();
  previous = region;
}

Under Node.js, run calls its callback and release does nothing:

$ node rebuild.ts
build 1: 87381 nodes
build 2: 87381 nodes
build 3: 87381 nodes

The scriptc executable prints the same. With SCRIPTC_REGION_STATS=1 it also reports each region it frees:

$ scriptc build rebuild.ts -o rebuild >/dev/null
$ SCRIPTC_REGION_STATS=1 ./rebuild
build 1: 87381 nodes
build 2: 87381 nodes
scriptc region 1: freed 87381 objects, 87381 owned objects, 2 chunks (4.0 MiB); cycles 0.0 ms, walk 14.9 ms, free 2.6 ms
build 3: 87381 nodes
scriptc region 2: freed 87381 objects, 87381 owned objects, 2 chunks (4.0 MiB); cycles 0.0 ms, walk 8.9 ms, free 2.4 ms
scriptc regions: 3 created, 2 released, 2 freed, 0 pinned, 0 kept
scriptc region heap passes: 0

API

new LifetimeRegion()
Creates a region. Creating a region also retries the released regions that could not be freed yet.
region.run(fn)
Calls fn with the region as the current region and returns its result. ProgramLifetime instances created during the call belong to the region, and so do those that async functions called during it create after an await (see Async work). Calls nest: a run inside the callback makes another region current until it returns, and the previous region is current again however the callback finishes, including when it throws.
region.release()
Requests that the region be freed. scriptc frees it as soon as nothing outside the region refers to any of its instances, which may be at once, at a later release or region creation, or never. A region is never freed while a run call on it is active, or while async work started inside it has not finished. After release(), run still calls its callback, and the instances it creates belong to the default region.

ProgramLifetime instances created outside every run call belong to the default region, which is never freed. Ordinary objects (instances of classes without the contract, arrays, maps, closures) are reference counted as always; a region frees the ones that only its instances refer to.

When a region is freed

When a region is released, scriptc checks that every reference to its instances comes from the region itself: from its own instances, or from ordinary objects that only the region refers to. It then frees the instances and those ordinary objects together, and removes the WeakMap and WeakSet entries keyed by the freed instances.

A released region stays allocated, and its instances stay usable, while anything else refers to one of them:

  • a variable, a module variable, or a local of a function that is still running;
  • an ordinary object that something outside the region refers to, including an array, Map or Set element and a closure's captured variable;
  • an instance of another region, until that region is freed;
  • an instance of the default region, which is never freed;
  • a promise, generator or Error that holds an instance, until it is no longer referenced.

Async work

An async function called inside run keeps the region for its whole run: the instances it creates after each await belong to the region too, as do those of the async functions it calls. A server can give every request its own region, even while requests interleave at their awaits:

requests.ts
import type { ProgramLifetime } from "@scriptc/types";
import { LifetimeRegion } from "@scriptc/experimental";

class Part implements ProgramLifetime {
  readonly text: string;
  previous: Part | undefined = undefined;
  constructor(text: string) {
    this.text = text;
  }
}

const read = (): Promise<void> => new Promise((resolve) => setImmediate(resolve));

async function handle(id: number): Promise<string> {
  let last: Part | undefined;
  for (let i = 0; i < 1000; i++) {
    if (i % 100 === 0) await read(); // the next chunk of the request body
    const part = new Part(`${id}.${i}`);
    part.previous = last;
    last = part;
  }
  return `request ${id}: last part ${last!.text}`;
}

async function serve(id: number): Promise<string> {
  const region = new LifetimeRegion();
  try {
    return await region.run(() => handle(id));
  } finally {
    region.release();
  }
}

Promise.all([1, 2, 3].map(serve)).then((responses) => {
  for (const response of responses) console.log(response);
});

Each request's 1000 parts are freed when the request releases its region:

$ scriptc build requests.ts -o requests >/dev/null
$ SCRIPTC_REGION_STATS=1 ./requests >/dev/null
scriptc region 1: freed 1000 objects, 0 owned objects, 1 chunks (2.0 MiB); cycles 0.0 ms, walk 0.0 ms, free 0.0 ms
scriptc region 2: freed 1000 objects, 0 owned objects, 1 chunks (2.0 MiB); cycles 0.0 ms, walk 0.0 ms, free 0.0 ms
scriptc region 3: freed 1000 objects, 0 owned objects, 1 chunks (2.0 MiB); cycles 0.0 ms, walk 0.0 ms, free 0.0 ms
scriptc regions: 3 created, 3 released, 3 freed, 0 pinned, 0 kept
scriptc region heap passes: 0

A region released while async work started inside it is still pending stays allocated until that work has finished, and the work keeps creating its instances in the region. Callbacks that the event loop calls (timers, queueMicrotask, event listeners) and the bodies of async generators run outside the region; synchronous generators run in the region of the code that resumes them.

Threads

Each thread has its own regions and its own default region. A region belongs to the thread that created it: on another thread, run makes that thread's default region current and release does nothing. A region that holds an instance published with @scriptc/threads is never freed, because other threads may refer to it.

Cost

Programs that never construct a LifetimeRegion compile exactly as they would without the package. In programs that do, every retain and release of a ProgramLifetime instance that belongs to a region updates a count of the region chunk that holds it. Instances of the default region are not counted, and until the program creates its first region, a retain or release only tests whether counting has started.

Freeing a region visits each of its instances and the ordinary objects they own. Each thread keeps up to 8 MiB of the memory of freed regions for its next regions, so a program that frees a region per request or per batch does not map and fault in new pages every time. A region that the check would keep may be kept only by unreachable cycles of ordinary objects that refer into it, so before keeping it scriptc runs the cycle collector over the thread's heap and checks again.

SCRIPTC_REGION_STATS=1 prints one line per release attempt, freed, kept (with the number of references the check could not explain) or pinned (published), and at exit a summary per thread and the number of those cycle-collector passes over the thread's heap.

InternTable

Programs that run the same work on several worker threads often build the same cache on every thread: interned types, strings, resolved modules. An InternTable is one named table of published values that every thread of a scriptc program shares, so each value is created once and every thread reads the same object.

intern.ts
import { Worker, isMainThread, parentPort } from "node:worker_threads";
import { InternTable } from "@scriptc/experimental";

class Shape {
  readonly name: string;
  readonly parts: readonly Shape[];
  constructor(name: string, parts: readonly Shape[]) {
    this.name = name;
    this.parts = parts;
  }
}

// Module scope: every thread that evaluates this line opens the same table.
const shapes = new InternTable<string, Shape>("example.shapes");

function shape(name: string, parts: readonly Shape[] = []): Shape {
  return shapes.getOrInsertComputed(name, () => new Shape(name, parts));
}

function build(): string {
  const point = shape("point");
  const line = shape("line", [point, point]);
  const square = shape("square", [line, line, line, line]);
  return `${square.name}: ${square.parts.length} lines, ${shapes.size} shapes`;
}

if (isMainThread) {
  for (let i = 0; i < 2; i++) {
    new Worker(new URL(import.meta.url)).on("message", (text: string) => console.log(text));
  }
} else {
  parentPort!.postMessage(`worker: ${build()}`);
}

Each thread that evaluates new InternTable(...) opens the same table. Under Node.js every thread has its own table, so each worker builds its own three shapes; in the scriptc executable both workers use one table and read the same shape objects. Both print the same:

$ node intern.ts
worker: square: 4 lines, 3 shapes
worker: square: 4 lines, 3 shapes
$ scriptc build intern.ts -o intern >/dev/null
$ ./intern
worker: square: 4 lines, 3 shapes
worker: square: 4 lines, 3 shapes

Methods

new InternTable<K, V>(name)
Opens the table called name. Within one thread every construction with the same name refers to the same table. In scriptc builds every thread of the process shares it; under Node.js each thread has its own. Keys are strings, numbers, or arrays of numbers or of strings. Strings and numbers compare like Map keys; arrays compare by their elements in the same way and can have at most 65536 elements.
table.get(key), table.has(key), table.size
Read the table.
table.getOrInsert(key, value)
Returns the value stored under key. When there is none, publishes value, stores it and returns it.
table.getOrInsertComputed(key, callback)
Returns the value stored under key. When there is none, calls callback(key), publishes its result and stores it, unless a value was stored under key meanwhile (by the callback, or in scriptc builds by another thread), and returns the stored value.

A key's value never changes once stored, and there is no set or delete. Stored values and everything they reach are immutable in both runtimes. A value that publish refuses makes the call throw the same TypeError and stores nothing.

scriptc requires the name to be a string literal and every construction of one name to use the same type arguments, since all threads share the table's layout. Values are numbers, booleans, strings, bigints, arrays, Maps, Sets, objects and class instances.

Thread behavior

No lock is held while a callback runs. Two threads that miss the same key at the same time may both call the callback; one result is stored, and both calls return it. A program behaves the same under scriptc and Node.js when:

  1. A stored value does not depend on which thread created it, for example because the callback computes it from the key and other stored values.
  2. It does not compare identities or ids across threads.
  3. A thread does not depend on another thread's inserts: size and has reflect every thread's inserts in scriptc and the calling thread's under Node.js.

State that a thread computes about a shared value later (lazily resolved members, caches) belongs in thread-local state, such as a ThreadSlots family, a module-level Map keyed by the value or an array indexed by an id the value carries: module state is per thread in both runtimes. In scriptc an array indexed by a dense id is the fastest of these.

Memory

Lookups take no lock and write nothing. Inserts lock one of 64 segments of the table for a probe and a few stores. Stored values are immortal in scriptc builds: they stay allocated until the process exits, including a value a callback computed for a key that another thread stored first. Programs that never construct an InternTable do not run any of its code.

ThreadSlots

Threads that share values through an InternTable often compute more about each shared value later: resolved members, cached results, flags. That state differs per thread, so it cannot live in the shared value, which is immutable. A ThreadSlots family holds one value per key and thread, like a WeakMap that each thread has its own copy of. A thread sees only the values it set, including for keys that every thread shares.

slots.ts
import { Worker, isMainThread, parentPort, threadId } from "node:worker_threads";
import { InternTable, ThreadSlots } from "@scriptc/experimental";

class Shape {
  readonly name: string;
  constructor(name: string) {
    this.name = name;
  }
}

class ShapeState {
  uses = 0;
  readonly thread: number;
  constructor(thread: number) {
    this.thread = thread;
  }
}

const shapes = new InternTable<string, Shape>("example.slot-shapes");
// Module scope: every thread that evaluates this line opens the same family.
const states = new ThreadSlots<Shape, ShapeState>("example.shape-states");

function use(name: string): ShapeState {
  const shape = shapes.getOrInsertComputed(name, () => new Shape(name));
  let state = states.get(shape);
  if (state === undefined) {
    state = new ShapeState(threadId);
    states.set(shape, state);
  }
  state.uses++;
  return state;
}

if (isMainThread) {
  for (let i = 0; i < 2; i++) {
    new Worker(new URL(import.meta.url)).on("message", (text: string) => console.log(text));
  }
} else {
  use("square");
  const state = use("square");
  parentPort!.postMessage(`worker: ${state.uses} uses, own state ${state.thread === threadId}`);
}

In the scriptc executable both workers read the same Shape object from the table, and each keeps its own ShapeState for it. Both runtimes print the same:

$ node slots.ts
worker: 2 uses, own state true
worker: 2 uses, own state true
$ scriptc build slots.ts -o slots >/dev/null
$ ./slots
worker: 2 uses, own state true
worker: 2 uses, own state true

Slot methods

new ThreadSlots<K, V>(name)
Opens the family called name. Within one thread every construction with the same name refers to the same family.
slots.get(key), slots.has(key)
Read the calling thread's value for key.
slots.set(key, value)
Sets the calling thread's value for key and returns the slots. It never writes the key, so it works on published keys.
slots.delete(key)
Removes the calling thread's value for key and returns whether there was one.

Under Node.js each family is a WeakMap per thread.

Keys and values

scriptc requires:

  • the name to be a string literal, and every construction of one name to use the same type arguments;
  • the key type to be a class declared in the program; keys may be instances of its subclasses;
  • the value type to be a class, an object type, an array, a Map or a Set;
  • the methods to be called through a module-level const initialized with new ThreadSlots<K, V>("name"), as in the example. A family's storage is found by its name when the program is compiled.

Slot cost

Each instance of a key class, and of every class in its hierarchy, carries a 4-byte slot index, 0 until a value is set for it. The first set for a key assigns the index; indices are never reused. get and has read the index, a page of the thread's family and the entry, with no lock, hash or atomic update. A family stores values in pages of 1024 entries, allocated as the thread sets values in them.

A family holds a reference to each of the thread's values. A value is released when it is replaced or deleted, when its key is freed, and when the thread exits. Keys that are freed with a LifetimeRegion keep their values until the thread exits, a value that belongs to a region keeps the region allocated while the family holds it, and a value that refers to its own key keeps the key alive. Programs that never construct a ThreadSlots do not run any of its code, and their classes have no slot index.

InlineArray

InlineArray<T> marks an array field of a ProgramLifetime class whose elements scriptc keeps in the instance itself, after its other fields, instead of in an array object of their own. It suits a program with millions of short arrays that never change once built, such as the children of each node of a syntax tree: an ordinary array costs a separate allocation with a header, and a reference to it in the instance.

lists.ts
import type { ProgramLifetime } from "@scriptc/types";
import type { InlineArray } from "@scriptc/experimental";

class Token implements ProgramLifetime {
  readonly text: string;
  constructor(text: string) {
    this.text = text;
  }
}

class TokenList implements ProgramLifetime {
  readonly pos: number;
  readonly tokens: InlineArray<Token>;
  constructor(pos: number, tokens: readonly Token[]) {
    this.pos = pos;
    this.tokens = tokens;
  }
}

function width(list: TokenList): number {
  let total = 0;
  for (const token of list.tokens) total += token.text.length;
  return total;
}

const list = new TokenList(0, ["let", "x", "=", "1"].map((text) => new Token(text)));
console.log(list.tokens.length, width(list), list.tokens[0]!.text);
console.log(list.tokens.map((token) => token.text).join(" "));

Both runtimes print the same:

$ node lists.ts
4 6 let
let x = 1
$ scriptc build lists.ts -o lists >/dev/null
$ ./lists
4 6 let
let x = 1

Field rules

scriptc requires:

  • T to be a ProgramLifetime class, and the field to be an instance field of a ProgramLifetime class;
  • the constructor of the class that declares the field to write it once, from one of its parameters. A subclass constructor passes one of its own parameters there through super(...). Nothing else writes the field;
  • the array passed to the constructor to be one that nothing else keeps: a new array (an array literal, a copy such as slice(), [...items] or Array.from(items), or the result of a function that returns only such arrays or its own argument), a local array at its last use (calls that only read it or add to it may come first, and a loop may pass it and then assign a new array to the local), a parameter whose callers each pass such an array, an array that an InlineArray field already holds, or a const array that is never written, such as a shared empty array;
  • the arrays fields hold never to be written, through a read of the field (which the readonly type allows only through a cast) or through any other reference. scriptc follows these arrays through locals, parameters, return values, fields, the elements of arrays, Maps and Sets, and closures, and reports one that reaches a write, a dynamic value (also inside the fields of an object that becomes one), a call through a function value (also inside an array, Map or Set passed there), or a library call other than printing and JSON.stringify without a replacer. The report names the field read the array may come from;
  • the arrays fields hold, and their reads, not to be compared by identity: with === or !==, as an indexOf or includes argument, or as a Map key or Set element. Two reads of the field need not give the same array. Compare the elements instead, or keep a copy made with slice() where identity matters;
  • instances of the class not to be constructed through a class value. They may become dynamic values, for example through a conversion to unknown or an interface several classes implement, when the dynamic code that sees them uses the class's own members and does not read the field: scriptc follows such values through locals, parameters, return values, fields, the elements of arrays, Maps and Sets, the members of dynamic objects and arrays, calls through function values, and the callbacks of array methods such as map and forEach, and finds the first that reaches a read or write of the field's name or of a computed key, a checked cast to a record, array or collection type that has a record member of the field's name or holds a dynamic value, a call through a value no compiled function reaches (also as an array method's callback), or a library call other than printing, JSON.stringify without a replacer, publish, a worker's workerData, and class tests and casts. A dynamic view of an inline field would read it as a new array each time, so when such an operation exists the field keeps its elements in an ordinary array, which behaves as under Node.js, and scriptc reports a warning (SC6105). The report names the operation.

scriptc reports a field that does not keep these rules (SC7009).

Reads

Indexing the field, reading its length and iterating it with for...of read the elements from the instance, directly or through a const local used only in those ways. Any other use, such as passing the field to a function or calling one of its methods, gets an array of the elements, which is frozen in scriptc builds. Under Node.js the field holds the array the constructor stored. The two agree because the field rules keep that array from being written.

Inline array cost

Each element takes 4 bytes in the instance, and the field's own slot holds the count. The constructor copies the elements from the array it is passed, so that array can be released. The elements start at the same offset in every class of the hierarchy, after the largest set of fixed fields. A read that gets an array allocates a small header that refers to the instance's elements, and frees it when the read's value is released.

The elements stay in an ordinary array object, and the field behaves like any other array field, in builds with sanitizers, in programs that use LifetimeRegion, and on platforms other than 64-bit Linux.