Skip to content

Redline native ownership - #236

Open
andreaTP wants to merge 12 commits into
bytecodealliance:mainfrom
andreaTP:redline-native-ownership
Open

andreaTP wants to merge 12 commits into
bytecodealliance:mainfrom
andreaTP:redline-native-ownership

Conversation

@andreaTP

Copy link
Copy Markdown
Contributor

To be reviewed

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
andreaTP force-pushed the redline-native-ownership branch from 74cdaa0 to e70eaa7 Compare October 20, 2026 16:16
@andreaTP
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

No deployments
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.

1 participant