- Support MessagePort ownership transfer across Workers, MessageChannels and structuredClone, including parentPort data connections, queued messages, graph aliases and ordered MessageEvent ports. Validate port and buffer transfers together before detaching source ownership.
Fix two main reds: drop the unused HashMap import in perry-ext-http's HTTP/2 module (the warnings job), and make the library_search compressed-archive package test locate only the package's candidate, so PERRY_RUNTIME_DIR (set in CI) no longer answers before the package.
Implement Worker.prototype[Symbol.asyncDispose]() using the existing termination
lifecycle, with async function metadata, dynamic terminate() overrides, and
Promise rejection and exit-ordering parity.
perry compile --target android|ios|ios-sim|wasip1|wasip2 fetches the signed @perryts/perry-target-<triple> package from the perry release on demand, as perry target add <target> would, when the target's runtime or stdlib is not installed: it prints one perry: fetching ... line, verifies the Ed25519 manifest, and installs once per perry version under ~/.perry/targets. The compile-time fetch is now generic over the package kind (ensure_package), and locate_or_fetch looks again after an install. An installed package that lacks the archive, the host's own triple, instrumented (_instr) archives and PERRY_NO_AUTO_FETCH=1 never fetch; a refused fetch puts its reason and the install commands in the missing-library error. perry run --target keeps choosing local or remote by the installed libraries and does not fetch. The wasi npm smoke test sets PERRY_NO_AUTO_FETCH=1.
perry target add and the compile-time ext fetch now print perry: downloading <asset> (31.2 MB) from github.com before every download, redraw a progress line when stderr is a terminal (one done: 31.2 MB in 14 s line otherwise), and give the tarball request max(600 s, size at 32 KiB/s) instead of a flat 600 s, so a slow link is not cut off mid-file. When the release host cannot be reached at all (no HTTP status: resolve, connect, TLS or a deadline) the error reads cannot reach <host> (<cause>) and leads with the pinned npm install -g @perryts/perry-ext-<triple>@<version> command (npm uses the user's own registry and proxy), then HTTPS_PROXY or PERRY_RELEASE_MIRROR=<dir|https url>. A 404 stays "no such package"; there is no registry fallback, because the signed manifest lives only on the GitHub release.
Link-time archive dedup now reads each member's native symbol table: every llvm-nm call in strip_dedup passes --no-llvm-bc (only for LLVM-based nm tools, GNU nm rejects it). Before, on macOS archives that still carried an __LLVM,__bitcode section (unstripped local builds), llvm-nm returned the bitcode module's symbols, dedup compared the wrong sets, kept duplicate std/core members and the link printed duplicate symbol warnings.
--target wasip1 (#13259) is complete. The reference preview 1 host in test-wasi/p1-host builds again under the repository-wide build-std: it is now panic=abort, like the root workspace. Before this, its std build failed with E0152, and the wasi-execution job could not run the wasip1 host suite. The P1 output docs now list the PR for each step, and the host requirement is native wasm exception handling instead of SjLj.
Whole-program facts re-check a lookup that skipped its builtin members (an
own definite key shadowing them, DECISIONS 234) only on the events that can
revoke it: a key redefined through ⊤ or on a builtin prototype re-checks the
lookups of that key; an escape, delete or key-set change of a class re-checks
the lookups on that class; only an event of every key re-checks all. The
first solve of tssrc drops from 29.4 s to 13.8 s with the same proven sites.
A class declared in a function that can run more than once, with a computed-key member ([Symbol.iterator]() {}), now sees its own evaluation under its own name (#13965). Such a class is a fresh class object per evaluation, but its body read the shared template instead, so x instanceof C inside its constructor was false for real instances and a static method returning C returned a different object than the caller's C. A CommonJS module compiled through compilePackages runs in a factory with exactly this shape: undici's HeadersList copy constructor (init instanceof HeadersList) fell through to new Map(headersList), which broke fetch() from source.
Removes the native bindings for seven pure-JS npm packages: nodemailer, cheerio, lodash, ethers, ws, undici and typescript. import ... from "<pkg>" now compiles the package's real source from node_modules, like any other package. This is part of the native-binding removal campaign. There is one commit per package, and the last one regenerates the API reference.
- Deleted:
perry-ext-nodemailer,perry-ext-cheerio,perry-ext-ethersandperry-ext-undici;- perry-stdlib's bundled
nodemailer,cheerio,ethersandlodashcopies, and the turnloop SMTP engine that only nodemailer used. With them goturnloop-smtp/lettre andscraperwith its html5ever closure, so about 30 packages leaveCargo.lock; - the well-known rows and their upstream pins, the native-table rows, the runtime declarations, the API-manifest entries, the Android stubs, and the CLI feature / driver / sandbox arms;
- the cheerio
$(selector)HIR rewrites and the typescript enum fold; - the undici
new Agentarms and the hard-codedPERRY_NATIVE_EXTENSION_PACKAGESresolver guard, which became empty.
- Kept:
perry-ext-wsstays. It also serves the globalWebSocketclient and perry-ext-http's WebSocket hand-off. Only the npmwsmapping goes.- Its link key is now the Perry-owned
perry/websocket. - Its dispatch key is the internal
__websocket__, so the generatedperry.d.tsno longer declaresmodule "ws". - Only the global
WebSocketis tagged native. An imported orrequiredWebSocketis the package's class.
- Its link key is now the Perry-owned
perry-ext-typescriptbecomesperry-ext-bun. It keepsBun.Transpiler/Bun.buildunder the Perry-ownedperry/bun-transpilerkey.- Global
fetchwas never served by perry-ext-undici and is unchanged. npm undici'ssetGlobalDispatcherdoes not reach it.
- Known gaps from source:
- ethers: ABI decoding (#13960) and signing (#13961);
- lodash:
_.templatewith default options (#13962); - cheerio:
fromURL(#13963); - undici:
fetch(#13964, #13965); - typescript compiles correctly, but a program that imports it takes 17–37 min to compile at 7–10 GB peak RSS, plus the wpfacts stage (#13974).
- Tests:
- Binding-only tests are removed.
- The test-files cases for these packages now drive the npm packages, pinned as root devDependencies: nodemailer 9.0.3, cheerio 1.2.0, lodash 4.18.1 and ws 8.21.1. ethers 6.17.0 was already a root dependency.
- Stale known-failure and snapshot entries for tests that now pass are removed.
- Re-derived:
- the native-result ledger: 182 → 157 rows, 170 → 145 providers;
- the native-handle, export-collision, string-payload, SSO, GC root-holder and unrooted-local inventories;
- the registry-lifetime floors,
workspace-architecture.json, the governance table, the wasm32 runtime ABI table and the API reference.
Fixed
- Expose Worker threadId, threadName, and resourceLimits through inherited getter-only properties that reflect exit state, preserve user-defined shadows, and return isolated resource-limit snapshots.
wpfacts: console and process.stdout/stderr/stdin are host objects of the runtime, one token each (stdio was ⊤); their described own members are the runtime natives until a store reaches the key (the DECISIONS 255 store funnel, one predicate read by the host calls, the host loads and the site verdict), so chess console.log / stdout.write / stdin.setEncoding sites are proven; a written key makes the call an unknown method of the host.
- wpfacts: a RegExp method call (
test,exec, the methods the solve models) is a native member while no own key, prototype write or escape can replace it (chess app.ts:151).
Whole-program facts prove method calls on defaulted parameters: an undefined a call passes is no longer a value of the parameter, the default replaces it (chess tests 785 to 786 of 806 sites, tssrc 22,792 to 23,108 of 66,746).
wpfacts: a read of a key that no receiver holds, on closed fixed object-literal layouts whose prototype is the unwritten Object.prototype that lacks the key, is proven to give undefined (chess search options maxDepth/maxNodes); a key some receivers hold and others lack stays "slots differ".
performance.markResourceTiming no longer brand-checks its receiver, matching
Node (which brand-checks every other Performance method but not
markResourceTiming, eventLoopUtilization or timerify). A detached call
such as undici's module-scope const markResourceTiming = performance.markResourceTiming used to throw TypeError [ERR_INVALID_THIS],
so every undici.fetch() compiled from source rejected with TypeError: fetch failed and network errors escaped uncaught from finalizeAndReportTiming
(#13964).
Array.prototype methods called on a primitive receiver through .call, .apply or a
held method value (Array.prototype.slice.call("abc", 1), const push = Array.prototype.push; push.call(true, 1)) again run on ToObject(this): slice of a string returns its characters
(was [], and a number receiver crashed), push/pop/shift/unshift/reverse/splice
throw on a string and mutate the Number or Boolean wrapper (were undefined), and
keys/values/entries iterate the wrapper and throw on null/undefined. The method value
converts a non-object receiver at its entry; object receivers keep their existing paths.
Whole-program facts prove array methods (slice, includes, join) on arrays returned by functions: a summarized literal's elements no longer stand for every named key of the array (chess 613 to 617 of 647 sites, chess tests 784 to 791 of 806).
The compile-env audit lists PERRY_FACTS_ACTIVATION_CHECK as a diagnostics-only variable, so codegen_env_vars_are_build_cache_inputs passes again.
wpfacts: a for-of loop now reads only the values its iteration yields, and arrays carry a per-array hole fact, so element reads from for-of over holey-free arrays (chess and chesst receivers such as run.ts split) are proven instead of reading undefined or the iterator record.
A program that imports only node:inspector or node:cluster (for example
new Session() or cluster.Worker) no longer aborts with "native
constructor bag events.EventEmitter differs from its static layout". Those
modules now read EventEmitter through its own export, which installs its
statics (once, on, listenerCount, defaultMaxListeners, ...) before
the constructor is sealed on its static layout, the same way a direct
node:events import does.
WASI (#13953): --target wasm32-wasip2 and --target wasip2 now build the
same WASI 0.2 component as --target wasi, and --target wasm32-wasip1
builds the preview 1 module. These names used to fall through to a host link
that failed with unable to disambiguate: -dead_strip. wasip3 and
wasm32-wasip3 now fail with an error naming the WASI targets that exist.
A WASI 0.2 program that iterates process.stdin (for await) no longer panics with failed to spawn thread: the event loop polls fd 0 as on preview 1 instead of starting a
reader thread. The TUI input reader, spinner ticker and Atomics.waitAsync
take the same no-thread path on WASI 0.2 that they already took on preview 1.
docs/WASI_SUPPORT.md gains a target-name table and notes that
navigator.userAgent reports Node.js/22 as a compatibility value.
Fixed the rustc warnings the warnings CI job reports under -D warnings on main: the STACK_MAPS_REBUILD_OWED doc comment now sits inside per_test_global!, the unneeded unsafe around schedule_native_immediate is gone, runtime_layouts::layout_id (test-only) is #[cfg(test)], and perry-ext-http no longer glob-re-exports client_surface. No runtime behavior changes.
A native payload (the cell behind a Hash, a zlib stream, an http response) is now an own slot of its object under the runtime-private key [[NativePayload]] (slot 0 of a born instance) instead of the object metadata word: payload instances are born without a metadata record, and a stream state record no longer keeps a second copy of the cell.
Runtime: MessageEvent is a key of the static GLOBAL_OBJECT layout, so the global object is born with all of its builtin names as image atoms (the MessageEvent global was a 130th, heap-string key beside the layout's 129).
Program facts are solved over the whole program: one solve over every
module, where a function body contributes only once the program can run
it. The per-unit solve (one solve per module, contracts exchanged between
the units to a fixed point) is retired; PERRY_FACTS_UNITS=1 keeps it
reachable for its tests until it is deleted. On tsc-sized programs the
facts pass is about 10x faster (the TypeScript compiler's sources: 652 s
per unit, 32 s whole, on the same host) and proves more sites (22,792
against 20,644); small programs lose only proofs in functions the program
never runs.
The flip came with five soundness rules, each with a fixture under
test-files/test_gap_*: a class whose extends value is a native
constructor or a function born by one gives no fixed-layout or
prefix-stable template below it; a super() call is static only when the
evaluated parent is the linked parent, and the fact names that parent; a
class expression evaluated more than once gives no fact keyed on its class
object or private brand; a per-evaluation template reserves its birth
keys instead of owning them; and a static family lists its constructor
keys in the class reservation order the facts and the key index read.
The key index names a spilled accessor pair as it names an inline one. derive_word
now gives every public accessor of an indexed list an ACCESSOR_ENTRY whose index
word is the pair's storage slot, with PAIR_SPILLED when the slot lies past the
record's inline slots. The probe (first bucket and continued) and the slow walk
(own_entry -> accessor_in) read the pair with one load, inline or from the spill
(index_pair_bits), instead of re-reading the key's attributes and storage slot on
each read. The word is a fact of the immutable key list, its attributes and the
inline slot count; no cache. An empty spill slot declines exactly as before.
A runtime test checks that the index names every spilled pair and that both
readers return the attribute walk's word.
A Map or Set .size read now follows the receiver's prototype chain. The intrinsic storage count is read only when the receiver has no own size and inherits the unwritten intrinsic Map.prototype / Set.prototype; a subclass getter, a relinked prototype or a redefined size accessor is honoured, matching node.
The check costs 13 instructions per m.size read (29 to 42).
Whole-program facts no longer model export type T = ... or export interface I
(or export { I } of a local interface) as a module export whose value is
unknown. Such an export is erased at run time, so the module namespace has no
key for it; the facts now ask the same HIR predicate as codegen and the
namespace builders (Module::is_type_only_export_binding), so a computed read
of a namespace that lists type exports ((ns as any)[k]) no longer holds an
unknown value and its receivers' sites can be proven.
ERR_INVALID_ARG_TYPE received-value unit tests follow node 26.12 value.constructor?.name (one constructor Get, non-empty string names, otherwise inspect); an empty Buffer now inspects as <Buffer > like node (console.log, util.inspect and the received description) instead of <Buffer>.
The perry-runtime JSON hidden-field unit test now expects a digits-only __perry_cap_7 to stay visible on a class instance; only the compiler's exact salted capture key __perry_cap_<digits>m<12 hex> is hidden, as everywhere else.
A store of a named property into an array, the first one at a site the compiler gave an array layout for, now asks the prototype chain the way node does: an inherited setter on Array.prototype runs and an inherited read-only key makes the store fail, instead of the property being written as an own key. A read-only key that Array.prototype re-defines after a delete is now honored too.
The realm image function test sets its hello-world floor from the measured
reach (274). #12774 removed the Object constructor own copies of six inherited
Object.prototype methods, so those closures are no longer in every realm.
A file descriptor the program inherited across exec (an fd above 2 that is not close-on-exec) is no longer closed when a runtime thread winds down or its registry is dropped; as in node it is closed only by the program's own fs.closeSync/fs.close or by a socket built on it.
WebAssembly host wrappers are born as instances of their constructor: an
exported Memory, Table or Global inherits from WebAssembly.<Ctor>.prototype,
Table.prototype.get/set/grow operate on their receiver, Table.prototype.length
is a getter without a setter, and new WebAssembly.Table and
new WebAssembly.Global no longer throw.
Fixed
worker_threads.receiveMessageOnPort()now rejects invalid receivers and closed BroadcastChannels without reading spoofable public IDs, while accepting closed MessagePorts and synchronously consuming runtime-backed port inboxes.
Fix node:crypto argon2 and argon2Sync silently ignoring the optional secret (pepper) and associatedData parameters. Both inputs now affect the derived key, with owned submission-time copies for asynchronous calls. Associated data longer than the dependency's 32-byte limit throws ERR_OUT_OF_RANGE instead of being ignored; this limit differs from Node.
A program that reaches perry-stdlib only through a provider with no matching import (new SQL() from "bun", listen from "bun") now initializes the stdlib it links: its handle dispatchers are registered, and new SQL(":memory:") no longer throws "Invalid native property owner" (test_gap_bun_sql_9604 matches bun).
Whole-program facts take the compile's one activation pass from dynamic reach instead of running it a second time (a program with dynamic import() now solves its activation once), and the dynamic reach line counts its dynamic edges without PERRY_DYNAMIC_REACH_REPORT.
- Implement canonical Event-derived
MessageEventconstruction and worker/channel delivery, including port validation, iterable conversion, legacy initialization, and throw-safe dispatch state cleanup.
Fix namespace calls such as Object.keys(x) after a program stores into the global object: a computed-key store (globalThis[k] = v) or Object.assign / defineProperty onto the global object now guards the call on the namespace's runtime pristine bit instead of keeping the intrinsic, so a replaced namespace is called (it printed the intrinsic result before). Whole-program facts treat the strict existing-global store Object = w as a store into the global object, so values reachable through the replaced namespace stay live. A parenthesized or TS-typed target such as (Object as any) = w is scanned as the same store as Object = w.
A private method called as o.#m() on a receiver with no shape word (a Proxy, a Date) at a site that had not yet run now throws the TypeError node throws instead of running the method: a zero shape word never matches an unprimed call site.
push on a frozen, sealed or non-extensible array now reports the first Set that fails, as node does: Cannot add property N, object is not extensible with arguments, and the read-only length error for a frozen array with none. Object.isFrozen and Object.isSealed of a manually locked array that carries named or length descriptors are now true, and Reflect.ownKeys of such an array lists length once.
A TypeScript import that names only an interface or type alias no longer counts as a binding, so a local declaration of the same name (TypeScript's own function Symbol beside the imported Symbol interface) compiles instead of failing with "Identifier has already been declared". Duplicate imports, namespace imports and JavaScript modules keep the error.
A program that reaches Headers, Request or Response only by a runtime string now links the real perry-stdlib web-fetch implementation instead of the no-op stubs, and the events max-listener warning no longer throws "process.emitWarning is not supported" when the program never names process: a global or module surface now installs the implementation that runtime-internal code calls.
The HTTP client's res.trailers keys are lowercase, as in node, whatever the wire spelling of the trailer names (they kept the wire spelling, so res.trailers["x-end"] was undefined for an X-End trailer).
res.write() on a chunked HTTP/1 response now follows node 26.12: writes made in the same tick, or between cork() and uncork(), go out as one chunk, writableCorked and writableLength report the corked state, and a string chunk with an unknown encoding throws ERR_UNKNOWN_ENCODING.
A literal require through createRequire loads an installed CommonJS package however the module obtains createRequire from "module" (a default import, CommonJS destructuring or binding of require("module"), or inline require("module").createRequire) and however it names itself as the base (import.meta.url, import.meta.filename, new URL(".", import.meta.url), fileURLToPath(import.meta.url), __filename). A relative URL whose final segment is . or .. now keeps the trailing slash of the directory it names (new URL(".", import.meta.url) was the module's directory without its slash), and a createRequire base that ends in a separator resolves relative to that directory as in Node. Before, those forms threw MODULE_NOT_FOUND at runtime for a package that a literal require loads.
A Node-API function that closes its own handle scope before it returns the object it built there (node-addon-api's Napi::HandleScope shape) returns that object instead of undefined: closing a scope releases its handles for reuse, and a handle stays readable until a new handle takes its slot, as under V8. A returned handle that no longer reads throws ERR_INVALID_RETURN_VALUE instead of becoming undefined.
Node-API environment teardown at the natural end of the program runs in Node's order: one cleanup queue of cleanup hooks, async cleanup hooks and thread-safe functions, newest first, then the finalizers of the closed thread-safe functions (before, every thread-safe function finalized before any hook ran). napi_add_async_cleanup_hook accepts a null removal handle, as Node does.
Fixed new of a subclass without its own constructor running the base class field initializers twice ("Cannot initialize #x twice") when the inherited constructor calls super().
new net.Socket({ fd }) adopts the descriptor as the socket's stream and owns it, as under Node; on fds 0 to 2 it leaves the descriptor open when it closes, as libuv does. A readable fd socket starts reading at construction, so data that arrives before the first listener is buffered (readableLength) and not lost. readable: false and writable: false start the socket with that side ended. end() on a pipe, FIFO or tty completes after the queued writes drain, with no error, instead of failing the write. The socket emits prefinish before the end() callbacks and finish, write and end() callbacks receive null, and closed, readableLength, readableObjectMode and writableObjectMode are readable.
Regression tests for Function.prototype.call.call(f, ...),
Function.prototype.apply.call(f, ...) and Function.prototype.bind.call(f, ...)
(also through a local holding the intrinsic): they run f itself, even when f
was relinked by Object.setPrototypeOf onto an object with its own
call/apply/bind or has an own call. Tests only; no behaviour change.
A call whose callee the whole-program facts prove to be one of two to four bodies now compares the callee's function info with each body's and calls the matching body directly instead of making an indirect call; a proven closure body (one body or an arm) is called through its typed entry behind the public entry's argument check. The proven-call fixtures run 2% (four bodies rotating every call) to 47% (one arrow body) fewer instructions per call.
A by-name read on a Temporal value (d.constructor, d.hasOwnProperty, a
member added to Temporal.PlainDate.prototype) answered undefined when the
built-in getters and methods did not know the key. It now continues as the
ordinary property read on Temporal.<Kind>.prototype.
A user property spelled like a compiler capture field (__perry_cap_user, __perry_cap_7) is an ordinary property: only the compiler\x27s own __perry_cap_<id>m<12 hex> fields are capture storage, so such keys now survive in class instances, enumeration and JSON.
Eight test-read process-global counters in perry-runtime (feature-installer runs, the stack-map rebuild debt, the zlib super-construct recorder, the namespace getter count, the proxy delete-trap flag) are now per-test, so sibling tests no longer move their values; no change to a shipped binary.
Native module namespaces with a short name (fs, os, path) no longer decline own-field reads: the module-name guard reads inline strings, not only heap strings. Two store-site audit markers for the symbol space.
Fix renamed worker-thread constructor imports: dispatch MessageChannel, BroadcastChannel and Worker by their original export name rather than the local alias. Preserve constructor-name collisions, Worker child-entry discovery and eval metadata, while retaining existing lexical/global/namespace resolution.
A keyed read m[k] on a Map or Set with a key computed at run time read the
collection's storage as an array: map[0] answered the first entry key and
every other key (map["marker"], map["get"], a subclass accessor) answered
undefined. It is now the ordinary property read: own properties, then the
prototype chain.
The store twin m[k] = v on a Map or Set is now the ordinary [[Set]] too (it
was dropped): an inherited setter runs with the collection as receiver,
otherwise an own property is created; constant-index reads (set[7]) and the
typed-feedback fallback no longer read the storage.
A stream's _readableState now carries node 26.12's fastChunk, pipeOnData and pipePause fields (born null, between length and pipes), so programs that list or serialize the readable state match node 26.12.
o.k = v after const o = {} and this.k = v in a constructor whose field
is only declared were turned into a plain field write at the object's birth
even when a prototype defines a setter or a read-only k (through an alias of
Object.prototype, new Function/eval, a relinked prototype, or another
module). Both folds now ask the program's prototype-write census, which
already decides this for Object.create families: a key folds only when no
prototype can intercept its add; otherwise the assignment stays a [[Set]].
Fix computed object-literal keys: {[k]: v} defines the property instead of assigning it, so {["__proto__"]: v} no longer changes the prototype and inherited setters no longer run.
Fix the own-key order of Function.prototype: arguments and caller come after toString, in creation order as in node.
ERR_INVALID_ARG_TYPE now describes a received object like node 26.12 (node 6b84ee66fa): it reads value.constructor?.name once per property, names the value only by a non-empty string, and otherwise prints {} for an object with no enumerable own keys and [Object] for one with keys (a non-enumerable key such as a defined constructor accessor no longer counts).
A generator, async or async-generator function called through apply,
call or bind by name now reads the method off its kind prototype once
program code wrote that prototype (Object.getPrototypeOf(gen).apply = f, an
accessor, a relink). It used to answer the builtin Function.prototype
method regardless. Unwritten kind prototypes keep the builtin answer.
Buffers taken from the shared pool now sit in a pool that is padded and aligned to 64 bytes like node 26.12 (node 6bff2238f5): the pool store is Buffer.poolSize + 64 bytes, the pool starts at the first 64-byte aligned offset, and a new pool is created when a request no longer fits in Buffer.poolSize, so buf.byteOffset and buf.buffer.byteLength of pooled buffers match node.
Fix messaging constructor error policies: MessagePort call/construction now reports ERR_CONSTRUCT_CALL_INVALID, MessageChannel calls without new report ERR_CONSTRUCT_CALL_REQUIRED, and BroadcastChannel calls retain a code-free class-constructor TypeError. Preserve canonical identities and successful construction.
A loop over a class instance now runs its loop region's fast copy: 24 -> 12
instructions per iteration for a plain .js class, 18 -> 13 for a .ts class
with declared or initialized fields. A class family layout may hold holes for
life, so the region's prime now serves it with a presence test on the object
(a re-check never passes a word with the presence bit; only the full entry
guard proves presence). A loop-invariant bound (n in i < n) is
entry-tested as a Number instead of re-checking every iteration, and a read
the region proves a Number stays a raw double in the fast copy.
The region word now names a key's storage slot, not its position in the key
list: an instance whose keys were stored out of declaration order no longer
has the fast copy store one key into another key's slot.
A program with a dynamic import() no longer gets a
globalThis.AsyncLocalStorage that node does not have. The entry seeds that
global only for Next.js programs, and every module loaded by import() used
to count as a Next.js module. Such a module now gets its path initializer
registered lazily, like an on-demand CommonJS module, so startup also skips
one registry insert per deferred module.
Array.prototype.splice on a frozen array (direct or through
Reflect.apply/.call) moved, inserted and removed elements instead of
throwing. A frozen array's elements and length are non-writable, so every
splice, splice(0, 0) included, now throws a TypeError before the array is
touched.
A builtin method captured before the program replaced it
(const origPush = Array.prototype.push; Array.prototype.push = function (...xs) { origPush.call(log, ...); ... })
recursed until the stack overflowed when the replacement called it through
.call/.apply: the compiler rewrote origPush.call(r, x) to r.push(x),
which reads push by name again and so reaches the replacement. That rewrite
no longer applies once the program writes that method name (or call/apply)
onto a builtin prototype; the reflective call runs the value it names.
obj.toLocaleString() on an ordinary object whose prototype chain defines
toLocaleString (a function constructor's prototype, a replaced or
Object.create prototype) printed [object Object]: the by-name dispatcher
answered Object.prototype.toLocaleString without reading the receiver's
chain. It now reads the method off the chain like valueOf and
hasOwnProperty already do; Object.prototype's own method still answers
when nothing earlier on the chain defines one.
Fixes the CI reds left after #13834: perry-runtime's integration tests link the test-only layout descriptors; the navigator global births its getter, so a program that reads only navigator compiles (gap test abortsignal/navigator now passes); on Windows exactly one object per archive holds each runtime layout's weak external (MSVC LNK1227). The realm link-graph gate and the realm-diff gate run green again: the gate witness goes through the runtime, the global cell's layout is in every program's R, the restricted realm dumper imports only modules in R, the realm-diff references are built from the same dumper source as the check, a new witness compares namespace imports (node:events, node:fs, node:path/posix), and each gate run works in its own temporary directory. C dependencies compile with a fixed SOURCE_DATE_EPOCH, so runtime archives no longer embed their build time (it moved linked strings and showed as +12 Ir in startup A/Bs).
A Uint8Array argument now stores through the typed-array spec ABI instead of
the guarded byte path (#13777 landed the read half). a[i] = v on a TaPtr
parameter takes the same proven-view checked store IndexSet takes: a bounds
compare against the entry-hoisted length and one store i8, with no per-store
receiver guard and no js_uint8array_set slow arm.
ToUint8 needs no new conversion gate: that tier admits an integer element only
for a proven exact-i32 value, whose truncated low byte is ToUint8, so a value
that is not proven integral (v + i from a Number parameter, NaN, a
string) keeps the guarded store, and a store past the end stays the silent
no-op the spec requires.
Fix missing-argument validation for Worker, MessagePort and BroadcastChannel postMessage while preserving explicit undefined. Use existing zero-copy native arguments and ordinary Worker method dispatch; rejected calls enqueue nothing and own method replacements/getters are respected.
The collect_modules glob tests run program-wide optional-feature selection after collecting, as the compile pipeline does since closed CommonJS feature selection moved it out of per-module collection; the aliased fs/promises glob test passes again and the unrelated-glob test checks a selection that actually ran.
A literal require("pkg") of a compiled CommonJS module inside an ES-module-shaped file (one the CommonJS wrapper does not rewrite) is now the same compile-time edge the wrapper writes: the target initializes on the first call and the call returns its exports. Before, the call stayed a runtime-loader lookup while the program was marked CommonJS-closed, so nothing was published for the loader and the require threw MODULE_NOT_FOUND. The program stays closed; only a computed require(name) opens it.
A CommonJS module with a class no longer evaluates a module it requires only inside an if (or a function) before its own first line. The wrapper evaluates ahead of hoisted classes only the targets of aliases it surfaces at module scope, the only ones a class can read; a conditional require loads when its call runs, as in node.
The parse_ir_text leak test measures its resident-set growth in a separate process, so parallel tests no longer make it fail.
A class declared at module scope that extends a class value returned by a factory (const C = make(1); class Child extends C {}) is constructed again instead of throwing ReferenceError: Child is not defined.
A native handle id (an http request or response, a WebSocket, a database
statement, any perry-ffi payload) is reissued only after a full collection
proves that no JavaScript value and no WeakMap entry still names it. Before,
an id was recycled at the next event-loop tick, so on a keep-alive connection
a new request could be === an old request the program still held, and a
WeakMap keyed by the old request answered for the new one. The collection that
releases an id also deletes every WeakMap entry keyed by it, and a boxed
common-registry handle used as a WeakMap key is no longer dropped while its
handle is still alive. A WebSocket connect promise resolves with a handle
instead of a plain number id.
Tests: the worker static-lane test in constfn_method_lanes.rs reassigned
o.m to an arrow literal. Since FVA, the facts prove that m holds exactly
those two bodies, and the proven field call lowers the site before the method
site, so no static lane was emitted and the test failed. The test now stores a
value the facts cannot name a body for (globalThis.noSuch ?? arrow), so it
covers the static lane in a worker and after the reassignment again. The
compiler is unchanged.
util.inspect and console.log lay out arrays with Node groupArrayElements, for top-level arrays and arrays nested in objects.
fs, dgram and child_process error codes for every errno come from the libuv errno map (ENXIO was reported as EIO).
Fix Worker-local process exit: process.exit() now stops only the calling Worker, natural completion honors its process.exitCode, and Worker exit listeners can update the final code. Reuse the existing controlled-stop handoff; external termination remains code1 without invoking those listeners.
A local declaration that redeclares an import binding is now a SyntaxError (Identifier already declared), as in Node.
Array.from(dataView) answers [] and spread or for-of over a DataView or ArrayBuffer throws not iterable, as in Node.
Compiling a program with native addons no longer needs rustc on PATH: the host target triple is the compiler build target.
process.report.getReport().header carries glibcVersionRuntime and glibcVersionCompiler on glibc Linux, as Node does, so loaders that probe them no longer take the musl build.
fs operations accept file descriptors 3 and above that the process inherited across exec (EBADF before).
A static method called on a class declared inside a function that runs more than once now sees the evaluation it was called on: D.read() in the second call of g read the first call's captured variables, because the guard-free static call bound this to the first evaluation's class object.
Test-only: stale assertions on the deleted method-site counters (class_read_primes, primes_*, generic_mru_hit, ...) are dropped or replaced by IR witnesses, and an order-dependent perry-runtime async-resource unit test installs the node-api host itself.
Fix Worker.terminate() completion: concurrent calls now return distinct promises that all resolve with the worker's exit code, and calls after exit resolve to undefined. Keep every pending completion in the existing owner-scoped GC roots and settle it after the exit event.
A createRequire call (or a require literal) of a package Perry implements
natively, such as node-pty, now gets the native module, the same one a static
import gets. It used to pull the package's npm files (and the Node-API addon
they load) into the program when the package was installed in node_modules.
A perry.nativeAddons entry for such a package has no effect and prints a
compile note; list the package in perry.compilePackages to host its addon.
The static require transform also recognises import * as NodeModule from "node:module" with NodeModule.createRequire(...) and an immediately called
createRequire(import.meta.url)("pkg").
A CommonJS module that a createRequire require loads first no longer runs its body twice, and require.cache[path] holds its record: the program's require.cache is Module._cache from program start (the CommonJS support module initializes before every other module), so a createRequire function made before the first CommonJS load holds the same cache object, as in node.
Test only: the class-capture environment test now expects a class declaration that can run more than once to read a guarded environment, which is what the compiler has emitted since decision 64 c′. No compiler change.
Fixed
- Notify the creating process about new Workers through its public nextTick scheduler, retaining the returned Worker and honoring late process.emit lookup. Preserve the anonymous nonconstructible callback contract and declare the process realm dependency.
process.exit() no longer tears the Node-API environment down. Like Node's
reallyExit, it runs the exit event and the process.finalization exit
callbacks and ends the process; environment cleanup hooks and thread-safe
function, async work and object finalizers run only at the natural end of the
event loop and when a worker exits. An addon whose thread-safe function
finalizer joins a thread that never ends no longer hangs process.exit().
Every perry build compiles the Rust standard library from source without its
optional backtrace support (.cargo/config.toml [unstable] build-std,
rust-src in rust-toolchain.toml). Compared with rustup's prebuilt std,
which is built with the features backtrace, panic-unwind and
compiler-builtins-c, this std is built with none of them: no DWARF
symbolizer (backtrace), no unwinding panic runtime (every perry build is
panic=abort; panic-unwind was never linked), and compiler_builtins' Rust
intrinsics without compiler-rt's C copies (compiler-builtins-c needs the
compiler-rt sources, which rust-src does not ship). std has no other
feature that the prebuilt build enables. The prebuilt std's default panic
hook linked its symbolizer (gimli, addr2line, miniz_oxide, rustc-demangle)
into every program: a hello world loses 170 KB of .text and 191 KB of
stripped file on Linux x86-64, every runtime archive about 10-20 MB of std
debug info. A Rust panic in the runtime prints the same message, location
and exit status; RUST_BACKTRACE no longer prints frames. Tests build with
panic=abort (panic-abort-tests), as every shipped build already did.
scripts/check_std_symbolizer.sh fails CI and release packaging when an
archive carries std's symbolizer again. Native-library crates a project
builds itself are compiled by the project's own cargo and std, as before.
Perry programs link again with GNU ld older than 2.36 (binutils 2.35, the linker on glibc 2.31 systems). Every perry_realm input section is now link-ordered to the global that owns it, so the old linker no longer rejects a mix of ordered and unordered sections.
Programs no longer abort with "runtime layout X is born, but the program's
realm does not reach it" after #13783. Layout probes (is this object or
prototype on layout X?) compare X's fixed word instead of naming its
descriptor; the runtime paths that birth a layout from a builtin the
program does reach now declare it (http/http2 server close, fetch body
reads, http2 compat responses); createRequire(...) results and
process.getBuiltinModule(...), also through globalThis.process, load
modules for realm reach like require. Runtime-internal completions (zlib
callbacks, fs operations and stream turns, AbortSignal.timeout, the http
request timeout, deferred child_process/cluster/ipc/messaging emits) no
longer create a JS Timeout/Immediate handle: they keep their loop phase
and order and report the async_hooks provider type node reports for each
callback
(ZLIB, FSREQCALLBACK, FSREQPROMISE, Timeout, PIPEWRAP, PROCESSWRAP,
TickObject, MESSAGEPORT, Test; Immediate for PerformanceObserver
dispatch). A pending AbortSignal.timeout(ms) no longer keeps the process
alive, as in node.
http IncomingMessage now supports paused-mode reading: 'readable' is emitted on a later turn, 'end' follows once the body is read, and read() draining the body schedules 'readable' and 'end', so readers that pull a request body complete.
stream/promises pipeline() now treats a trailing options object ({ signal, end }) as options instead of a stage, so a response piped with options is delivered.
globalThis.crypto methods (getRandomValues, randomUUID, subtle) now work before any node:crypto import has run.
URLSearchParams.prototype[Symbol.iterator] is now the entries function, and entries(), keys() and values() return iterators.
Array.prototype.slice called on a typed array, Buffer or DataView now returns a plain Array of the element values.
worker_threads.markAsUncloneable/markAsUntransferable/isMarkedAsUntransferable and perf_hooks.monitorEventLoopDelay/createHistogram now work through the module namespace object.
fs.read now accepts the (fd, options, cb), (fd, buffer, cb) and (fd, cb) callback forms through the namespace dispatch, with bigint positions.
'key in C' and Reflect.has(C, key) for a class extending one evaluation of a class-expression parent now answer from that parent object instead of the declaration's shared dynamic statics.
require() now resolves a package's exports conditions in the package's own key order (node, require, default) instead of a sorted order.
A CommonJS export named like a runtime global (exports.undefined, NaN, Infinity, and any other global) no longer becomes a module-scope binding that shadows the global, which made every read of it a dead-zone read at module init.
JSON.parse SyntaxErrors now carry V8's messages (Expected ',' or ']' after array element in JSON at position N (line L column C), Unexpected token ..., with V8's source context), and JSON.rawJSON returns a frozen object and checks the JSON number grammar.
A class evaluation's prototype now gets its symbol-keyed instance methods (a method keyed by [Symbol.iterator] etc. on a class created inside a function).
new Name() outside the scope of a nested class with that name now resolves the global of that name instead of the nested class.
An object literal key that is a lone-surrogate string now keeps its value and definition position instead of being lowered to a different key.
A plain function created in a class body now keeps that class evaluation's private environment, so private names resolve inside it.
The builtin-prototype alias fold (x.call(...) on an alias of a builtin prototype method) is now keyed on the resolved binding, so a same-named local in another scope no longer rewrites to a call with an undefined this.
A symbol is known by its GC header, not by a by-name fallback: persistent symbols live in one process symbol space, globalThis is an ordinary layout record whose builtin keys are image atoms, and the by-name symbol fallback (req_handle_symbol_fallback, SMALL_HANDLE_SYMBOL_OWNER_EVER, the is_persistent_symbol scans and the TEXT-entry exception) is removed. Symbol-keyed reads and writes use the layout slot like any other key, and a globalThis read of a builtin name is answered from the global object static layout. Atom registration no longer probes the builtin global index and reuses the probe vacant slot on a miss.
Stack trace symbolization now builds its ordered function-name index once, on first use, instead of scanning per frame.
A bound function's name now joins the target name's code units (names with astral characters are kept intact).
Generic function calls that appear only inside a native method call's receiver or arguments are now instantiated by monomorphization.
new C() and x instanceof C inside a class expression that refers to its own name now use that class evaluation, not the shared template.
The arguments object is now synthesized in for-loop init clauses, object-literal accessors, and parameter defaults of function expressions and nested function declarations, instead of failing with ReferenceError: arguments is not defined.
A process linking several Perry images (a main program plus precompiled worker programs) now runs every image's feature installer instead of only the last registered one, so features only some images use are no longer dropped.
Reading a static private member through the class object from a nested function (a static block or a closure in a static method) now has the declaring class's brand instead of throwing a TypeError.
A Proxy deleteProperty trap now receives a property key for a numeric index (delete proxy[0]), as the get and set traps do.
An async generator's yield* now pulls the delegated iterator through await states instead of an inline await, so the second next() of a delegating async generator settles under an event loop.
closure_local_inline no longer deletes a closure binding that still has a call with a different argument count; the call used to run against an unbound local.
The inline Array push and pop fast paths no longer load the element-store probe through a select-guarded address; each load now sits behind the branch that proves its address, which fixes a read past the end of a small array allocated at the end of a page.
The in-process LLVM IR parse now disposes the copy of the IR text it parsed instead of leaking it, which removes a leak of about 40 MB per codegen unit on large programs.
A stack-map re-arm by a newly initialized image no longer makes a collection running on another thread abort in the root scan; the published-index flag stays set and the next build is owed instead.
A GC trigger check that an allocation reaches inside a GcSuppressScope is no longer dropped: it is owed and runs when the outermost scope closes. A loop of small Maps whose arena block boundaries all fell inside Map capacity growth ran without minors and grew the nursery without bound (2M Maps with array keys: peak RSS 171 MB, now 26 MB, with 2% fewer instructions).
Fixed
- Read native namespace own accessors through their getter instead of exposing internal AccessorPair storage. Preserve receiver and exception identity, getterless undefined values, data override ordering and reflection without getter invocation.
super() into a node:zlib codec constructor (class C extends Gzip, Deflate, Gunzip, ...) now runs the codec's in-place constructor on the subclass instance, whether the class writes a constructor or not. It used to construct a fresh codec that replaced this, so the instance lost the subclass prototype and its _transform/_flush overrides.
class X extends AssertionError (from assert or assert/strict) goes through the same arm: the runtime-owned AssertionError init is one match arm there, and the assert-specific arm that sat beside it is gone.
The earlier relink of a fresh native-export object to newTarget.prototype stays as the fallback for exports that have no in-place form yet (net, domain, sqlite, StringDecoder, Uint8Array); an export that initializes in place returns the receiver and the relink does nothing.
wpfacts: a prototype's chain fact reaches an object only through a key the object does not own definitely. A computed-key defineProperty on Object.prototype is an accessor under any key there; a template meets it only through a miss site (a read, call or set of a key it may not own), in whole-program and per-unit mode alike, and another unit's miss sites and defineProperty calls on a parameter run on the caller's argument.
Fix the unused variable: slots warning in the arity-0 closure dispatch arms so the -D warnings job is green again.
Twelve cold and warm GC walkers (remembered-set rebuilds, dirty-card scans, the leaf service, image cells, old-evacuation rewrite, malloc-mark remembering, the promoted-cohort check) now share one out-of-line copy of the GC slot-layout decoder instead of each inlining its own, which makes every linked binary about 200 KB smaller. The minor-GC copying drain, its preflight and the full-mark trace keep their inlined decoder. Startup instruction counts are unchanged.
The realm image has one Set index sizing, shared by the emitter and the runtime threshold crossing, and static array elements have one home in the realm manifest (array storage). A realm-diff gate script compares the realm dumper with checked-in references, in full mode and with a test-only restricted realm reach.
WeakMap, WeakSet, Map, Object.is and FinalizationRegistry.unregister find an
array under both addresses growth gives it: the forwarding stub a table or an
alias kept, and the new head the growing binding holds. Identity is the
forwarding-resolved head, as === defines it. A lookup that hits on bits pays
nothing for this; only a miss of an array key asks: a small Map scans for stub
keys only when an array grew since it last proved it holds none, a large Map
and the weak tables find the array by the relocation-stable identity token Set
already uses.
A collected FinalizationRegistry cell stays registered until its cleanup job
runs: gc() no longer delivers callbacks at its own tail, so
gc(); registry.unregister(token) returns true and cancels the callback, as
in node.
- Module top-level
thisisundefined(#13558). A module's[[ThisValue]]starts asundefined(ECMA-262 §16.2.1.6.4), but Perry lowered every top-levelthisto the CommonJSmodule.exportsstand-in, soconsole.log(this === undefined)in amain.mjsprintedfalsewhere Node printstrue(test262language/module-code/eval-this.js). Lowering now consults the module goal it already derived for strictness — ESM format/package context (.mjs,.mts, nearestpackage.json"type": "module") or in-sourceimport/exportdeclarations — and emitsundefinedfor that top level, including through arrow functions and a foldedeval('this'). A CommonJS entry keeps themodule.exportsstand-in object, thePERRY_GLOBAL_SCRIPT_THISopt-in still gives a CommonJS file the Test262 script'sthis === globalThis(a module staysundefinedunder it), and module code being strict,this.x = 1at module top level now throws. Module-goal detection for ambiguous sources is unchanged: a file whose only module syntax isimport.metaor a top-levelawaitis still classified CommonJS, exactly as it was for strictness. Regression:test-files/test_gap_13558_module_top_level_this.ts(thenode --experimental-strip-typesoracle),crates/perry/tests/issue_13558_module_top_level_this.rs(the module-format andPERRY_GLOBAL_SCRIPT_THISvariants a single.tsfixture cannot express, plus the preserved CommonJS stand-in). Regression:test-files/test_gap_13558_module_top_level_this.ts(thenode --experimental-strip-typesoracle),crates/perry/tests/issue_13558_module_top_level_this.rs(the module-format andPERRY_GLOBAL_SCRIPT_THISvariants a single.tsfixture cannot express, plus the preserved CommonJS stand-in).
Fixed
- Declare Worker ownership of the shared event-loop-utilization and CPU-usage result layouts, preventing sparse-realm birth aborts without retaining unrelated perf-hooks or process records.
Regression test for #13485: an own property written on a built-in iterator
(array, Map, Set, string, typed-array, matchAll and iterator helpers) no
longer clobbers its internal state, so next() keeps advancing and the
property reads back like in Node. Already fixed on main; this pins it.
Fixed
- Admit only catalogued optional runtime-layout symbols when linking Darwin executables, so sparse compiler-owned descriptors can remain absent weak references without relaxing unrelated undefined-symbol errors. Preserve driver, direct-linker and Swift argument forms.
Cold sorts in perry-runtime (GC diagnostics, stack-map verification, layout and shape census, directory listing, locale, resolver order) share one cold_sort instantiation instead of one standard-library sort per call site, which shrinks every linked binary. Array.prototype.sort, typed-array sorts and property-enumeration order stay on the standard sort.
The compiler now emits the descriptor and key list of every runtime object
layout the program's realm reaches (R), and the runtime no longer carries a
catalog of its own. Each layout in R is a standalone descriptor under its
direct symbol perry_runtime_layout_<NAME>, with its fixed native layout word,
its keys, index, prototype record, gc plan and (for string-keyed layouts) its
link-time key list in perry_realm. Layouts with a symbol key name the
runtime's own symbol atoms and build their key list at first birth until the
realm owns its symbols. Nothing is emitted for a layout outside R.
Every runtime birth reaches its descriptor through a weak reference to that
symbol and aborts with the layout's name when the program's realm does not
reach it. The positional descriptor lookup over the perry_layouts section,
wasm's runtime layout inventory, the const image key-list machinery and the
unused CJS_MODULE_RECORD layout are removed; its ABI row stays reserved so
no other native word moves. Runtime tests, and test crates that link the
runtime without a compiled program (feature test-layout-descriptors, enabled
only from dev-dependencies), expand the same declarations into test-only
definitions.
A module now keeps one definition of each content-named coalesced global
(image strings and atoms), so independent emitters of the entry module can
name the same key. Tests check every emitted descriptor cell and key entry
against the declared facts and the compiler's word rule against the runtime's
word table; scripts/realm_wgraph_gate.py proves on a linked hello that only
R's descriptors are present, each tied to its own key list, and that no
test-only definition is linked.
wpfacts tests: the CommonJS class fixture test follows run-once template classes
(#13755). A class of a factory that runs once is the class itself, so the whole
program no longer solves the methods of a class that is never constructed, and
per unit its classes stay contracts. The test asserts both, under its new name
a_class_of_a_run_once_factory_is_a_contract.
Fixed
- Preserve compiled file and eval Worker entries during dynamic module pruning and whole-program activation, including dynamic imports owned by the Worker. Reuse one module-root authority without disabling dead-import elimination.
changelog.d/ is a pending queue again, and a release's fragments are archived under the version that shipped them instead of being deleted.
Perry had 4,211 fragments in changelog.d/ (5.7 MB of text) because the documented fold — ./scripts/cut_release_notes.sh --fold vX.Y.Z — was a manual post-release step that never once ran: git log --grep='fold changesets' returned 0 commits. Every release body was therefore the whole accumulated backlog, and from v0.5.1520 on it exceeded GitHub's 125,000-character limit and was truncated, pointing at a tree that carried the same backlog.
- Released fragments move to
changelog/vX.Y.Z/, with amanifest.jsonrecording the version, the fold source and the entry count. Nothing is deleted:git log --follow changelog/vX.Y.Z/<fragment>still reaches the PR that wrote it. --notes-only --version vX.Y.Zreads that folder when it exists and the pending queue otherwise, so a release's notes are reproducible and the fold may land before the tag, on the release branch, as part of the reviewed commit.release-packages.ymlandscripts/publish/release.mtspass the version.- The fold archives what the tag shipped (
git show), not what the worktree holds, and warns when a fragment was edited after the release. scripts/check_changelog_archive.pyis a new requiredlintgate: it fails on a malformed archive, on a fragment that is both archived and pending, and on a pending queue over 6,000 fragments — the signature of a fold that stopped happening. Both it andscripts/cut_release_notes.shcarry a--self-test, also run byscripts/pre-tag-check.sh.
The backlog is archived, every file byte-identical to the tag it shipped in: 1,509 entries in changelog/v0.5.1520/ and 2,594 more in changelog/v0.5.1664/. A fragment is archived once, under the first release that shipped it, so the 1,496 entries the later tag still carried were skipped rather than duplicated. The pending queue drops from 4,211 fragments to the 122 merged since v0.5.1664.
The v8 serializer and deserializer now expose their operations through the canonical constructor prototypes. They were reachable only through the native-module vtable, so v8.Serializer.prototype.writeHeader did not exist, typeof s.writeHeader was undefined, and Object.getPrototypeOf(s) === v8.Serializer.prototype was false. node_v8/serde_prototypes.rs now owns the methods — one shim per implemented operation (writeHeader, writeValue, releaseBuffer, writeUint32, writeUint64, writeDouble, writeRawBytes and their read* counterparts), each validating its receiver through the module brand and throwing Illegal invocation otherwise — and attach installs them on a constructor's prototype, with the Default* subclasses linking to their parent's prototype instead of duplicating the table. build_v8_instance links the receiver's [[Prototype]] to that same prototype, so ordinary lookup finds them. The node-suite case test_gap_v8_serde_prototype_methods.ts now matches Node byte for byte (method and prototype identity, method lengths, the deserializer's prototype, and the brand TypeError), and a runtime unit test pins that attach installs on the prototype of the constructor it is handed and that the object the v8 namespace exposes is that same constructor. This is the prototypes half of the work: the object-construction rooting rework and the ABI change that enables Reflect-based dynamic aliases are not carried.
Drop an unneeded mut on a read-only closure in the wpfacts function-own-property summary, so the -D warnings build passes again.
Pass a Uint8Array argument through the typed-array spec ABI (TaPtr) like
the other numeric kinds (#13774). new Uint8Array(n) lowers to
Uint8ArrayNew, which the call-site pre-pass did not recognize, so the callee
kept the boxed ABI and s += a[i] went through the dynamic string-or-number
add. The pre-pass now proves Uint8ArrayNew bindings under the same rules as
TypedArrayNew, and a Uint8ArrayGet off a TaPtr parameter takes the same
checked load as IndexGet: a bounds compare against the hoisted length and a
byte load, with no per-read receiver guard.
A += 1 loop summing a 2^20-element Uint8Array 400 times drops from 1354 ms
to 318 ms, level with Uint16Array (335 ms).
Dynamic closure calls that pad missing arguments with undefined
(dispatch_with_arity and the exact arms of dispatch_rest_bundled) now fill
one fixed array once and read constant indices, instead of a bounds-checked
branch per argument in every arm. Behavior is unchanged; the two functions are
much smaller in every linked binary.
A lazy JSON.parse array (an array root of 1 KiB to 16 MiB) now materializes
IN PLACE: the lazy value is born with an ordinary array's header and one
element slot per element, and materialization fills the slots that are still
holes and turns the same object into the ordinary array (obj_type flips from
the lazy kind to Array; the tape is released). One object, one address, so
identity (===, Map/Set/WeakMap keys), every compiled binding and every
element a caller already read stay valid across materialization, and a binding
that saw the lazy value takes the dense fast paths afterwards. The separate
materialized array, its sparse cache and bitmap side blocks, and the
redirect-to-materialized branches in the readers (stringify, clone, filter,
the record, binding repair) are gone. The flip keeps the header facts the
program already set on the lazy value: integrity level, the custom-prototype
bit and the copy age, and states the whole array as its dense prefix
(ek_prefix): a JSON value is never a hole.
The whole-array re-parse fills the slots as a bulk fill: one word copy (a
plain memcpy when nothing was cached yet, holes only otherwise), then one
layout rebuild with the range barrier for an old owner instead of a barriered
per-element store. A verbatim copy of a parser array whose builder proved
every slot a pointer installs that layout directly.
for..of over a lazy array no longer falls back to the iterator protocol on
every pass: the counted array record admits it without forcing it, element
reads go through the lazy array's own read policy (subtrees on demand; a
sequential run of max(64, n/64) reads flips to one whole re-parse), and the
same record continues on the inline array path once that flip happened. A
loop that breaks early keeps the laziness the tape paid for. Binding repair
(js_array_refresh_local_head, the shared read cold arm) never forces a lazy
array. length of a lazy array read through the generic path goes straight
to the array length.
Array.from(array) and [...array] of an ordinary array convert holes to
undefined only past the source's dense prefix: a hole-free array (a parsed
array, one built by push) copies like js_array_clone, and the copy states
itself dense.
A stale tape reader (TapeSource::Lazy) of a flipped value reads an empty
tape: tape_slice/blob_bytes test the object's kind, since the lazy fields
are hole-filled array reserve after the flip.
delete parsed[i] on an unmaterialized lazy array no longer panics: the
delete forces the array in place and leaves a hole, like any ordinary array.
Per-unit facts now publish what each unit stores on a function value's own
properties. A store such as k.parse = k or require.cache = ... often runs
in a different unit from the function's owner, and the other units that held
the function never saw it: a call through marked.parse(...) read nothing and
its arguments did not escape, which made per-unit facts stronger than the
whole program. Every unit now publishes its stores per function name and key,
the driver joins them over all units, and every holder of the function (the
owner included) reads the join. Only unknown code makes the keys unknown.
PERRY_IC_DIAG is retired. Its counters (ic_note, ic_note_prime,
ic_note_hit, js_ic_diag_note_hit) had no caller left in the runtime or in
emitted code, so an armed run wrote an empty report. The instrument, its knob
in the hot-diag lists (runtime, compiler, build-cache test), its wasm ABI row,
its GC root-holder entry and its uses in the miss-read lane scripts are
removed. PERRY_REGEX_DIAG, PERRY_ENUM_DIAG and PERRY_RECEIVER_REPR_DIAG
are unchanged.
The unknown-receiver probe now answers a read that reaches an accessor from
the receiver's shape, at every named site and without a name gate: the pair
in the key's slot is the authority, for the key list, the overflow table and
the first prototype that holds the key. A getter whose compiled body is
exactly return <captured binding> carries that fact on its own
JsFunctionInfo (FN_RETURNS_CAPTURE: capture index and cell offset in the
trusted-clone words, which such a body does not use), so its read is the
binding load the call would execute, live bindings included; a binding still
in its dead zone, and every getter with any other body, is called exactly
once per read by the site's decline edge. A pair without a getter reads
undefined. The decline edge's layout walk now also serves accessors kept in
an overflow table, which previously fell to the by-name read. CommonJS export
getters (tsc/Babel Object.defineProperty(exports, "x", { get }), SWC/TS
_export, webpack __webpack_require__.d) become a probe hit plus a few
loads.
The program-wide accessor-name set (ClassAccessorNames) and the
read_may_reach_getter gate are removed: named read sites own no shape/slot
memo. The CommonJS wrapper no longer declares, in the born exports layout,
a name that only Object.defineProperty writes, so a literal-key accessor
export is claimed in the object's own key list with its attributes instead of
moving the object to its overflow table. No cache, memo or side table is
added.
Tests: runtime probe tests for the binding, dead-zone, no-getter, called
getter and overflow answers; a codegen info-render test; gap tests for CJS
export getters (literal and _export, live binding) and for accessor
semantics (effects called once per read, reassigned and dead-zone bindings,
overflow accessors and setters with the right this, class getters).
A symbol-keyed write on a class (C[S] = v) runs the class own or inherited static set [S] with this set to the class written to, instead of storing a data member that shadows the accessor; a getter-only static symbol accessor stores nothing (a strict write throws). A ClassBody static symbol accessor is now an own accessor property of the class function object.
wpfacts: + with a String operand is a String; a guard on a marker beside its values waits
+ now follows ECMA-262 13.15.3: after ToPrimitive a String operand makes the result a String, and
the numeric kinds of one operand reach the result only once the other operand may be no String
(Cons::Plus per operand, a Guard with the new test NOT_STR). Before, each operand added its own
kinds, so "" + x with a host or any x was String|Number|BigInt and every member read on a String
accumulator (this.input += key, line += word) stayed unproven. Cons::Arith is the ToNumeric
operand only. A Guard on a marker beside its values (a described parameter, a published method's
receiver and their reach markers) no longer admits the test by itself: it waits for the values and
fires if the marker leaves, as arithmetic already did.
s += a[i] over a specialized typed-array parameter keeps the accumulator a plain Number whatever the array's length expression is (#13740). The #8619 read proof was fed only the parameters with an integer-literal length, so new Int32Array(1 << 20) ran the string-or-number add diamond while new Int32Array(1048576) did not, about 4x slower.
Linux links no longer pass -lssl -lcrypto. perry-stdlib bundles its own
OpenSSL and its TLS is rustls, so no system OpenSSL library was ever used;
building a Perry program no longer needs libssl-dev (Debian/Ubuntu) or
openssl-dev / openssl-libs-static (Alpine).
Android and OpenHarmony programs that use a closure link again. These targets
always link the program as a shared object, but codegen treated an
executable compile for them as an ELF executable and addressed thread-locals
local-exec, which ld.lld rejects under -shared
(R_AARCH64_TLSLE_ADD_TPREL_HI12, R_X86_64_TPOFF32 against
PERRY_AGENT_PTRS and PERRY_INLINE_ARENA_STATE). Their emitted code now
reaches the runtime agent block and inline-arena state through the runtime
accessors, and the in-process backend emits emulated TLS for these triples,
the model rustc builds their runtime archives with, so every thread-local
(the runtime's and the program's own) binds through __emutls_v.<name>.
Linux and macOS executables are unchanged. Tests cover the TLS model per
target and the emitted access for both Android and OpenHarmony triples.
JSON.stringify(value, null, 2) printed an empty array that came from
JSON.parse as "" instead of [] (#13709). The parser builds [] with
capacity 0, and the indented printer only accepted arrays with a nonzero
capacity below 10,000. It now checks the GC header type instead. The same
check made an empty parsed array passed as the replacer (JSON.stringify(o, JSON.parse("[]"))) emit every key instead of none; that is fixed too.
Per-unit facts: an own getter of a published object literal is a contract function of its owner, as the literal's methods are. A reader runs it on its stand-in of the literal and gets the result its contract describes instead of an unknown value, so lib.item.kind through another module's getter is proven as in whole-program mode. The layout contract now says per key whether it holds a contract getter, a setter only, or a getter no contract describes (that one keeps the stub). A contract function that another unit runs on a receiver now has that run's result checked by its contract, so a method or getter returning this no longer hides a reader's store into the literal.
globalThis.navigator is now an accessor like Node 26's (get only, enumerable,
configurable). Its getter builds the navigator object on the first read and
keeps it, so LC_ALL and LANG (navigator.language and languages) and the
CPU count (navigator.hardwareConcurrency) are read on first use instead of
while the realm is populated. Programs that never touch navigator no longer
build it at startup.
The shape Region's smallest reservation is the static prefix plus the chunk
space again (256 MiB of address space), not 516 MiB. Only layout-word rows
index the Region; the WeakMap/WeakSet birth layouts are seeded at the rows of
their declared words, which lie in the static prefix, and a compile-time check
now keeps them there. A process under a 2 GiB address-space limit (the
protected-poll reclaim gap test) starts again.
assert.AssertionError is a real constructor export of node:assert and
node:assert/strict (one value for both, also on the CommonJS assert
function next to Assert), as in node: class AssertionError extends Error.
Its prototype inherits Error.prototype and has node's toString
(AssertionError [ERR_ASSERTION]: message), the constructor inherits Error,
and every AssertionError, thrown by an assert helper or constructed, is born
linked to that prototype, so instanceof Error, instanceof AssertionError
and constructor follow the prototype chain instead of a registered class id.
new reaches the constructor through any value of it (new ns.AssertionError(o),
new (require("assert").AssertionError)(o), new Assert()), and a class can
extend it. AssertionError.length is 1 and the strictEqual message starts with node's "Expected values to be strictly equal:". The codegen special cases for new <assert>.AssertionError(...) and new <assert>.Assert(...) are gone.
tests: a broken argument of a closed callee keeps the callee's parameter uses
class C extends zlib.Gzip {} instances get C.prototype: super() into a
native constructor export relinks the object it returns to new.target's
prototype, so new C() instanceof C is true, as in node.
Asking whether an object or a constructor prototype is on a runtime layout
(the node instance-layout probe, EventEmitter.prototype / EventEmitterAsyncResource.prototype
selection, the ServerResponse layout checks) no longer aborts in a program
whose realm does not reach that layout: a probe consults only reached layouts.
Every builtin family answers its realm intrinsic prototype (ECMA-262 Realm
[[Intrinsics]]) rather than the prototype of whatever globalThis.<Name>
currently holds: arrays, typed arrays, every Error kind, Date, Promise,
ArrayBuffer, SharedArrayBuffer, DataView, Map, Set, WeakMap, WeakSet, WeakRef,
FinalizationRegistry, RegExp, the boxed primitives and Iterator. After
globalThis.Date = function F() {}, an existing date is no longer
instanceof Date and Object.getPrototypeOf(date) is still the original
Date.prototype, as in node.
The realm entry also holds each family's original constructor, so inherited
constructor reads (promise.constructor, ta.buffer.constructor, ...),
species defaults and primitive method lookups answer %Promise%, %Array%,
%String.prototype%, ... after the global binding is replaced.
x instanceof F now throws a TypeError when F.prototype is not an object
and x is an object, as OrdinaryHasInstance requires; it returned false.
A function whose prototype was never materialized still answers false.
createHash / createHmac objects are born on the prototype of node's
internal Hash / Hmac constructor, which crypto.Hash / crypto.Hmac
share: h instanceof crypto.Hash is true, h.constructor is the internal
constructor (not the deprecated wrapper), and crypto.Hash.prototype.update
exists before the first createHash, as in node.
A class declared in a function that runs once and captures enclosing bindings
stays the class itself: its captures live in its single class environment, so
no per-evaluation class object is made. A sibling class method naming a later
class (new Eff() from Base) now constructs the same class the function
returns, with that class's prototype and constructor. A shorthand property
{ C } naming a class now reads the class its own scope declared; it used to
read the first class registered under that name when two functions each
declared a class C.
Fix module identity: readline/promises and WebAssembly.Module wrappers
require("readline/promises") now returns the same object as require("readline").promises. A WebAssembly.Module wrapper has no own properties ({...module} copies nothing), and WebAssembly.Module.exports/imports/customSections throw TypeError for a non-Module argument; the brand is the internal wrapper state only.
Facts: a builtin string or RegExp method call returns the primitive its algorithm returns (not a boxed container), RegExp split, exec and match results are exact Arrays of strings and captures read through the real species and exec, a nullish-coalescing result strips null and undefined at generation, a number key reads own number fields only on concrete objects (never on a unit marker), and a computed-key member call on a builtin namespace is an opaque call, and a store to lastIndex on a RegExp lands on its own data property (never on RegExp.prototype), while new RegExp(re) reads only @@match, source and flags of its operand.
An inherited static symbol-keyed accessor runs with the receiver as this: Deep[Symbol.species] on class Deep extends Own {} answers Deep, and super[S] in a static method keeps the calling class. The holder only locates the accessor, as for a string-keyed static accessor.
Intel macOS and Windows x64 builds compile again. Three codegen failures blocked them. (1) On x86_64 Mach-O, a module large enough to split into codegen units failed to assemble with "symbol '…fnname.N' can not be undefined in a subtraction expression": a function's $info record names its display name as a 32-bit name - info offset, and on Apple targets that name was an alias owned by one unit and declared external in the others, which x86_64 Mach-O cannot relocate. The name is now addressed inside the string image, which every unit that needs it defines (as ELF and COFF already did). (2) On Windows, every compile failed on a host whose clang is older than the bundled LLVM (GitHub's windows-2022 image ships clang 20.1.8): the stack-map path printed assembly with LLVM 22 and assembled it with the host clang, which rejected .seh_startepilogue/.seh_endepilogue and COFF .section ...,unique,N. COFF assembly is now assembled in-process by the bundled LLVM (byte-identical to clang 22's output); an external assembler that rejects a directive gets a hint naming PERRY_LLVM_CLANG. (3) In split Windows modules the native IR reader refused the TEB load inttoptr (i64 88 to ptr addrspace(256)) ("cannot materialize inttoptr"); it now reads inttoptr constants and a load whose pointer is a constant expression. The codegen-unit partitioner also now follows quoted symbol names (@"\01section$start$…"), so a later Mach-O unit that names the function-record section bounds declares them instead of failing to parse. (4) On Windows, the first birth of a literal-family object aborted with "literal birth requires a valid literal-section descriptor" (tuichess online mode): the runtime's read-only .perrylit$a/$z bounds landed in a different output section than the writable descriptors, so the span between them was empty. The bounds are now writable, as .perrylay's already were.
A class with private methods or accessors installs its private brand on a new
instance before its fields are defined. The branded shape is now recognised as
the class family layout it came from (a brand changes no slot), so field
definitions fill their layout slots. Before, they were appended after the
empty slots, and a read at the slot the layout names returned undefined.
A declared key (a CommonJS module's require.cache entry or exports name) keeps its birth slot again when every store that adds it runs before any later key can be present: stores that run once while a module evaluates, outside loops, in evaluation order (one body in source order, modules in static import order). A deleted cache entry, which lets the runtime evaluate a module again, ends that order.
codegen: the numbered-metadata counter and loop-ID numbering, and the text reader branch attachments and whole-function entry points, move to sibling modules (file-size gate; no behavior change)
hir: the shared expression walkers (walk_expr_children, walk_expr_children_mut) grow the stack like expression lowering does, so every recursive analysis over a deeply nested closure body (a 65-arm conditional chain: closure capture and assigned-local collection) runs on the lowering thread without overflowing it.
codegen: a finite numeric literal compared with another statically numeric operand by the bare fcmp is used as its f64 constant and records no JS-value materialization; a literal that can reach a runtime compare helper still records its runtime_api boxing, and materialize_js_value records every f64 again (the dynamic path materializes literal operands at its runtime boundary).
compile: PERRY_BYTE_VIEW_LOOP (the byte-view loop version kill switch) is a build-cache input: it changes codegen, so the object and completed-build caches key on it.
hir/transform: A label on a module-level loop no longer changes its lowering. Module-level for/while/do…while bodies lower like loops in a function body (the block is the loop body list, no lexical if (true) wrapper; for gets the same receiver hoist), a labelled module-level loop lowers through that same module path and only gains the label, and the Math.imul polyfill rewrite visits every statement kind (labelled loops, switch cases and try blocks kept the polyfill call).
codegen: A loop label that no break/continue targets only names the loop's region: every loop tier lowers the loop as without it (the label adds only the latch loop IDs).
codegen: numbered metadata ids come from one module counter. A buffer alias scope owns three consecutive ids (scope node, scope list, noalias list) and the alias domain and loop IDs follow from the same counter, so a module with more than 100 buffer alias scopes no longer defines !201 twice ("Metadata id is already used"). Native construction (the default for large split modules) builds the module's numbered !N nodes from the same skeleton lines the text path parses: !alias.scope/!noalias references and the loop/branch metadata of typed branches are attached instead of silently dropped, and an unknown attachment form is an error.
node:sqlite follows node 26.12: Database and Statement are the classes and DatabaseSync / StatementSync are the same exports, the statement iterator is StatementIterator, statements gain close(), [Symbol.dispose](), stat(counter) and resetStats(), db.createModule() validates its arguments like node (virtual tables themselves are not supported and a well-formed call throws ERR_METHOD_NOT_IMPLEMENTED), and any use of the database or its statements from inside an authorizer callback throws ERR_INVALID_STATE.
An arrow created in a derived constructor before super() reads this
through the binding cell that super() fills. Reading it before super()
returns now throws the same ReferenceError as any other this access
before super(), instead of evaluating to undefined.
Split the oversized Rust sources the File size limit lint flagged (walker/expr_clone,
perry-wpfacts model, solve, summary, builtins, gen/export, gen_rest, lib/contracts,
units/driver, and compile/resolve) into submodules by responsibility. Code is
moved, not changed; the shallow-copy and expr_rest matches are now chains of
group functions.
Tests: a perry test that panics while it owns the process environment no longer fails every later environment test with a poisoned lock; the lock restores the environment it handed out while the test unwinds.
Tests: the #7358 release ext build test reads the core package set from the workflow core_args variable, which the one-invocation build now passes, instead of the literal mbx line.
Tests: the GC diagnostics install test checks its no-knob case in its own test process, as the deserializer feature test does: other tests in the binary leave the process-global dynamic-code reach set, which installs the diagnostics for any program.
Route the util.parseArgs and util.styleText heap-type probes and the born-null
prototype test through the canonical address classifier, so a native handle in
the handle band is rejected before any GC header read. The instanceof
Function.prototype probe now tests for a reference rather than a raw heap tag.
better-sqlite3 Statement#columns(), get() and all() now root every
converted column value, row record and the result array across the
allocations that build them, so a collection triggered while a row is being
built can no longer leave a stale pointer in the result. The indexed Map
string-key match reads its payload through the string accessor.
Update printpdf to 0.12 (lopdf 0.44), fixing RUSTSEC-2026-0187 in perry-ext-pdf.
Ship the native-extension archives (libperry_ext_*.a, perry_ext_*.lib) for the Linux musl and Windows hosts in every channel: the musl tarballs and the Windows zips now carry them, and @perryts/perry-ext-<triple> packages are published (and auto-fetched) for x86_64-unknown-linux-musl, aarch64-unknown-linux-musl, x86_64-pc-windows-msvc and aarch64-pc-windows-msvc. A program importing node:http, ws, net, zlib and the other well-known modules now links on those hosts.
Make the vendored MSVC setup action load as CommonJS and restore its @actions/core dependency closure, fixing the windows-arm64-build "require is not defined in ES module scope" failure.
ReadonlySet.has and ReadonlyMap.get no longer take their branded fast path on a receiver they cannot prove is the builtin. The GC header says what the allocation is, not that the receiver still behaves like it: an own has/get accessor lives in the object's metadata and was stepped over entirely, a custom [[Prototype]] method was invisible, and a subclass brand was ignored — so Object.defineProperty(set, "has", {get}) never ran and Object.setPrototypeOf(map, {get(){…}}) was never consulted. The fast path now also requires the metadata to be absent and the family's intrinsic prototype to be pristine, so all three fall through to ordinary JavaScript lookup. The structural fallback had the mirror-image bug — it called the method by name in one step, re-reading the property and losing the value an accessor had selected — and now performs the Get once and calls the value it obtained, refreshing both operands afterwards because an accessor may collect. Two integration tests pin the observable semantics end to end: against the previous runtime they report own one 0 / prototype undefined, and with the change own one 1 / prototype override.
String.prototype.split and matchAll with a RegExp subclass separator construct the splitter and the matcher through the subclass: the constructor's @@species is read as an observable Get that walks the constructor's prototype chain. A function's symbol bag answers a RegExp protocol symbol read only for its own keys, and a proxy constructor's trap runs. Previously an absence over the bag counted as the function's, so the intrinsic RegExp and its builtin exec were used and a subclass exec never ran for split or matchAll.
A class field defined after the instance may already have been observed (an earlier initializer or a parent constructor handed out this) is no longer treated as present from birth, and the dispatch tower runs its proven-this clone unchecked only when the family holds the birth layout. Programs that printed NaN where node prints undefined now match node.
Compiler switch PERRY_VERIFY_FACTS=1 (a compile-time switch, part of the cache key) makes
codegen emit a runtime check at every proof it consumes: elided field reads,
writes and updates (class, key at slot, value kind), proven value and field
calls, the class switch of proven method dispatch, proven static calls, the
dispatch tower's layout proof, and the number test an indexed-owner loop
drops. A miss aborts with the module, line and column of the consumer, the
proof kind, what was expected and what the program held. Off, nothing is
emitted. The facts dump also names compiler-generated CommonJS modules
relative to the cache dir, so two dumps of one program from two cache dirs are
byte-identical.
Programs link again with GNU ld before 2.36, such as binutils 2.35 on Debian
bullseye, the linker of the glibc 2.31 release floor. Since DCE layer 0
(#13636) the link failed there with "perry_fnrec has both ordered [...] and
unordered [...] sections" / "final link failed: bad value": each body's
metadata record is tied to its body (SHF_LINK_ORDER), but the image's zero
record (the sentinel that makes the section exist when every record is
dropped) was a plain section of the same name, and binutils before 2.36
rejects an output section that mixes the two. On ELF the sentinel is now tied
too, to the bounds descriptor, the one global that names it, so every
perry_fnrec input is link-ordered and the link keeps exactly one sentinel
per image on every ELF linker. GNU ld 2.46 and lld still drop dead bodies
exactly as before; GNU ld 2.35 ignores -z start-stop-gc and keeps the JS
bodies, which is larger but correct. Mach-O, COFF and wasm are unchanged. A test checks that
every perry_fnrec section in an emitted ELF object is SHF_LINK_ORDER.
Combine guarded inline growth control, exact retained shape-body sharing, and unused fixed-record ABI dead stripping; clarify the local inlining bound, install diagnostic hooks in the GC smoke fixture, and honor Cargo's configured target directory when regenerating API docs.
- Reuse the validated arena header when acquiring an array iterator record,
avoiding a repeated ownership lookup while preserving forwarding, sparse
storage, and the existing non-arena validation path.
Align AArch64 functions containing final GC or worker-interrupt loop polls to at least 64 bytes. This reduces sensitivity of hot loop execution to code-layout changes while preserving stronger existing alignment and leaving other targets and functions without recognized polls unchanged. The policy reuses existing direct or statepoint-wrapped polls, adds no analysis state or runtime instructions, and keeps exact shape-body aliases intact.
A function object's own data keys are now a key-add on its shape.
CreateDataProperty (class fields on callable class instances) and [[Set]]
(Object.assign onto functions, computed-key stores) on a function answer from
the layouts: the bag's own key position, the function's chain from the link its
shape names, the bag's published key-add edge, and the closure's next keyed
Function shape from its own ShapeId's key-add edge (validated per use). The
generic named-Set tail, the per-hop getPrototypeOf chain walk and the
re-definition of default attributes are gone from these paths. A function's
bag is born once (a bound function's name was defined from inside its own
bag birth) and, when born by a program put, with inline room. The layout probe
reads a bound function through its bag and shape link. Fixed: a constructor's
prototype materialized after other own keys (a read, Object.freeze, an
assignment) enumerated last instead of after length and name.
The Android runtime builds again. The GC native-stack scan (#13582) asks for the thread's stack top on Linux and Android, but native_stack::stack_top was compiled for Linux only, so perry-runtime failed to build for aarch64-linux-android and x86_64-linux-android (the nightly's Android cross legs). It is now built wherever stack_bounds is.
for-of over an array lowers its body once (#13645). The counted iterator
record of #13655 split into an array arm and a protocol arm, and each arm
carried its own copy of the user body (Supabase code +5.5 MB, +2.65%, at the
same function count). The loop now has one body and two heads: the entry
verdict is tested once, the indexed head steps like i < a.length; a[i] with
the source's declared element type, the protocol head runs the record's Step,
and both enter the same body with the step value bound; the record's update
and completion state are shared. The loop goes through the region tier like
an indexed loop, so a per-iteration receiver still gets its guarded split.
- Preserve exported fixed-record ABI entry points for native callers without forcing their code into every executable. Runtime record/image helpers now use compiler retention like the other ABI anchors; registered layout and atom sections keep their existing linker retention. This removes an artificial retention edge and does not change runtime dispatch or add caches.
Bound repeated and transitive expansion of raised-budget receiver/closed-target early inlines using lowered body cost and direct call counts, while preserving independent numeric/index ABI admission and compact-leaf inlining. This reduces generated code and compiler work without new runtime lookup state.
Fixed
- An npm-installed perry can link programs that import node:http, ws, net, zlib, sharp and the other well-known native modules. Their extension archives never shipped in the npm host packages; they now ship in one package per host,
@perryts/perry-ext-<triple>(macOS and Linux glibc), which perry downloads from the GitHub release, verifies and installs into~/.perry/extthe first time a compile needs one (once per perry version; no network access otherwise).PERRY_NO_AUTO_FETCH=1turns the download off and the compile then namesnpm install -g @perryts/perry-ext-<triple>@<version>andperry target add --ext. An ext package npm-installed beside perry is found first.
Changed
- The triples that have a target or ext package are one list,
npm/packaged-triples.json, used by the publish scripts and compiled into perry. A cross target without a package (for example--target windowsfrom Linux) keeps the generic missing-library error instead of naming a package that does not exist.perry target addrefuses such a triple up front.PERRY_RELEASE_MIRRORpointsperry target addand the ext download at a directory or https mirror of the release assets.
Share exact retained ordinary and $pshape method implementations in the LLVM backend after useful inlining, preserving callable entries, native call ABI and GC metadata without forwarding calls or runtime lookup state.
- Avoid formatting and allocating missing negative integer array keys when the array's live layout and unmodified intrinsic prototypes prove absence. Own properties, getters, prototype mutations, custom prototypes and erased non-array receivers retain the full property lookup. The proof reuses existing object/header fields and introduces no cache or side table.
Reduce runtime code size by applying size optimization to selected out-of-line cold helpers while retaining speed optimization on hot paths. Keep generic array-read fallbacks and array growth speed optimized after search profiling exposed extra outlined calls. Add public reproduction tools and size/instruction/cycle/timing evidence for #13662; no side tables or caches.
Use the existing dense own-element prefix in array for...of reads to avoid repeated descriptor, length, capacity, and prototype checks on dense hits. Preserve the inline pristine-hole path and consult both the receiver's prototype and the default-chain protector before treating a hole as absent. This fixes a missed inherited getter when a loop body retargets its array's prototype and deletes the next element. No new cache, side table, or runtime metadata is introduced.
Parsing a runtime new Function/eval body, and any other source, skips the unicode-escape and class-syntax normalization scans and the span remap when the text cannot contain what they rewrite (no \u pair; no class with await or static with constructor), and the diagnostics source cache builds a file's line index on first lookup instead of on add. Output and diagnostics are unchanged.
Format the workspace with cargo fmt so the scheduled lint job's formatting step passes. No behavior change.
Clear the deny-warnings errors in perry-runtime, perry-codegen, perry-ext-http, perry-stdlib and perry-component-host (unnecessary unsafe, unused imports, dead code), so the scheduled warnings job passes. No behavior change.
A class born on a family now matches the layout word its objects carry. The
dispatch tower's $pshape recheck compared the static id of the class keys,
which no compiled object carries, so the proven clone never ran for a
family-born receiver; it now accepts the family word when the family holds the
birth layout. A checked field read admits a literal family (class id 0) by its
descriptor word, and the runtime fact check admits it the same way.
Bump wasmtime to 48.0.8 in perry-wasi and perry-component-host (with the
matching wasmtime-internal, pulley and cranelift 0.135.8 lockfile entries),
fixing RUSTSEC-2026-0422, RUSTSEC-2026-0423 and RUSTSEC-2026-0424.
Fix unnecessary IPC and regex retention when a discarded process signal listener
registration assigns a lexical arrow to a local variable. Preserve the callback's
identity for removeListener and off without adding analysis tables or caches.
Prove method calls on an own key an object holds from birth when unknown code may have written the builtin prototypes: the callee lookup stops at the own data property, as reads already do (DECISIONS 234), so a factory literal no longer escapes as the receiver of an unknown callee. A later delete or redefinition of the key gives the call the builtin members again. A literal's per-unit contract now also carries its own accessor keys (getters and setters): the reader holds them on its stand-in instance, so a call through a getter's result runs with the receiver as this (the receiver escapes in the reader) instead of being dropped, and an accessor key shadows the chain like a data key. No runtime caches or side tables are added.
Changed
- Cross-target libraries ship on npm as one host-independent package per target,
@perryts/perry-target-<triple>(android aarch64/x86_64, iOS device/simulator, wasm32-wasip2/wasip1), instead of inside the host packages. Windows npm users get--target androidfrom@perryts/perry-target-aarch64-linux-android; the Windows packages no longer carry the android libraries (they made the upload too large for npm). perry compile --target <t>finds a target package installed beside perry (global, project or pnpm) or in~/.perry/targets, uses it only when itstarget.jsonnames perry's exact version and source revision, and otherwise prints the exact install command.
Added
perry target add | list | remove: install a cross target's libraries from the GitHub release without npm, verified against a manifest signed with the CLI-update key (perry updater sign-target-manifest, its own signing domain). Release and nightly runs attach the signed target tarballs.
Dead-dynamic-import pruning (DCE layer 2) now walks the module scope of every
module a site-loaded module re-exports from. Before, a module only loaded
through export ... from inside an import() target was never walked, so its
own import() sites could look dead and their targets could be dropped from
the binary. The activation pass resolves re-export sources the same way the
site-loaded set does (one reexport_target), and its points-to bound is
raised to 1e9 entries so the Supabase CLI still converges with the bodies it
now has to walk. A wpfacts fixture fails when the re-export activation is
removed.
A #private field now carries no value out of its class in the whole-program and per-unit facts,
and private fields get definite birth slots after the declared keys, derived from the same
layout codegen allocates. Static private fields are no longer published as class statics.
Runtime
Apply size optimization to four builtin installation routines while hot helpers
retain the surrounding build profile. The O2/fat-LTO macOS ARM64 comparison saves
about 16 KB in linked applications at baseline steady-state performance. Include
reproducible size, instruction, cycle, startup and application measurements for
#13662. No side tables or caches are added.
Optimize indexed scans over private small numeric literal arrays by folding proven immutable lengths and own-element reads. Carry numeric selection and integer-index proofs through constant bindings while retaining ordinary lookup for mutation, escapes, unknown keys, and inherited getters. No runtime caches or side tables are added.
wpfacts: per unit, another unit's class contract names its prototype getters. A read under the key runs the owner's getter and finds an unknown value instead of undefined only, and the owner lets what its getters return leave beyond any contract. Per-unit facts are no longer stronger than whole-program facts when a getter sits on a path a callee lets escape; a key read after delete through a getter-owned bound method no longer folds to undefined (parity test test_parity_13674_delete_then_read).
Whole-program facts: a summarized class or literal getter/setter now runs on the
receivers its accessor group sees. Before, its instance had no this, so a
getter returning a field (get table() { return this.tab; }) had an empty
result, and a store through that result (another module's .set on the
returned Map) or through a setter was lost: reads of the stored entries were
proven when they must be refused. Tests cover a getter's Map taking another
module's .set, a setter's Map becoming the field, and the proven controls.
Whole-program and per-unit facts give a length read of a real Array or a
primitive String the verdict OwnLength: the property is own and
non-configurable on those values, so the read needs no prototype check. A
value that may be no Array (the arguments object, an array made through a
written constructor or species, a container of primitives) never gets it. Proven
sites rise on tssrc (20,139 to 20,427 per unit, 22,016 to 22,239 whole) and the
private chess witness (523 to 534 per unit, 526 to 540 whole).
Fixed
- Skip packed-loop admission when code generation rejects the fast clone, removing unused guards and descriptors from short indexed scans (#13660).
Avoid retaining the regex engine for provably isolated user-class search, match, and matchAll methods, and avoid retaining process IPC for discarded signal/exit registrations with literal arrow callbacks. Keep conservative support for unknown receivers, escaping process values, ordinary or unknown callbacks, and real IPC.
Fixed
- A
newon a named or default import of a package that is not installed is
now a compile error naming the package and the binding, as a call of one has
been since #11576. A call failed in the linker; a construct links, and builds
a plain object whose every member readsundefined, so the build reported
success over a program that cannot work. Node fails the same program at load
withERR_MODULE_NOT_FOUND. A property read or a type-only use of an
unresolved binding is unchanged, since neither fabricates a value.
An object's and a function's [[Prototype]] now have one record: the shape.
A null prototype was recorded twice, in the shape's identity and in a header
bit (OBJ_FLAG_NULL_PROTO) set at birth, and readers disagreed. Fixed: a
thenable on a relinked Object.create(null) object was not followed;
util.inspect called an object relinked to Object.prototype "null
prototype" and missed Object.setPrototypeOf(x, null); require.cache
inherited Object.prototype's methods. The bit is gone (0x40 is free).
Object.setPrototypeOf(fn, p) moved a function to the shared
FunctionDictionary shape and kept the link as a key in its state record; the
function now stays on a described shape that names the link, as an ordinary
object does. Fixed along the way: fn instanceof C and fn instanceof Function ignored a function's link; a function linked to null still read
toString and constructor from Function.prototype; an @@hasInstance on
a function's linked chain was ignored; a program's first inherited read of a
function (fn.toString) answered undefined. Stores onto a bound function
take the function store lane. An inherited read on a callable class instance
drops from 1.93k to 1.64k Ir, a call from 3.70k to 3.22k; startup Ir drops on
every witness (fastify inject -0.27%, commander -0.89%).
Fix two whole-program facts gaps on arrays: a folded indexOf/lastIndexOf/includes
now reads the array elements instead of letting the array escape, and a store whose
key is a number is an element store rather than a named-key store.
Per-unit facts: an object literal passed to a closed class constructor is
described by its layout instead of escaping, a dead duplicate site record from
an inlined copy no longer lowers a site another unit decides, a key no layout
holds is not an escape, and a native this.f.push(x) stores x as an element.
Per-unit proven sites rise on the private chess witness (510 to 523 of 667
before the denominator drop, 660 after); whole-program counts are unchanged.
Fix a whole-program facts soundness bug: a bound function made inside a
summarized function (function bindTo(o) { return leak.bind(null, o); })
lost its bound this and bound arguments, so a target that stored them on a
global left the caller's object proven local. A bound value now owns its
parts, and every bind call that makes it copies its operands into them.
Whole-program facts no longer report a false E14 for an element key of top:
such a key never names the constructor, so a read through it keeps its
other proofs. A call through a parameter whose call is never run produces no
result value, so nothing flows from it into later reads. Per-unit proven
read/call sites rise on the chess witness (477 to 510 of 667) and its tests
(594 to 643 of 821); whole-program counts and the large ledgers are unchanged.
Fix a whole-program-facts soundness gap: TypedArray methods and constructors, Date toJSON and the TextDecoder label now run the conversions their algorithms perform (argument and element ToNumber, map and sort result coercion, the species constructor, the join that toString looks up, the toISOString that toJSON invokes), so the objects they reach are no longer proven.
Fix oversized code from nested loop unrolling
Bound HIR loop expansion by the total copied statements and expressions,
including previously expanded inner loops. Large formatter bodies now remain
loops under the default size optimization instead of growing dramatically when
+= 1 is spelled ++ (#13649). Small fixed-trip loops remain eligible for
constant-index specialization.
Read an inherited property of a callable class instance (a function whose
[[Prototype]] is a class prototype, as ArkType's Callable builds with
Object.setPrototypeOf(fn.bind(this), C.prototype)) as one OrdinaryGet with
the function as receiver: the prototype's layouts answer a data value or the
getter to call (a compiled class getter or a getter closure, run with the
function as this), and only what they decline takes the complete read. An
inherited method read drops from 16.8k to 1.9k Ir and an inherited getter from
7.8k to 2.8k; Supabase CLI --version startup drops 13.5% (8.09 G to 7.00 G
Ir). The walk over a function's recorded prototype chain moved out of line of
the keyed function-property read, so that read's common path got shorter.
Object.getPrototypeOf on an instance of a declared class (and every runtime
prototype walk, such as the store check on a function's chain) now reads the
class's prototype holder from the instance's shape, the link the layout probe
already follows, instead of the generic resolution tail.
Fixes: a data null on a callable's prototype chain read as undefined, and
a key absent from that chain could find an expando on Function.prototype,
which is not on it.
Bind the thisArg argument of the %TypedArray%.prototype callback methods
(forEach, map, filter, every, some, find, findIndex, findLast,
findLastIndex) as the callback's this, on both the Uint8Array/Buffer and
the other element-kind paths; it was dropped and the callback saw undefined.
new TypedArray(iterable) now calls the source's @@iterator method with the
source as this, so a generator method that reads this works.
The callback loops carry the rooted thisArg next to the rooted callback; the
two-argument entry points keep binding undefined. A gap test compares all
element kinds against Node and a runtime unit test covers every method.
Fix Unix process.stdin.setRawMode(true) and tty.ReadStream.setRawMode(true) clearing terminal output processing. Raw input now preserves the existing output flags, keeping newline-based terminal rendering aligned when LF-to-CRLF translation is enabled and leaving disabled translation unchanged. Add PTY regression coverage for output bytes, raw input, repeated toggles, and terminal restoration.
The guest component acceptance builds its transform Rust host with --locked.
That host lock still held zstd 0.13.3 after turnloop-http moved to zstd 0.14.3,
so component_check.sh stopped at build-rust-host on main. The lock now
matches (zstd 0.14.3, zstd-safe 8.2.0, zstd-sys 2.1.2).
When a guest trap retires a component's Store, the destructors of the host
payloads it still held run at retirement. Their first exception is now thrown
from the next dispose(), once, as legacy disposal does; later destructor
exceptions are suppressed. Before, the trap was delivered and the destructor
exception was discarded, because dispose() on a retired component returned
immediately. When the failing call has its own JavaScript exception (an import
that threw or rejected), that exception came first and stays the only one
reported, as before. The exception is kept in the component's one existing
root-failure slot; no table or cache is added.
The native host-resources component acceptance now has a second throwing
destructor (suppressed) and checks that a repeated dispose() throws nothing.
Refresh the runtime and compiler documentation against the current implementation.
- Describe copying and non-moving collection, target-specific precise roots,
young non-moving large-object storage, inline births, native handles, weak
processing, and the distinction between JavaScript GC and Rust ownership. - Explain per-module program-facts solving, cross-module contracts, field/call
specialization, guarded fallback, proof diagnostics, and generic versus native
representations. Correct stale eval, CommonJS, Node-API and threading claims. - Translate the revised passages into all ten published languages and regenerate
gettext catalogs with the pinned toolchain. Keep section anchors stable across
translations. Existing unrelated translation gaps retain current-English fallback.
Validation: English and all ten localized mdBook builds; gettext syntax/format
checks; translated-passage coverage and code/link preservation; catalog extraction
and sync reproducibility; GC claim/knob checks and i18n toolchain tests. This is a
documentation-only change; no compiler or runtime behavior changes.
Fix a whole-program-facts soundness gap: RegExp test, @@replace, @@match, @@search, @@split and @@matchall now run an exec or species constructor the program stored on the regexp, so the objects they capture are no longer proven.
wpfacts no longer reads a sync generator's public next()/return()/throw() as
returning the step closure's raw value: the result is an iterator result
{ value, done } whose value joins every yielded value, so a receiver that a
generator yields alongside another shape is no longer proven as the first
shape alone. Output was already correct (the runtime is guarded); only the
fact was wrong.
Per-unit wpfacts run a method call made through a field of a published class's receiver (this.svc.onCreate(this)) in every unit that holds such an instance, on what that instance holds in the field, so the receiver no longer escapes just by being handed to that call. A constructor's field initializers are part of its contract, so a subclass in another unit now sees their stores through super(). A constructor no longer runs on the class's receiver token.
Disposing a component now runs the destructor of every owned guest resource
that is still live, exactly once, in the disposal frame. Since all non-HTTP
components moved to the persistent driver, disposal dropped the driver and
discarded its Store, so a live owner (for example an abandoned constructor
result) was never destroyed. Disposal now closes the driver's submission
channel: queued commands are refused without running guest code, in-flight
commands are cancelled, and the Store returns to Host::dispose, which drains
live owners in the same order as a legacy handle. This holds while a guest call
is still suspended. A driver that has already retired its Store (after a
cancelled started call or a failing destructor) has no owners left.
Driver tests cover a quiescent component and one with a suspended export and a
queued call; both were red on main. The resources-run native component
acceptance step is green again.
Per-unit wpfacts treat an own definite data key (constructor store, field initializer, literal key) as shadowing the prototype chain on read, so a store through an unknown value under a computed key no longer defeats reads of keys an object always holds itself. delete, defineProperty and escape of the object still revoke the key.
The link now removes every compiled function nothing live references, with no
analysis. Three things used to keep every function of a linked module: its
functions and constants shared one input section each (function and data
sections were off), every function's metadata sat in a per-module owner table
that module init registered, and the GC map was one retained directory per
module. Native objects now have one input section per function and per global
(-ffunction-sections -fdata-sections through a two-field adapter beside the
wasm one), JS bodies keep one section each inside perry_js_text, each body's
metadata record and GC map entry are tied to the body's own section (ELF
SHF_LINK_ORDER, Mach-O live_support, COFF associative COMDAT), and readers
walk the linker-bounded record section, which only holds survivors. The owner
keeps its identity and overrides and names its image's records with a
self-relative offset, so module init is unchanged. GC map v9 has one 20-byte
entry and one records blob per function, carrying the version in each entry.
wasm has no tied sections in wasm-ld, so there every record stays, as before.
Fastify inject is 3.0% smaller and Richards 3.1%; startup Ir does not increase.
Tests cover the record and entry formats, the unit split, the COFF COMDAT tie,
image copies for unloadable images, and a shared object's v9 map.
Install the GC diagnostics (PERRY_GC_LOG, PERRY_GC_DIAG,
PERRY_GC_VERIFY_EVACUATION, and the PERRY_GC_TRACE JSON) when one of
their knobs is set while compiling, the same pay-for-use rule the instrument
knobs follow. Since these diagnostics moved behind
js_runtime_install_diagnostics, a program whose installs are chosen from its
own features (every WASI link and every auto-optimized native link) installed
them only for a heap-snapshot or process.report call. Setting the knobs only
at run time therefore printed nothing and skipped the evacuation verifier
without any warning. The WASI image selftest's moving/full-collection gate was
red on main for this reason. A compile with none of the knobs set produces a
byte-identical binary. The image selftest, the wasip1 reference-host
discard_reuse case and the guest moving-GC component check now pass their GC
knobs at compile time too. The selftest checks moving collections with the
verifier's evacuation_ok lines: the copying_minors counter only counts in
instrumented archives.
Per-unit facts order the units of an import cycle by the edges whose values
they use. An edge that exists only through a parameter type or through a
namespace import's reach is weak; a key read statically off a namespace
(ns.k, ns.a.b) is a strong edge to the unit declaring it. The back edge of
a cycle, which the importer sees as unknown, is now a weak edge wherever the
strong edges have no cycle of their own, instead of whichever edge the
depth-first walk met first (tsc: ExternalProject extends Project saw
Project as unknown and broke the subclass). The order stays deterministic:
components in post-order, members in discovery order. A unit test covers a
cycle through a parameter type that was broken at extends.
Fixed
- Forward MessagePort
close(callback)through the existing once-close-listener sequence, preserving callback ordering, deduplication and the actual receiver. Report the Node-compatible method length of one.
instanceof now consults a computed static Symbol.hasInstance. When the right-hand side was a bare identifier naming a user class, codegen resolved it to the class id and emitted the static js_instanceof(v, <id>) compare. That answer never looks at the function object, so a computed static member — @@hasInstance, including one installed with Object.defineProperty on the class — was silently ignored, and it was ignored even for a primitive left-hand side. The class case now materializes the declaration's function object (emit_class_value, which is non-collecting and returns a pinned value) and calls js_instanceof_dynamic, which performs the observable GetMethod on it; class metadata that is not this lexical binding never claims it, as before. The regression asserts the emitted IR reads @@hasInstance from that object and no longer emits the static class-id compare — red against the previous lowering, which reported "an imported class must read @@hasInstance from its function object".
main compiles its test targets again after #13622. That PR added CompileOptions::namespace_self_import, and the field is required in a struct literal, but the fixtures across perry-codegen's and perry's test targets name their fields explicitly instead of spreading Default — so cargo check -p perry-codegen --tests and cargo check -p perry --tests stopped compiling on main (42 sites). Each one now sets namespace_self_import: false, which is what every fixture means: the flag is false in every test path, exactly as a defaulted field would be, so there is no behaviour change. The regression was invisible to the checks #13622 ran (cargo check -p perry --bins and the gap harness do not build the test targets), which is why the repair is verified by exit code this time: both commands exit 0.
Per-unit program facts no longer claim more than the whole program in three
cases. A unit that hands Object.prototype to unknown code now writes only the
keys stored into ⊤ onto the builtin prototypes (not every key); a number-key
read of an intrinsic prototype sees only number-key writes; and an
Object.setPrototypeOf on an unknown target in one unit reaches the other
units, as it does in the whole program.
wpfacts: an accessor whose getter or setter is an unknown function value now
defines only the key it is installed under. It used to count as a computed
These notes are truncated. This release carries 316 changelog
fragments and the full text exceeds GitHub's 125,000-character limit for a
release body. The complete set is in changelog.d/ at v0.5.1665.
CI test gate skipped by the maintainer: Full-tier reds are the same set as main's (pre-existing, triaged; none from the release commit). Release dry run 41921720860 green
incl. npm-pack-check; js-compiled 22/22 correct, 0 slowdowns >10% vs 0.5.1520.