Skip to content

Carry lifetime regions across await and reuse freed region chunks - #1194

Merged
ctate merged 3 commits into
mainfrom
opt/06-regions
Oct 18, 2026
Merged

ctate merged 3 commits into
mainfrom
opt/06-regions

Conversation

@cramforce

Copy link
Copy Markdown
Contributor

Summary

LifetimeRegion regions now work for async request handling, and regions that are freed over and over cost less.

Regions cross await. Before this change, an async function called inside region.run(fn) allocated in the region only until its first await. Everything it created after that went to the default region, which is never freed. Those objects usually pointed back at the objects created before the await, so the request's region could never be freed either. With one region per request, an async server's memory grew without bound, and every release() also triggered a full cycle-collector pass over the heap. Now:

  • Every async fiber records the region that was current when it was spawned (ScrFiber.region). The event loop makes that region current while it resumes the fiber and restores the previous region afterwards. The runtime calls nothing for this until the program creates its first region: a process-wide hook table (scr_region_fiber_hooks) installed by scr_region_create.
  • A region is not freed while any fiber that carries it exists (ScrRegion.fibers), and no release check runs on it in that time. So a region released while its async work is pending keeps receiving that work's objects. It is freed once the work ends and nothing outside the region refers into it. Escaping references still keep a region, as before. Exceptional exits are unchanged: run leaves the region in a finally, and a rejected async body drops its hold when its fiber is destroyed.
  • Generators behave as before: their bodies run in the region of whoever resumes them. Timers, queueMicrotask callbacks, event listeners and async-generator bodies run in the loop's (default) region. The docs say so.

Freed chunks are reused. A freed region's 2 MiB chunks used to be unmapped immediately. A program that frees a region per request or per batch therefore mapped, faulted in and unmapped new memory every time. In the request benchmark that came to about 30 page faults per request and about 19% of samples in the kernel. Each thread now keeps freed chunks in a cache:

  • The cache holds at most 8 MiB of resident memory per thread.
  • A cached chunk keeps only its first 256 KiB resident, cleared with memset. Past that it gets fresh pages via mmap(MAP_FIXED), so a small region that reuses a large region's chunk does not inherit its memory.
  • Chunks that huge pages may back are not cached.
  • Sanitized and RC-audit builds never reuse a chunk, so the use-after-free trap still works.

A cache bug caught and fixed during development. The first version kept cached chunks fully resident. It doubled steady-state RSS in generations-edit (76 → 159 MB), because small per-file regions kept reusing chunks that the checker region had fully touched. The 256 KiB warm limit fixes this.

Measurements

Linux Vercel Sandbox, 8 vCPU. Each benchmark is compiled with base (origin/main 8620fa5) and with this branch, and the two run interleaved; values are medians of 9 runs (ab-regions.mjs). RSS figures come from /proc/self/status.

benchmarkmetricmainthis PR
requests-async (4000 requests, 16 concurrent, 100 items, new)total2130 ms668 ms (−69%)
RSS at end / peak995 / 995 MB8.9 / 8.9 MB
requests-async, 1 at a timetotal2048 ms599 ms (−71%)
RSS at end995 MB3.4 MB
requests-async, 64 concurrent, 400 itemstotal7513 ms4260 ms (−43%)
RSS at end / peak4060 / 4060 MB61.6 / 79.8 MB
emit-small (20000 per-file regions)emit694 ms504 ms (−27%)
emit-files (200 per-file regions, large live heap)emit70.4 ms61.1 ms (−13%)
generations-allmedian generation50.2 ms45.3 ms (−10%)
steady RSS39.8 MB47.6 MB (the chunk cache)
generations-editmedian generation / steady RSS25.0 ms / 76.4 MB25.1 ms / 80.0 MB
compiler-watch (large regions, huge pages)median rebuild / RSS at end316 ms / 171.8 MB317 ms / 174.6 MB

Context for requests-async on this branch (medians of 5 interleaved runs, same VM):

  • on: the same classes as ProgramLifetime without regions, so nothing is ever freed. 551 ms, 979 MB.
  • off: plain reference-counted classes. 1288 ms, 26.6 MB.
  • region: this PR. 680 ms, 8.9 MB.
  • Node.js: 819 ms, 206 MB.

Per-request regions now run close to leak-everything speed. Against plain reference counting they are 1.9× faster and use a third of the memory.

How the requests-async gain splits between the two changes (separate, non-interleaved runs on a second sandbox): crossing await without chunk reuse took it from about 2.37 s to about 1.0 s. Chunk reuse then took it to about 0.71 s, and minor page faults fell from 121k to 1.7k.

scripts/bench-runtime.mjs --layouts=4 --runs=16 (base vs candidate) is neutral. The geomean ratio is 0.995. region-builds changes by −0.0% and async-pipeline by −0.8%. Every workload is within ±3% except http-api, at −4.4%. Async binaries grow by 48 bytes.

Tests

  • New corpus program 5023-lifetime-region-await (byte-identical to Node.js). It covers:
    • eight concurrent request regions that interleave at awaits, with children spawned after a resume;
    • a nested region inside an async continuation;
    • a sync generator driven from a continuation;
    • a rejection after an await;
    • a release while the work is pending;
    • background work that outlives its request;
    • an escaping result held in a cache.
  • tests/harness/lifetime-regions.test.ts checks, from SCRIPTC_REGION_STATS, that all 15 regions of 5023 are freed. Regions whose work was pending are freed without a kept attempt; the nested region and the escaping one are kept first and freed later. 5013 now frees 7 of 7 regions (was 2 of 7).
  • scr_region.test.c adds a check that a freed region's chunk serves the next region, cleared.
  • Docs: the /docs/experimental LifetimeRegion section gains an "Async work" subsection with an example whose output was run, the API and cost text is updated, and the @scriptc/experimental README and index.d.ts say the same.

Validation, all in a Linux Vercel Sandbox:

  • pnpm lint and pnpm format:check are clean.
  • pnpm test:ts7 --baselines-only passes. Re-recording added exactly the 5023 baseline and changed no existing entry.
  • Differential tests: the lifetime-region programs pass 15/15 in the plain lane, and SCRIPTC_SAN=1 passes 62/62 across the differential and region-harness tests.
  • The async|await|promise|generator differential subset passes 194/195. The one failure, 5570-fs-cp-async, also fails on the untouched base.
  • Unit and harness suites pass: scr_region.test.ts 3/3 (size-class, sanitized, TSan workers), lifetime-regions.test.ts 9/9, scr_async.test.ts, and regions.test.ts.
  • The self-hosting --emit=ir build reports no diagnostics, and self-hosting-native-driver.test.ts passes 1/1.
  • The docs check (NEXT_DIST_DIR=.next-check pnpm check) passes 25/25.

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
scriptc Ready Ready Preview, v0 Oct 18, 2026 1:24pm UTC

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 <noreply@anthropic.com>
Resolve the scr_resume_fiber conflict with stackless async coroutines
(#1204): the region the fiber carries is made current before either
resume path and restored after both. scr_async_spawn_stackless now
captures the spawning region like the fiber spawns, so stackless bodies
(every async body in 5013 and 5023 after the merge) keep allocating in
their region after an await and keep it alive while they exist.

5024-lifetime-region-await-forms pins regions carried by stackless and
fiber (for await) bodies that spawn and await each other.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ctate
ctate merged commit 4ed7511 into main Oct 18, 2026
66 checks passed
@ctate
ctate deleted the opt/06-regions branch October 18, 2026 13:47

This branch was successfully deployed

1 active deployment
Preview — 10c5e269 Deployed Oct 18, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants