Repository navigation
feat: run indirect calls to another instance's functions in that instance - #242
Merged
andreaTP merged 3 commits intoOct 20, 2026
Merged
Conversation
Follow-up to the review of this change, in both the Panama and JFFI runners: - a table entry names its owner by a machine id that is never reused, instead of the owner's context address. malloc hands a closed instance's address to the next one, which then took the closed instance's entries for its own and jumped into its unmapped code - a table keeps the native instances whose functions it holds, as the interpreter's TableInstance does, instead of a static map that kept every unclosed machine for the life of the JVM. Closed ones that no entry refers to any more are dropped, and calling one still in a table throws "instance is closed", from any machine - native table.set registers its instance with the table the first time it writes to it - a table initialiser's entries belong to the instance that defines the table, rather than to whichever instance calls first. This was also wrong in the interpreter and the JVM compiler, so Instance now records the owner for every machine - the type check on a call to another instance uses FunctionType.matches, as the interpreter does, so a declared subtype is accepted - a null entry is now rejected by the runner, and the direct path checks the function pointer again, so a function the image left uncompiled traps instead of being called at address 0 - table.grow, table.fill and table.copy go through NativeTable, so the owner bookkeeping lives in one place - the .native format moves to version 4, since compiled code reads the new entry layout and context slots A return_call_indirect to another instance still runs as a nested call, so a chain of them across instances uses stack. The interpreter and the JVM compiler reject that case outright.
A call_indirect through an entry another instance owns runs in the owner's machine, and two pieces of the caller's state went stale across it: - The owner may grow a memory the caller also uses. Compiled code caches a non-shared memory's page count in the caller's context, so afterwards memory.size was stale and the caller trapped on the new pages. Refresh it after the call, as after a host import. - The owner's first call anchored a fresh 512KB stack budget wherever the caller had got to, so each instance in a chain added its own and the chain could outgrow the thread's stack. The owner now starts from the caller's limit.
Contributor
Author
|
@andreaTP Thanks for finding those bugs and fixing them... I'm sorry for the issues it seemed I had not thought about tracking properly w/ using the machine id. Good finds with the rest as well |
andreaTP
approved these changes
Oct 20, 2026
andreaTP
left a comment
Contributor
There was a problem hiding this comment.
Thanks for the help @Phoenixxo !
andreaTP
added a commit
to andreaTP/endive
that referenced
this pull request
Oct 20, 2026
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.
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.
A Redline table entry holds a function pointer and a type index, but not which instance the function belongs to.
call_indirectcalled every entry with the caller's memory and context, so a function that another instance put in the table ran against the wrong memory, globals and tables. The type check had the same problem: it compared the caller's type index with the owner's, and type indices are module-local, so a valid call could trap withindirect call type mismatch. This is the Redline side of what #88 fixed in the interpreter. The spec tests don't catch it because their shared-table functions only return constants.wit-component lays out every component this way. Its shim module calls the host through a table that a separate fixup module fills, so any component with imports hits it. I had been working around it in my project by keeping those modules off Redline.
Each table entry now also records the context of the instance that owns its function. When the caller owns the entry, the call is made directly as before, which only adds one load and one compare. Otherwise it goes through the runner, which checks the signature by structure and calls the function through the owner's machine, so traps, host exceptions, interrupts, multi-value results and tail calls behave the same as other calls between instances.
NativeTable.instance(index)now returns the entry's real owner instead ofnull, including for entries written by an instance that isn't running natively. The change is in both the Panama and JFFI runners.This also matters for the precompiled trampolines from #228. An imported function in a shared table is the owner's trampoline, and calling it with the caller's context would run the caller's import instead. Entries from another instance never get called with the wrong context now, so the trampolines don't need to handle that case.
I added
CrossInstanceCallIndirectTestto both runner test modules. It covers functions that read and write their own global and memory, call their own import, return multiple values, tail call and trap, along with a structural type check and entries filled by an interpreted instance. The full runner suites pass on both (28,805 tests on Panama and 28,803 on JFFI).