Skip to content

feat: run indirect calls to another instance's functions in that instance - #242

Merged
andreaTP merged 3 commits into
bytecodealliance:mainfrom
Phoenixxo:fix/cross-instance-call-indirect
Oct 20, 2026
Merged

andreaTP merged 3 commits into
bytecodealliance:mainfrom
Phoenixxo:fix/cross-instance-call-indirect

Conversation

@Phoenixxo

Copy link
Copy Markdown
Contributor

A Redline table entry holds a function pointer and a type index, but not which instance the function belongs to. call_indirect called 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 with indirect 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 of null, 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 CrossInstanceCallIndirectTest to 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).

@Phoenixxo
Phoenixxo requested a review from andreaTP as a code owner October 18, 2026 23:21
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.
@Phoenixxo

Copy link
Copy Markdown
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 andreaTP left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the help @Phoenixxo !

@andreaTP
andreaTP merged commit 1cd6d40 into bytecodealliance:main Oct 20, 2026
25 checks passed
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants