From 3fcef83d38c292fa29c45f8f2e699c2b701c706a Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 17 Oct 2026 11:26:12 -0700 Subject: [PATCH 1/2] Carry lifetime regions across await and reuse freed region chunks An async function called inside LifetimeRegion.run now keeps its region after every await: its fiber records the region that was current at spawn, and the event loop makes that region current while it resumes the fiber. A region is not freed (or attempted) while a fiber that carries it exists, so a region released while its async work is pending keeps taking that work's objects and is freed once the work ends. Before, objects created after an await went to the default region, which is never freed, and kept the request's region allocated for good, so per-request regions in async servers grew without bound. Freed region chunks now go to a per-thread cache (at most 8 MiB resident, the first 256 KiB of each kept cleared, fresh pages past it, no huge-page chunks) instead of being unmapped, so a region per request or per batch no longer maps and faults in new memory every time. Adds the requests.ts region benchmark (concurrent async handlers), corpus 5023 and its region-stats checks, and a chunk-reuse runtime unit check. Co-Authored-By: Claude Opus 5.5 --- benchmarks/regions/README.md | 1 + benchmarks/regions/emit.ts | 2 +- benchmarks/regions/requests.ts | 221 ++++++++++++++++++ benchmarks/regions/run.mjs | 4 +- docs/content/docs/experimental.mdx | 63 ++++- docs/test/fixtures/migration-baseline.json | 4 + .../corpus/5023-lifetime-region-await.ts.json | 6 + packages/experimental/README.md | 2 +- packages/experimental/index.d.ts | 11 +- packages/runtime/src/scr_async.c | 20 ++ packages/runtime/src/scr_region.c | 158 ++++++++++++- packages/runtime/src/scr_region.h | 21 ++ packages/runtime/src/scr_region.test.c | 25 ++ packages/runtime/src/scr_region.test.ts | 11 +- tests/corpus/5013-lifetime-region-async.ts | 9 +- tests/corpus/5023-lifetime-region-await.ts | 200 ++++++++++++++++ tests/harness/lifetime-regions.test.ts | 23 +- 17 files changed, 746 insertions(+), 35 deletions(-) create mode 100644 benchmarks/regions/requests.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5023-lifetime-region-await.ts.json create mode 100644 tests/corpus/5023-lifetime-region-await.ts diff --git a/benchmarks/regions/README.md b/benchmarks/regions/README.md index 457f059bea..0cc450abc1 100644 --- a/benchmarks/regions/README.md +++ b/benchmarks/regions/README.md @@ -7,6 +7,7 @@ Programs that build generations of `ProgramLifetime` objects and free each gener | `generations.ts` | Per-file syntax trees and binder symbols in one region per file, checker state (types, signatures, node and symbol links in paged tables, literal and union caches) in one region per generation. `edit` re-parses one file per generation, `all` re-parses every file. | | `compiler.ts` | A tokenizer, recursive-descent parser, binder and checker over generated sources, rebuilt from scratch in one region per build, like a watch mode that does not reuse syntax trees. | | `emit.ts` | A large live graph of ordinary objects with parent links, standing in for a type checker, and a per-file emit that builds its output in a region released right after. | +| `requests.ts` | Batches of concurrent async request handlers, each in its own region across its awaits: tokenize a JSON body, parse it into a syntax tree that points at its tokens, validate it into issues and an index, render a response, release the region. | ```console $ node benchmarks/regions/run.mjs --baseline=../scriptc-main --runs=7 diff --git a/benchmarks/regions/emit.ts b/benchmarks/regions/emit.ts index 630708361e..9fd15b8605 100644 --- a/benchmarks/regions/emit.ts +++ b/benchmarks/regions/emit.ts @@ -54,7 +54,7 @@ let level: Type[] = [new Type(id++, undefined)]; while (id < typeCount) { const next: Type[] = []; for (const t of level) for (let k = 0; k < 4 && id < typeCount; k++) next.push(new Type(id++, t)); - roots.push(...next); + for (const t of next) roots.push(t); level = next; } diff --git a/benchmarks/regions/requests.ts b/benchmarks/regions/requests.ts new file mode 100644 index 0000000000..6148eb25df --- /dev/null +++ b/benchmarks/regions/requests.ts @@ -0,0 +1,221 @@ +// An async request handler: each request tokenizes a JSON body, awaits (as +// if reading more of it), parses the tokens into a syntax tree whose nodes +// point back at their tokens, awaits again (as if loading reference data), +// validates the tree into issues held in an index, and renders a response. +// Requests run in batches of concurrent handlers, so the handlers of one +// batch interleave at every await. +// +// requests [plain] +// +// Each request runs in its own LifetimeRegion, released when its response +// is rendered; `plain` never constructs a region. +import { readFileSync } from "node:fs"; +import type { ProgramLifetime } from "@scriptc/types"; +import { LifetimeRegion } from "@scriptc/experimental"; + +const T_PUNCT = 0; +const T_STR = 1; +const T_NUM = 2; +const T_WORD = 3; + +class Token implements ProgramLifetime { + readonly kind: number; + readonly text: string; + readonly pos: number; + constructor(kind: number, text: string, pos: number) { + this.kind = kind; + this.text = text; + this.pos = pos; + } +} + +const N_OBJECT = 0; +const N_ARRAY = 1; +const N_STRING = 2; +const N_NUMBER = 3; +const N_LITERAL = 4; + +class JsonNode implements ProgramLifetime { + readonly kind: number; + readonly token: Token; + parent: JsonNode | undefined = undefined; + readonly keys: string[] = []; + readonly children: JsonNode[] = []; + constructor(kind: number, token: Token) { + this.kind = kind; + this.token = token; + } +} + +class Issue implements ProgramLifetime { + readonly path: string; + readonly node: JsonNode; + readonly message: string; + constructor(path: string, node: JsonNode, message: string) { + this.path = path; + this.node = node; + this.message = message; + } +} + +// ---- request bodies (deterministic) ---- + +function body(id: number, items: number): string { + let seed = 7 + id * 7919; + const rand = (n: number): number => { + seed = (seed * 1103515245 + 12345) % 2147483648; + return seed % n; + }; + const parts: string[] = []; + for (let i = 0; i < items; i++) { + const qty = rand(12) - 1; + const sku = rand(9) === 0 ? "" : `sku-${rand(5000)}`; + const tags = Array.from({ length: rand(4) }, (_, k) => `"t${k}${rand(10)}"`).join(","); + parts.push(`{"sku":"${sku}","qty":${qty},"price":${rand(10000) / 100},"gift":${rand(2) === 0},"tags":[${tags}]}`); + } + return `{"order":${id},"customer":"c${id % 97}","items":[${parts.join(",")}]}`; +} + +// ---- handler ---- + +function tokenize(text: string): Token[] { + const tokens: Token[] = []; + let i = 0; + while (i < text.length) { + const c = text.charCodeAt(i); + const start = i; + if (c === 34) { + i++; + while (text.charCodeAt(i) !== 34) i++; + i++; + tokens.push(new Token(T_STR, text.slice(start + 1, i - 1), start)); + } else if (c === 45 || (c >= 48 && c <= 57)) { + i++; + while (i < text.length && (text.charCodeAt(i) === 46 || (text.charCodeAt(i) >= 48 && text.charCodeAt(i) <= 57))) i++; + tokens.push(new Token(T_NUM, text.slice(start, i), start)); + } else if (c >= 97 && c <= 122) { + while (i < text.length && text.charCodeAt(i) >= 97 && text.charCodeAt(i) <= 122) i++; + tokens.push(new Token(T_WORD, text.slice(start, i), start)); + } else { + i++; + tokens.push(new Token(T_PUNCT, text[start]!, start)); + } + } + return tokens; +} + +class Parser { + private at = 0; + private readonly tokens: Token[]; + constructor(tokens: Token[]) { + this.tokens = tokens; + } + value(): JsonNode { + const token = this.tokens[this.at++]!; + if (token.kind === T_STR) return new JsonNode(N_STRING, token); + if (token.kind === T_NUM) return new JsonNode(N_NUMBER, token); + if (token.kind === T_WORD) return new JsonNode(N_LITERAL, token); + const node = new JsonNode(token.text === "{" ? N_OBJECT : N_ARRAY, token); + const close = token.text === "{" ? "}" : "]"; + while (this.tokens[this.at]!.text !== close) { + if (node.kind === N_OBJECT) { + node.keys.push(this.tokens[this.at]!.text); + this.at += 2; // the key and the colon + } + const child = this.value(); + child.parent = node; + node.children.push(child); + if (this.tokens[this.at]!.text === ",") this.at++; + } + this.at++; + return node; + } +} + +function member(node: JsonNode, key: string): JsonNode | undefined { + const i = node.keys.indexOf(key); + return i < 0 ? undefined : node.children[i]; +} + +function validate(root: JsonNode, issues: Issue[], index: Map): number { + let total = 0; + const items = member(root, "items")!; + for (let i = 0; i < items.children.length; i++) { + const item = items.children[i]!; + const path = `items[${i}]`; + const sku = member(item, "sku")!; + if (sku.token.text === "") issues.push(new Issue(`${path}.sku`, sku, "empty sku")); + else if (index.has(sku.token.text)) issues.push(new Issue(`${path}.sku`, sku, `duplicate of ${index.get(sku.token.text)!.token.pos}`)); + else index.set(sku.token.text, item); + const qty = Number(member(item, "qty")!.token.text); + if (qty <= 0) issues.push(new Issue(`${path}.qty`, member(item, "qty")!, "quantity must be positive")); + else total += qty * Number(member(item, "price")!.token.text); + const tags = member(item, "tags")!; + for (const tag of tags.children) if (tag.token.text.endsWith("0")) issues.push(new Issue(`${path}.tags`, tag, "reserved tag")); + } + return total; +} + +const hop = (): Promise => new Promise((resolve) => setImmediate(resolve)); + +async function handle(text: string): Promise { + const tokens = tokenize(text); + await hop(); + const root = new Parser(tokens).value(); + await hop(); + const issues: Issue[] = []; + const index = new Map(); + const total = validate(root, issues, index); + await hop(); + const first = issues[0]; + return `${member(root, "order")!.token.text} ${tokens.length} ${index.size} ${issues.length} ${total.toFixed(2)}` + + (first === undefined ? "" : ` ${first.path}@${first.node.token.pos}`); +} + +// ---- driver ---- + +function rssMb(key: string): number { + const m = new RegExp(`${key}:\\s*(\\d+) kB`).exec(readFileSync("/proc/self/status", "utf8")); + return m === null ? 0 : Number(m[1]) / 1024; +} + +const args = process.argv.slice(2); +const requests = Number(args[0] ?? "4000"); +const concurrency = Number(args[1] ?? "16"); +const items = Number(args[2] ?? "100"); +const useRegions = args[3] !== "plain"; + +async function serve(id: number): Promise { + const text = body(id, items); + if (!useRegions) return await handle(text); + const region = new LifetimeRegion(); + const response = await region.run(() => handle(text)); + region.release(); + return response; +} + +async function main(): Promise { + let hash = 0; + let lines = 0; + let sample = ""; + let rssFirst = 0; + const t0 = performance.now(); + for (let next = 0; next < requests; next += concurrency) { + const batch: Promise[] = []; + for (let k = next; k < next + concurrency && k < requests; k++) batch.push(serve(k)); + for (const response of await Promise.all(batch)) { + for (let i = 0; i < response.length; i++) hash = (hash * 31 + response.charCodeAt(i)) % 1000000007; + lines++; + if (lines === 1) sample = response; + } + if (next === 0) rssFirst = rssMb("VmRSS"); + } + const ms = performance.now() - t0; + console.log(`${lines} responses, hash ${hash}, first ${sample}`); + console.log( + `total ${ms.toFixed(1)} ms per-request ${((ms * 1000) / requests).toFixed(1)} us` + + ` rss-first ${rssFirst.toFixed(1)} MB rss-last ${rssMb("VmRSS").toFixed(1)} MB peak ${rssMb("VmHWM").toFixed(1)} MB`, + ); +} + +main(); diff --git a/benchmarks/regions/run.mjs b/benchmarks/regions/run.mjs index 1f1c4609f4..e698dc47d4 100644 --- a/benchmarks/regions/run.mjs +++ b/benchmarks/regions/run.mjs @@ -39,6 +39,8 @@ const CONFIGS = [ { name: "compiler-one-shot", program: "compiler.ts", args: ["400", "60", "1"] }, // Per-file emit regions next to a large live heap of ordinary objects. { name: "emit-files", program: "emit.ts", args: ["400000", "200", "2000"] }, + // Concurrent async request handlers, one region per request across its awaits. + { name: "requests-async", program: "requests.ts", args: ["4000", "16", "100"] }, ]; const work = join(tmpdir(), `scriptc-region-bench-${process.pid}`); @@ -93,7 +95,7 @@ for (const config of CONFIGS) { const lines = r.stdout.trim().split("\n"); outputs.set(name, lines[0].replace(/ gen0 .*/, "")); const metrics = {}; - for (const [, key, value] of lines.at(-1).matchAll(/([a-z0-9-]+) ([0-9.]+) (?:ms|MB)/g)) + for (const [, key, value] of lines.at(-1).matchAll(/([a-z0-9-]+) ([0-9.]+) (?:ms|us|MB)/g)) metrics[key] = Number(value); samples[name].push(metrics); } diff --git a/docs/content/docs/experimental.mdx b/docs/content/docs/experimental.mdx index ac5891ec97..6a0fb53efe 100644 --- a/docs/content/docs/experimental.mdx +++ b/docs/content/docs/experimental.mdx @@ -73,9 +73,9 @@ scriptc region heap passes: 0
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. 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.
+
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. After release(), run still calls its callback, and the instances it creates belong to the default region.
+
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. @@ -92,7 +92,62 @@ A released region stays allocated, and its instances stay usable, while anything - 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. -Work that resumes after an `await` inside the callback runs outside the region, so the instances it creates belong to the default region and keep the instances they refer to allocated. +### 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: + +```ts: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 => new Promise((resolve) => setImmediate(resolve)); + +async function handle(id: number): Promise { + 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 { + 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: + +```console +$ 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 @@ -102,7 +157,7 @@ Each thread has its own regions and its own default region. A region belongs to 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. 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. +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. diff --git a/docs/test/fixtures/migration-baseline.json b/docs/test/fixtures/migration-baseline.json index 6aaab5f70c..dd4320e03a 100644 --- a/docs/test/fixtures/migration-baseline.json +++ b/docs/test/fixtures/migration-baseline.json @@ -273,6 +273,10 @@ "text": "When a region is freed", "id": "when-a-region-is-freed" }, + { + "text": "Async work", + "id": "async-work" + }, { "text": "Threads", "id": "threads" diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5023-lifetime-region-await.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5023-lifetime-region-await.ts.json new file mode 100644 index 0000000000..9e7d481300 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5023-lifetime-region-await.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5023-lifetime-region-await.ts" + ], + "diags": [] +} diff --git a/packages/experimental/README.md b/packages/experimental/README.md index 413deeff24..8a44053b0b 100644 --- a/packages/experimental/README.md +++ b/packages/experimental/README.md @@ -21,7 +21,7 @@ function rebuild(): Program { - `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; calls nest. - Instances created outside every `run` call belong to the default region, which is never freed. -- Work that resumes after an `await` inside `fn` runs outside the region. +- Async work started inside `fn` keeps its region: what it creates after an `await` belongs to the region too, and the region is not freed before that work has finished. - `release()` requests that the region be freed. scriptc frees it once nothing outside the region refers to any of its instances; until then the region stays allocated. Later `run` calls on a released region allocate in the default region. - A region that holds a published object (`@scriptc/threads`) is never freed. diff --git a/packages/experimental/index.d.ts b/packages/experimental/index.d.ts index d831cbff96..057dcde09f 100644 --- a/packages/experimental/index.d.ts +++ b/packages/experimental/index.d.ts @@ -7,13 +7,14 @@ export declare class LifetimeRegion { constructor(); /** Calls `fn` with this region as the current region and returns its * result. ProgramLifetime instances created during the call, outside - * nested `run` calls of other regions, belong to this region. Work that - * resumes after an `await` inside `fn` runs outside the region. */ + * nested `run` calls of other regions, belong to this region, and so do + * those that async functions called during it create after an `await`. */ run(fn: () => T): T; /** Requests that the region be freed. Its instances are freed once - * nothing outside the region refers to any of them; until then the - * region stays allocated. Later `run` calls allocate outside the region. - * Never observable except through memory use. */ + * nothing outside the region refers to any of them and the async work + * started inside it has finished; until then the region stays allocated. + * Later `run` calls allocate outside the region. Never observable except + * through memory use. */ release(): void; } diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index 9bf42bb07e..d8ff981543 100644 --- a/packages/runtime/src/scr_async.c +++ b/packages/runtime/src/scr_async.c @@ -29,6 +29,7 @@ #include #include +#include #include #include #include @@ -594,6 +595,11 @@ struct ScrFiber { * SPAWNER at spawn (Node's init-time capture), swapped active with the * fiber (the exc-cell pattern) so run()'s window rides awaits. */ ScrAlsCtx *als; + /* The lifetime region the body was spawned in (scr_region_fiber_hooks; + * NULL = the default region): the loop makes it current again whenever + * it resumes the fiber. Async bodies only; generators run in their + * resumer's region. */ + void *region; bool done; /* A native synchronous generator compiled as an LLVM coroutine (see coro * above): no stack or saved context; resumes call into the frame on the @@ -676,6 +682,12 @@ void scr_als_ctx_release(ScrAlsCtx *c) { free(c); } +/* The lifetime region a new async fiber carries (ScrFiber.region). */ +static inline void *scr_fiber_region_capture(void) { + const ScrRgnFiberHooks *regions = atomic_load_explicit(&scr_region_fiber_hooks, memory_order_relaxed); + return regions != NULL ? regions->capture() : NULL; +} + static SCR_TL ScrCtx scr_loop_ctx; /* the main stack (scheduler home) */ static SCR_TL ScrFiber *scr_current = NULL; /* A finished fiber whose last switch went to the loop (scr_resume_fiber). */ @@ -1883,6 +1895,7 @@ static void scr_fiber_destroy(ScrFiber *f) { #endif #endif scr_als_ctx_release(f->als); + if (f->region != NULL) atomic_load_explicit(&scr_region_fiber_hooks, memory_order_relaxed)->drop(f->region); #if !defined(_WIN32) && !defined(__wasi__) scr_fiber_stack_free(f->stack); #else @@ -1914,6 +1927,7 @@ static ScrPromise *scr_async_spawn_impl(void (*entry)(ScrFiber *, void *), void /* AsyncLocalStorage: the child runs in the SPAWNER's context (Node's * init-time capture); snapshots are immutable, so a retain suffices. */ f->als = scr_als_ctx_retain(*SCR_ALS_SLOT()); + f->region = scr_fiber_region_capture(); scr_fibers_live++; #ifdef _WIN32 @@ -2123,6 +2137,7 @@ ScrPromise *scr_async_spawn_after(ScrPromise *dependency, f->entry = entry; f->argpack = argpack; f->als = scr_als_ctx_retain(*SCR_ALS_SLOT()); + f->region = scr_fiber_region_capture(); scr_fibers_live++; if (dependency->state == SCR_PROM_PENDING) { @@ -3624,6 +3639,9 @@ static void scr_resume_fiber(ScrFiber *f) { (void)scr_win_self(); #endif f->return_to = &scr_loop_ctx; + /* The body continues in the lifetime region it was spawned in. */ + const ScrRgnFiberHooks *regions = atomic_load_explicit(&scr_region_fiber_hooks, memory_order_relaxed); + void *region_before = regions != NULL ? regions->resume(f->region) : NULL; #ifdef __wasi__ scr_current = f; scr_exc_swap_cell(&f->exc); @@ -3640,8 +3658,10 @@ static void scr_resume_fiber(ScrFiber *f) { scr_current = NULL; scr_exc_swap_cell(NULL); scr_als_active = &scr_als_main_slot; + if (regions != NULL) regions->suspend(region_before); #else scr_switch(&scr_loop_ctx, &f->ctx, f); + if (regions != NULL) regions->suspend(region_before); f = scr_loop_reap; if (f == NULL) return; scr_loop_reap = NULL; diff --git a/packages/runtime/src/scr_region.c b/packages/runtime/src/scr_region.c index c1a125e8bc..c8906ad0fb 100644 --- a/packages/runtime/src/scr_region.c +++ b/packages/runtime/src/scr_region.c @@ -70,6 +70,18 @@ void __lsan_unregister_root_region(const void *p, size_t size) __attribute__((we #define SCR_RGN_AHEAD_START ((size_t)1 << 20) /* see scr_rgn_ahead */ _Static_assert(sizeof(ScrRgnChunk) <= SCR_RGN_HDR, "the chunk header fits before the first segment"); +/* Freed chunks a thread keeps for its next regions: a program that frees + * a region per request or per batch would otherwise map, fault in and + * unmap its chunks every time. A cached chunk keeps at most its first + * SCR_RGN_CACHE_WARM bytes resident (cleared), and gets fresh pages past + * them, so a small region that reuses the chunk of a large one does not + * hold the large one's memory. The cache holds at most SCR_RGN_CACHE_BYTES + * of resident chunk memory (whole chunks where chunks are not mapped + * pages); chunks beyond it, and chunks huge pages may back, are unmapped + * as before. */ +#define SCR_RGN_CACHE_BYTES ((size_t)8 << 20) +#define SCR_RGN_CACHE_WARM ((size_t)256 << 10) + /* Regions start with small pages and switch to huge pages once they span * this many chunks (a default region at once): small regions then do not * pay a 2 MiB fault, large ones do not pay 512 small ones. */ @@ -99,6 +111,7 @@ typedef struct ScrRegion { uint64_t id; /* 0: the thread's default region */ int state; uint32_t depth; /* run() frames active on this region */ + size_t fibers; /* async fibers that carry it (scr_region_fiber_hooks) */ ScrRgnChunk *chunks; size_t nchunks; ScrRgnSeg *segs; @@ -210,7 +223,10 @@ typedef struct { size_t allocated; /* bytes of region segments this thread has allocated */ size_t checked; /* `allocated` at the last retry */ ScrRgnWalk walk; + ScrRgnChunk *cache; /* freed chunks, linked through `next` (SCR_RGN_CACHE_BYTES) */ + size_t cache_bytes; /* their resident bytes, as recorded in each one's `found` */ bool exit_registered; + bool exited; /* the exit cleanup ran: the fiber hooks do nothing */ double cycle_ms; /* the cycle pass before the next attempt (stats) */ struct { size_t created, released, freed, pinned, leaked, attempts; @@ -329,18 +345,39 @@ static bool scr_rgn_huge(const ScrRegion *r) { return r->id == 0 || r->nchunks + /* A chunk for `r`'s next one, with its page policy. */ static ScrRgnChunk *scr_rgn_chunk_reserve(const ScrRegion *r) { ScrRgnChunk *c = scr_rgn_chunk_map(); + c->huge = false; +#if SCR_RGN_MMAP + c->touched = (char *)c + SCR_RGN_HDR; +#else + c->touched = (char *)c + SCR_RGN_CHUNK; /* allocated zeroed */ +#endif #if SCR_RGN_MMAP && defined(MADV_HUGEPAGE) - if (scr_rgn_huge(r)) (void)madvise(c, SCR_RGN_CHUNK, MADV_HUGEPAGE); + if (scr_rgn_huge(r)) { + (void)madvise(c, SCR_RGN_CHUNK, MADV_HUGEPAGE); + c->huge = true; + } #else (void)r; #endif return c; } +/* Faults [from, to) of chunk `c` in ahead of use. */ +static void scr_rgn_prefault(ScrRgnChunk *c, char *from, char *to) { + if (to > c->touched) c->touched = to; + scr_mem_prefault(from, to); +} + static ScrRgnChunk *scr_rgn_chunk_new(ScrRegion *r) { - ScrRgnChunk *c = r->spare ? r->spare : scr_rgn_chunk_reserve(r); + ScrRgnChunk *c = r->spare; + if (!c && scr_rgn.cache) { + c = scr_rgn.cache; + scr_rgn.cache = c->next; + scr_rgn.cache_bytes -= (size_t)c->found; + } + if (!c) c = scr_rgn_chunk_reserve(r); r->spare = NULL; - if (r->bytes >= SCR_RGN_AHEAD_START) scr_mem_prefault(c, (char *)c + 2 * SCR_RGN_AHEAD); + if (r->bytes >= SCR_RGN_AHEAD_START) scr_rgn_prefault(c, (char *)c, (char *)c + 2 * SCR_RGN_AHEAD); #ifdef SCR_LSAN_IMMORTAL if (__lsan_register_root_region) __lsan_register_root_region(c, SCR_RGN_CHUNK); #endif @@ -369,26 +406,74 @@ static void scr_rgn_ahead(ScrRegion *r, ScrRgnChunk *c, char *from) { size_t before = (size_t)(from - (char *)c), after = (size_t)(c->bump - (char *)c); if (after / SCR_RGN_AHEAD != before / SCR_RGN_AHEAD) { char *lo = (char *)c + (after / SCR_RGN_AHEAD + 1) * SCR_RGN_AHEAD; - if (lo < (char *)c + SCR_RGN_CHUNK) scr_mem_prefault(lo, lo + SCR_RGN_AHEAD); + if (lo < (char *)c + SCR_RGN_CHUNK) scr_rgn_prefault(c, lo, lo + SCR_RGN_AHEAD); } if (!r->spare && after >= SCR_RGN_CHUNK / 2 && before < SCR_RGN_CHUNK / 2) { bool huge = scr_rgn_huge(r); r->spare = scr_rgn_chunk_reserve(r); - scr_mem_prefault(r->spare, (char *)r->spare + (huge ? SCR_RGN_CHUNK : SCR_RGN_AHEAD)); + scr_rgn_prefault(r->spare, (char *)r->spare, (char *)r->spare + (huge ? SCR_RGN_CHUNK : SCR_RGN_AHEAD)); } } +/* Returns a chunk that holds no object to the system. */ +static void scr_rgn_chunk_drop(ScrRgnChunk *c) { +#if SCR_RGN_MMAP + munmap(c, SCR_RGN_CHUNK); +#elif defined(_WIN32) + _aligned_free(c); +#else + free(c); +#endif +} + /* A spare chunk never held objects: it goes straight back. */ static void scr_rgn_spare_drop(ScrRegion *r) { if (!r->spare) return; + scr_rgn_chunk_drop(r->spare); + r->spare = NULL; +} + +/* A freed region's chunk: kept for the thread's next regions while the + * cache has room, cleared (a fresh chunk is zero, and allocation relies on + * that), else unmapped (SCR_RGN_CACHE_BYTES). Sanitized builds never reuse + * a chunk (scr_rgn_chunk_unmap). */ +static void scr_rgn_chunk_retire(ScrRgnThread *t, ScrRgnChunk *c) { +#ifndef SCR_RGN_POISON + char *first = (char *)c + SCR_RGN_HDR; + if (c->bump > c->touched) c->touched = c->bump; #if SCR_RGN_MMAP - munmap(r->spare, SCR_RGN_CHUNK); -#elif defined(_WIN32) - _aligned_free(r->spare); + char *warm = (char *)c + SCR_RGN_CACHE_WARM; + size_t resident = (size_t)((c->touched < warm ? c->touched : warm) - (char *)c); #else - free(r->spare); + size_t resident = SCR_RGN_CHUNK; #endif - r->spare = NULL; + if (!c->huge && t->cache_bytes + resident <= SCR_RGN_CACHE_BYTES) { +#if SCR_RGN_MMAP + if (c->touched > warm) { + /* Fresh zero pages past the warm part (64 KiB steps cover every + * page size). */ + size_t tail = ((size_t)(c->touched - warm) + 0xffff) & ~(size_t)0xffff; + if (tail > SCR_RGN_CHUNK - SCR_RGN_CACHE_WARM) tail = SCR_RGN_CHUNK - SCR_RGN_CACHE_WARM; + if (mmap(warm, tail, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, -1, 0) == MAP_FAILED) { + scr_rgn_chunk_unmap(c); + return; + } + c->touched = warm; + } + memset(first, 0, (size_t)((c->bump < warm ? c->bump : warm) - first)); +#else + memset(first, 0, (size_t)(c->bump - first)); +#endif + c->found = (int64_t)resident; + c->next = t->cache; + t->cache = c; + t->cache_bytes += resident; + return; + } +#else + (void)t; +#endif + scr_rgn_chunk_unmap(c); } /* The instance stride: the emitted fast path rounds the size the same way. */ @@ -875,7 +960,7 @@ static void scr_rgn_free_region(ScrRgnThread *t, ScrRegion *r) { scr_rgn_spare_drop(r); for (ScrRgnChunk *c = r->chunks, *next; c; c = next) { next = c->next; - scr_rgn_chunk_unmap(c); + scr_rgn_chunk_retire(t, c); } for (ScrRgnSeg *s = r->segs, *next; s; s = next) { next = s->next; @@ -1118,7 +1203,7 @@ static bool scr_rgn_try_free(ScrRgnThread *t, ScrRegion *r, bool tentative) { /* A condemned region not in use, never attempted, or kept by an attempt * whose outcome may have changed since (see SCR_RGN_RETRY_BYTES). */ static bool scr_rgn_due(const ScrRegion *r, const ScrRgnThread *t) { - if (r->state != SCR_RGN_CONDEMNED || r->depth != 0 || r == t->current) return false; + if (r->state != SCR_RGN_CONDEMNED || r->depth != 0 || r->fibers != 0 || r == t->current) return false; return !r->kept || t->allocated >= r->retry_at || scr_rgn_counted(r) != r->kept_counted; } @@ -1162,11 +1247,55 @@ static void scr_rgn_retry(ScrRgnThread *t) { scr_rgn_try_due(t, false); } +/* ── async continuations ─────────────────────────────────────────────── */ + +/* A fiber spawned while a region is current carries it: the region + * cannot be freed until the fiber is gone, so work that resumes after an + * await can keep allocating in it, condemned or not (scr_rgn_due). The + * fiber's own references to the region's objects are counted like any + * others. Fibers of the default region carry nothing. */ +static void *scr_rgn_fiber_capture(void) { + ScrRgnThread *t = &scr_rgn; + ScrRegion *r = t->current; + if (t->exited || !r || r->id == 0) return NULL; + r->fibers++; + return r; +} + +static void scr_rgn_fiber_drop(void *region) { + if (region && !scr_rgn.exited) ((ScrRegion *)region)->fibers--; +} + +/* The event loop resumes a fiber: its region becomes current until the + * fiber suspends or finishes. Run frames never span a suspension, so the + * fiber leaves the region current that it found. */ +static void *scr_rgn_fiber_resume(void *region) { + ScrRgnThread *t = &scr_rgn; + if (t->exited || !t->dflt) return NULL; /* no region on this thread yet */ + ScrRegion *previous = t->current; + scr_rgn_switch(t, region ? (ScrRegion *)region : t->dflt); + return previous; +} + +static void scr_rgn_fiber_suspend(void *previous) { + if (previous && !scr_rgn.exited) scr_rgn_switch(&scr_rgn, previous); +} + +static const ScrRgnFiberHooks scr_rgn_fiber_hooks = { + scr_rgn_fiber_capture, + scr_rgn_fiber_drop, + scr_rgn_fiber_resume, + scr_rgn_fiber_suspend, +}; + +_Atomic(const ScrRgnFiberHooks *) scr_region_fiber_hooks = NULL; + /* ── program-facing entry points ─────────────────────────────────────── */ double scr_region_create(const ScrRgnType *types) { const ScrRgnType *expected = NULL; atomic_compare_exchange_strong(&scr_rgn_types, &expected, types); + atomic_store_explicit(&scr_region_fiber_hooks, &scr_rgn_fiber_hooks, memory_order_relaxed); /* Counting starts before the region's first object exists. */ atomic_store_explicit(&scr_region_counting, 1, memory_order_relaxed); ScrRgnThread *t = scr_rgn_thread(); @@ -1230,6 +1359,10 @@ static void scr_rgn_thread_exit(void) { free(r->cur); free(r); } + for (ScrRgnChunk *c = t->cache, *next; c; c = next) { + next = c->next; + scr_rgn_chunk_drop(c); + } free(t->regions); free(t->cursors); free(t->stack); @@ -1243,4 +1376,5 @@ static void scr_rgn_thread_exit(void) { #endif memset(t, 0, sizeof *t); t->exit_registered = true; /* the cleanup ran; a late allocation keeps its memory */ + t->exited = true; /* fibers destroyed later must not touch the freed regions */ } diff --git a/packages/runtime/src/scr_region.h b/packages/runtime/src/scr_region.h index 14eae6951a..69c2d3f14f 100644 --- a/packages/runtime/src/scr_region.h +++ b/packages/runtime/src/scr_region.h @@ -77,6 +77,11 @@ typedef struct ScrRgnChunk { struct ScrRgnChunk *next; char *bump; /* the next free segment byte */ int64_t found; /* references the last walk saw into this chunk */ + /* What stays resident across reuse (scr_region.c's chunk cache): the + * highest byte any region carved or faulted in, and whether the chunk + * may be backed by huge pages. */ + char *touched; + bool huge; } ScrRgnChunk; _Static_assert(offsetof(ScrRgnChunk, count) == 0 && offsetof(ScrRgnChunk, owner) == sizeof(int64_t), "llvm/regions.ts addresses the chunk count at 0 and the owner at 8"); @@ -111,4 +116,20 @@ void scr_region_release(double id); void scr_region_ref(ScrRgnWalk *w, void *obj); void scr_region_slot(ScrRgnWalk *w, void *obj, void (*release)(void *)); +/* Async continuations. A fiber that runs an async function body carries + * the region that was current when it was spawned (`capture`; NULL is the + * default region), and the event loop makes that region current while the + * fiber runs after an await (`resume` answers the region to restore with + * `suspend`). A region is never freed while a fiber that carries it + * exists; `drop` ends the fiber's hold. scr_region_create installs the + * hooks (process-wide, once); until then the fiber machinery (scr_async.c) + * calls nothing. Every hook acts on the calling thread's regions. */ +typedef struct ScrRgnFiberHooks { + void *(*capture)(void); + void (*drop)(void *region); + void *(*resume)(void *region); + void (*suspend)(void *previous); +} ScrRgnFiberHooks; +extern _Atomic(const ScrRgnFiberHooks *) scr_region_fiber_hooks; + #endif /* SCR_REGION_H */ diff --git a/packages/runtime/src/scr_region.test.c b/packages/runtime/src/scr_region.test.c index b79c818cd3..31e98942a8 100644 --- a/packages/runtime/src/scr_region.test.c +++ b/packages/runtime/src/scr_region.test.c @@ -381,6 +381,30 @@ static void check_debug_report(void) { puts("the debug report names what keeps a region"); } +/* A freed region's chunk serves the thread's next region, cleared where + * the freed objects were. Sanitized builds never reuse a chunk. */ +static void check_chunk_reuse(void) { +#ifndef SCR_RC_AUDIT + double r = region(); + scr_region_enter(r); + Node *first = node_new(7); + first->other = node_retain(first); + scr_region_leave(); + ScrRgnChunk *chunk = scr_rgn_chunk_of(first); + node_release(first); + scr_region_release(r); + double s = region(); + scr_region_enter(s); + Node *again = node_new(8); + scr_region_leave(); + assert(scr_rgn_chunk_of(again) == chunk); + assert(again->other == NULL && again->parent == NULL && again->kids == NULL); + node_release(again); + scr_region_release(s); + puts("a freed region's chunk serves the next region, cleared"); +#endif +} + static void check_poison(void) { #if defined(SCR_RC_AUDIT) && !defined(_WIN32) double r = region(); @@ -499,6 +523,7 @@ int main(int argc, char **argv) { check_in_use(); check_cursors(); check_retry_schedule(); + check_chunk_reuse(); check_poison(); } else if (!strcmp(mode, "debug")) { check_debug_report(); diff --git a/packages/runtime/src/scr_region.test.ts b/packages/runtime/src/scr_region.test.ts index cfadac91eb..28e44e09af 100644 --- a/packages/runtime/src/scr_region.test.ts +++ b/packages/runtime/src/scr_region.test.ts @@ -122,11 +122,16 @@ describe.skipIf(process.platform === "win32")("counted lifetime regions", () => const sanitized = lane === "sanitized"; expect(owned.stdout.trim().split("\n")).toEqual([ ...OWNED_STDOUT, - ...(sanitized ? ["sanitized builds fault on any access to a freed region"] : []), + sanitized + ? "sanitized builds fault on any access to a freed region" + : "a freed region's chunk serves the next region, cleared", ]); const ownedStats = attempts(owned.stderr); - expect(ownedStats.attempts).toEqual([...OWNED_ATTEMPTS, ...(sanitized ? ["18 freed"] : [])]); - const n = sanitized ? 18 : 17; + expect(ownedStats.attempts).toEqual([ + ...OWNED_ATTEMPTS, + ...(sanitized ? ["18 freed"] : ["18 freed", "19 freed"]), + ]); + const n = sanitized ? 18 : 19; expect(ownedStats.summaries).toEqual([ `scriptc regions: ${n} created, ${n} released, ${n} freed, 0 pinned, 0 kept`, ]); diff --git a/tests/corpus/5013-lifetime-region-async.ts b/tests/corpus/5013-lifetime-region-async.ts index 683d93d6d7..930c6af677 100644 --- a/tests/corpus/5013-lifetime-region-async.ts +++ b/tests/corpus/5013-lifetime-region-async.ts @@ -1,10 +1,9 @@ // LifetimeRegion (@scriptc/experimental) with async callbacks: run(fn) // returns fn's promise once fn first awaits, and the work that resumes -// after an await runs outside the region (it allocates in the default -// region, which is never freed). Releasing the region while the async work -// is still pending is safe: everything the pending work refers to stays -// allocated, and objects of the default region that refer into a released -// region keep it allocated for good. Node.js prints the same. +// after an await still allocates in the region. Releasing the region while +// the async work is still pending is safe: the region is not freed before +// that work has finished and nothing outside refers to its objects. +// Node.js prints the same. import type { ProgramLifetime } from "@scriptc/types"; import { LifetimeRegion } from "@scriptc/experimental"; diff --git a/tests/corpus/5023-lifetime-region-await.ts b/tests/corpus/5023-lifetime-region-await.ts new file mode 100644 index 0000000000..b17ef07aa1 --- /dev/null +++ b/tests/corpus/5023-lifetime-region-await.ts @@ -0,0 +1,200 @@ +// LifetimeRegion (@scriptc/experimental) across await: an async callback +// keeps allocating in its region after it resumes, so a region per request +// holds the whole request's graph and frees it once released. Requests run +// concurrently and interleave at every await; continuations nest other +// regions, reject, release their region while still pending, spawn work +// that outlives the request, and return objects that escape into a cache. +// Node.js prints the same. +import type { ProgramLifetime } from "@scriptc/types"; +import { LifetimeRegion } from "@scriptc/experimental"; + +class Token implements ProgramLifetime { + readonly text: string; + readonly pos: number; + constructor(text: string, pos: number) { + this.text = text; + this.pos = pos; + } +} + +class Node implements ProgramLifetime { + readonly token: Token; + readonly kids: Node[] = []; + parent: Node | undefined = undefined; + readonly attrs = new Map(); + constructor(token: Token) { + this.token = token; + } +} + +const tick = (): Promise => new Promise((resolve) => setTimeout(resolve, 1)); +const turn = (): Promise => new Promise((resolve) => setImmediate(resolve)); + +function tokenize(source: string): Token[] { + return source.split(" ").map((text, i) => new Token(text, i)); +} + +async function parse(tokens: Token[]): Promise { + const root = new Node(tokens[0]!); + for (let i = 1; i < tokens.length; i++) { + if (i % 3 === 0) await turn(); + const node = new Node(tokens[i]!); + node.parent = root; + node.attrs.set("pos", `${tokens[i]!.pos}`); + root.kids.push(node); + } + return root; +} + +function render(root: Node): string { + return `${root.token.text}(${root.kids.map((k) => `${k.token.text}@${k.attrs.get("pos")}`).join(" ")})`; +} + +async function handle(id: number, source: string): Promise { + const tokens = tokenize(source); + await tick(); + const root = await parse(tokens); + await Promise.resolve(); + // Children spawned after a resume run in the region too. + const sizes = await Promise.all(root.kids.map(async (kid) => { + await turn(); + const copy = new Node(kid.token); + copy.parent = kid; + return copy.token.text.length; + })); + return `#${id} ${render(root)} sizes=${sizes.join(",")}`; +} + +async function serve(id: number, source: string): Promise { + const region = new LifetimeRegion(); + try { + return await region.run(() => handle(id, source)); + } finally { + region.release(); + } +} + +// A sync generator driven by a resumed continuation. +function* chain(tokens: Token[]): Generator { + let prev: Node | undefined; + for (const token of tokens) { + const node = new Node(token); + node.parent = prev; + prev = node; + yield node; + } +} + +async function nested(): Promise { + const outer = new LifetimeRegion(); + const inner = new LifetimeRegion(); + const result = await outer.run(async () => { + const before = new Node(new Token("outer", 0)); + await tick(); + const local = inner.run(() => new Node(new Token("inner-sync", 1))); + const later = await inner.run(async () => { + await turn(); + const n = new Node(new Token("inner-async", 2)); + n.parent = local; + return n; + }); + const after = new Node(new Token("after", 3)); + after.parent = before; + after.kids.push(local, later); + const gen = [...chain(tokenize("g0 g1 g2"))]; + return `${after.parent.token.text} ${after.kids.map((k) => k.token.text).join("+")} ${later.parent!.token.text} ${gen.at(-1)!.parent!.token.text}`; + }); + inner.release(); + outer.release(); + return result; +} + +async function rejects(): Promise { + const region = new LifetimeRegion(); + try { + await region.run(async () => { + const node = new Node(new Token("doomed", 0)); + await tick(); + node.kids.push(new Node(new Token("child", 1))); + throw new Error(`failed after ${node.kids.length} child`); + }); + return "no error"; + } catch (e) { + return e instanceof Error ? e.message : String(e); + } finally { + region.release(); + } +} + +async function releasedWhilePending(): Promise { + const region = new LifetimeRegion(); + const pending = region.run(async () => { + const first = new Node(new Token("first", 0)); + await tick(); + // The region was released meanwhile; the work still allocates in it. + const second = new Node(new Token("second", 1)); + second.parent = first; + await turn(); + return `${second.parent.token.text}>${second.token.text}`; + }); + region.release(); + return await pending; +} + +// Background work started in a request outlives it. +let background: Promise | undefined; +async function outlives(): Promise { + const region = new LifetimeRegion(); + const text = await region.run(async () => { + const node = new Node(new Token("request", 0)); + background = (async (): Promise => { + await tick(); + await tick(); + const late = new Node(new Token("late", 1)); + late.parent = node; + return `${late.parent.token.text}<-${late.token.text}`; + })(); + await turn(); + return node.token.text; + }); + region.release(); + return text; +} + +// A result that escapes the request into a module-level cache keeps the +// region allocated until the cache lets go of it. +const cache = new Map(); +async function escapes(key: string): Promise { + const region = new LifetimeRegion(); + const node = await region.run(async () => { + await turn(); + const n = new Node(new Token(key, 0)); + n.kids.push(new Node(new Token(`${key}-kid`, 1))); + return n; + }); + region.release(); + cache.set(key, node); + return `${node.token.text}/${node.kids[0]!.token.text}`; +} + +async function main(): Promise { + const sources = ["let a = b + c ;", "fn f ( x ) { x }", "1 2 3 4 5 6 7", "if ( x ) y else z"]; + for (let round = 0; round < 2; round++) { + const responses = await Promise.all(sources.map((s, i) => serve(round * 10 + i, s))); + for (const r of responses) console.log(r); + } + console.log(await nested()); + console.log(await rejects()); + console.log(await releasedWhilePending()); + console.log(await outlives()); + console.log(await background); + console.log(await escapes("cached")); + console.log(cache.get("cached")!.kids[0]!.token.pos); + cache.clear(); + // A later region, after the cache let go. + console.log(await serve(99, "done .")); +} + +main().catch((e) => { + console.log("unexpected", String(e)); +}); diff --git a/tests/harness/lifetime-regions.test.ts b/tests/harness/lifetime-regions.test.ts index 7eb294482e..2b1516ef47 100644 --- a/tests/harness/lifetime-regions.test.ts +++ b/tests/harness/lifetime-regions.test.ts @@ -145,9 +145,9 @@ describe.skipIf(process.platform === "win32")("lifetime regions", { timeout: 240 }); test("async work, nesting, holders the walk does not inspect and handles", async () => { - // Objects allocated after an await live in the default region and keep - // the regions they refer into. - expect((await run("5013-lifetime-region-async.ts")).summaries).toEqual([summary(7, 2)]); + // Work that resumes after an await allocates in its region, which is + // freed once that work has finished. + expect((await run("5013-lifetime-region-async.ts")).summaries).toEqual([summary(7, 7)]); const nesting = await run("5014-lifetime-region-nesting.ts"); expect(nesting.summaries).toEqual([summary(4, 2)]); const opaque = await run("5021-lifetime-region-opaque-holders.ts"); @@ -156,6 +156,23 @@ describe.skipIf(process.platform === "win32")("lifetime regions", { timeout: 240 expect((await run("5019-lifetime-region-values.ts")).summaries).toEqual([summary(2, 0)]); }); + test("regions carried across await by concurrent requests are freed", async () => { + const stats = await run("5023-lifetime-region-await.ts"); + expect(stats.summaries).toEqual([summary(15, 15)]); + // Each request's region (eight interleaved ones, then a later one) is + // freed when the request releases it; a region released while its work + // is pending, or while work it started is still running, is freed once + // that work ends, without an attempt that keeps it. + for (const id of [1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 12, 13, 15]) + expect( + stats.attempts.filter((a) => a.startsWith(`${id} `)), + `region ${id}`, + ).toEqual([`${id} freed`]); + // The nested region is referenced by the outer one until that is freed; + // the escaping result keeps its region until the cache lets go. + keptThenFreed(stats.attempts, [10, 14]); + }); + test("weak keys of freed regions do not survive", async () => { const stats = await run("5016-lifetime-region-weak-keys.ts"); expect(stats.summaries).toEqual([summary(5, 4)]); From 63dae8775eddb2bcd37c61d860798558933190f6 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 17 Oct 2026 12:24:47 -0700 Subject: [PATCH 2/2] Define the region fiber hooks in an always-linked runtime unit scr_async.c reads scr_region_fiber_hooks, but several runtime test links (cipher, bytes, sha-kernels, runtime) take scr_async.c without scr_region.c and failed with an undefined reference. scr_object.c is in every runtime link, so the pointer now lives there; scr_region.c still installs the hooks. Co-Authored-By: Claude Opus 5.5 --- packages/runtime/src/scr_object.c | 5 +++++ packages/runtime/src/scr_region.c | 2 -- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/runtime/src/scr_object.c b/packages/runtime/src/scr_object.c index a533a409ce..7cafbeca3d 100644 --- a/packages/runtime/src/scr_object.c +++ b/packages/runtime/src/scr_object.c @@ -2,6 +2,11 @@ * one runtime layout, including captured bindings for local classes. */ #include "scr_runtime.h" +/* The lifetime-region fiber hooks (scr_region.h) live in this always-linked + * unit: scr_async.c reads them, and some runtime test links take + * scr_async.c without scr_region.c. scr_region_create installs them. */ +_Atomic(const ScrRgnFiberHooks *) scr_region_fiber_hooks = NULL; + void scr_classobj_trace_v(void *object, ScrTraceVisit visit, void *ctx) { ScrClassObj *c = object; for (size_t i = 0; i < c->ncaps; i++) visit(c->caps[i], ctx); diff --git a/packages/runtime/src/scr_region.c b/packages/runtime/src/scr_region.c index c8906ad0fb..31d01296e3 100644 --- a/packages/runtime/src/scr_region.c +++ b/packages/runtime/src/scr_region.c @@ -1288,8 +1288,6 @@ static const ScrRgnFiberHooks scr_rgn_fiber_hooks = { scr_rgn_fiber_suspend, }; -_Atomic(const ScrRgnFiberHooks *) scr_region_fiber_hooks = NULL; - /* ── program-facing entry points ─────────────────────────────────────── */ double scr_region_create(const ScrRgnType *types) {