Repository navigation
Conversation
If building an Instance fails after its machine was created, for example when the start function traps, nobody gets the instance back to close it. The constructor now calls the new Machine.abandon() hook, a no-op by default, so a native machine can free what it allocated. The build may already have put its functions in an imported table, where the spec keeps them callable, so abandon() is not simply close(). Anything allocated before the machine existed may still leak. Machine.close() and Instance.close() now document that a close may refuse with IllegalStateException, having released nothing.
Compiled code reaches an imported memory or table through a raw address, so its owner must not free it while an importing instance is still open. - Borrowers records the open instances that import a native memory or table. A close marks it first, so no instance can import it half way through, and a refused close changes nothing. - A failed build whose functions a table still holds can yield its imports instead: closing one of them closes the failed build rather than refusing, since nobody holds the failed instance to close it. - NativeResource is the internal contract the runners' memories and tables implement. - ImportFactory implements AutoCloseable: it frees the native memories and tables it created, all or nothing, and refuses while an open instance still imports one of them. - NativeResourceCount counts live native allocations, for leak tests.
Part of bytecodealliance#203. The garbage collector never reclaims an instance's off-heap state, so close() has to free all of it, every time. - Every native allocation of a machine goes on a free list that close() runs in full, newest first, even if one free fails. That includes the table writer flags bytecodealliance#242 added. - The globals buffer belongs to the machine, so it is freed, and instances built from one builder no longer share it. A host global stays on the heap until an instance adopts it, so it no longer leaks eight bytes. - The tables a module defines are freed whether or not a call ran. - close() refuses with IllegalStateException, freeing nothing, from inside one of the instance's calls, or while another instance imports its memory or tables. - After close, a call throws WasmEngineException, as it already did through a table entry, and memory or table access throws IllegalStateException instead of touching freed memory. Globals keep their last value. - Closing a memory is idempotent, and grow() refuses after it. - A failed build is closed, unless an imported table holds one of its functions. Then it stays callable, as the spec requires, until one of the resources it imports is closed, which closes it too.
Part of bytecodealliance#203. - Each instance gets a shared arena of its own, holding its context, stubs, globals, tables and table writer flags, with its code mapping tied to it. The factory no longer shares one arena and globals buffer between instances built from one builder. - close() closes that arena first. It refuses with IllegalStateException, freeing nothing, while a call on the instance is running on any thread, since a downcall keeps its context arena alive, or while another instance imports its memory or tables. It used to unmap memory and code under the running call. - A memory has an arena of its own as well, so host access after close throws instead of reading unmapped pages, and its page count comes from that arena instead of malloc. - After close, a call throws WasmEngineException, as it already did through a table entry. Globals keep their last value. - A host table gets its own arena, freed by its owner rather than by the garbage collector. - A failed build is closed, or, while an imported table holds one of its functions, kept until one of its imports is closed, as on jffi.
Part of bytecodealliance#203. Unlike the bytecode fallback, a natively compiled instance holds memory the garbage collector never frees, so the redline docs now say to close it, what happens after close, that importers close before what they import, and that a failed build may leak. The imports example and the e2e tests nest the instance inside a closing ImportFactory.
callForeignEntry read the length of a multi-value result before checking it for null, so a host function returning null surfaced as a NullPointerException. It now returns early as importDispatchDirect does. Also corrects the version in the .native format description.
andreaTP
force-pushed
the
redline-native-ownership
branch
from
October 20, 2026 16:16
74cdaa0 to
e70eaa7
Compare
andreaTP
marked this pull request as ready for review
October 20, 2026 16:17
A build could still leak native memory when it failed before its machine was there to free it. - The builder validates exports before creating memories, so a bad export no longer leaves one allocated. - When the machine cannot be built, for example because the precompiled code is for another module, the Instance constructor closes the memories built for it. - When a machine's constructor fails part way, the factory frees what it allocated: Panama closes the instance arena, and jffi runs the free list, which the machine now takes from JffiNativeMachine.create(). - ImportFactory checks it is open before creating a memory or table, so one made after close() is not leaked. The docs no longer say a failed build may leak.
close() refused while callDepth was above 0, but callDepth was a plain int changed by the calling thread, so a close from another thread could miss a call that had just started. Panama's arena still caught that; jffi freed the code under it. jffi also read the function table before it counted the call in. A call now counts itself in, then checks a volatile closing flag, and close() sets that flag before it looks at the count, so one of them always sees the other. A call that starts while a close is deciding waits for it, and throws if the instance closed. The flag stays set once close() succeeds. jffi counts the call in before any native read, and gets the cross-thread close test Panama already had.
- jffi grow() and the initial commit call mprotect themselves and check its result. PageManager.protectPages drops it, so under memory pressure grow() reported success for pages that were still inaccessible, and the next access crashed. - grow() publishes the page count compiled code reads last, in both runners, so guest code on another thread can't use new pages that memory.init, waits or host access still reject. - Compiled code reads a shared memory's page count with an atomic load, so a loop can't keep a stale count and miss another thread's growth. - withMemoryLimits() refuses to change whether the module's memory is shared, since its native code was compiled for one or the other. - A notify that wakes nobody no longer starts a new generation. It let a waiter that arrived after an earlier notify take that notify's wakeup, leaving the waiter it was meant for asleep. Fixed in ByteArrayMemory, ByteBufferMemory and NativeWaiters.
- A table's buffer holds at most 1,000,000 entries, but its header gave compiled code the declared max, 10,000,000 when none is declared, as the limit for table.grow. A module could grow its table past the buffer and write entries beyond it, which call_indirect would later jump through. The header now holds the smaller of the two. - grow() from Java computed oldSize + delta in an int, so a huge delta wrapped to a negative size that passed every check. - A funcref read from another instance's table carries that instance's function id. table.set in compiled code, and table writes from Java, looked the id up in this instance's function table without a bounds check. They now trap with "undefined element" when the id is outside it. - initializeNativeTables() set its flag before checking that every table is native, so a call that failed there left later calls with a half set up table array. It now checks first and sets the flag last.
Every outermost call into a machine anchored a fresh 512KB stack budget wherever the stack had got to. Calls through a table already passed the caller's limit along, but a call through a function import reaches the callee via the host, so each instance in such a chain added its own budget and the chain could outgrow the thread's stack. A thread-local now holds the machine whose outermost call is running on the thread, and the next machine's outermost call starts from its limit. That covers table calls too, so they no longer pass the limit by hand.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
To be reviewed