Skip to content

[prototype] [wip] kotlin wasm binding - #557

Draft
sargunv-bot wants to merge 32 commits into
mainfrom
t3code/add-kotlin-wasm-bindings
Draft

[prototype] [wip] kotlin wasm binding#557
sargunv-bot wants to merge 32 commits into
mainfrom
t3code/add-kotlin-wasm-bindings

Conversation

@sargunv-bot

@sargunv-bot sargunv-bot commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds a Kotlin/Wasm (wasmJs) binding that runs on the Emscripten pthread -sPROXY_TO_PTHREAD gives main(), so it blocks and calls the C API synchronously like every other Kotlin target, and takes native callbacks through callback_adapter.h rather than any bespoke proxying.

Test plan

//bindings/kotlin:wasmJsTest runs the suite in headless Chromium against the prelinked module, with the generated externs checked against that module by scripts/check-browser-exports.py; JVM, Native, Android, Rust, and C API suites are unchanged and green.

Not ready for review

Draft because the browser suite is not green yet: 161 tests run, 16 fail, and the run exceeds the runner's 600s budget. Known failures:

  • Two presentation/readback colour mismatches (presentsASurfaceSessionToThePageCanvas, presentsABorrowedTextureAndThenASecondOne).
  • One std::bad_alloc in a rendering update.
  • Ten cascading owner thread already has a live runtime — a failing test leaves its runtime open and poisons the tests after it, which the previous per-scope design hid.
  • Two missing map events.

Also unverified: the kotlin-test adapter hook uses the external namespace (globalThis.kotlinTest.adapter); if kotlin-test falls back to BareAdapter instead, the suite would stop at the first failure and report zero — a silent green. Worth asserting a deliberately failing test yields a non-zero exit before trusting it.

AI assistance

  • Tools: Claude Code
  • Context: Design, implementation, and review were AI-assisted throughout, including multi-agent workflows for the audit, the design comparison, and the rewrite. The architecture decision was settled by a spike that proved Kotlin/Wasm can be imported into a proxied pthread and block there while the page stays responsive; three defects in it were found by running it in a browser rather than by reading it. See AI_POLICY.md.

sargunv-bot and others added 30 commits August 5, 2026 23:19
The browser target built archives that only a matching emsdk could link, so no
host without a C toolchain could use it. This adds the linked module as the
distributable artifact, the generic call path a foreign-function host needs, and
the Kotlin/Wasm binding built on top: 10 of the 16 expect declarations, with the
same synchronous API every other platform has.

The binding keeps that API by parking a Kotlin stack on a promise. JSPI is a
virtual-machine feature, so Kotlin needs no support for it: `@JsFun` emits its
body verbatim into the import object, which is enough to install a suspending
import, and `@WasmExport` produces a raw export `WebAssembly.promising` accepts.
Owner-affine calls therefore run on a pthread the module owns while the caller
waits, and a host writes ordinary synchronous code inside `maplibreScope`.

Everything that describes the ABI is generated from the build rather than
written beside it: the export list from the linked archive, lowered signatures
from the module's own type section, struct layouts and field types from the
pinned Emscripten clang, and enum values from its AST. The generated accessors
name fields rather than offsets and widths, which is the class of bug this layer
exists to prevent -- the hand-written offsets it replaced were wrong on every
field of the first descriptor they described.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Descriptors that carry strings need them placed in the module's heap, and every
remaining handle has at least one. The module's own encoder does the work, so
nothing here re-implements UTF-8; these only size the region and copy the result
back into Kotlin before native can invalidate it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing in this source set could be verified until all sixteen expect
declarations had an actual, so every change so far was checked only by the six
errors it did not fix. These scaffolds close that: each member throws and names
itself, the target compiles, and the bodies are replaced one file at a time
against a build that tells the truth.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every wasmJs expect now has a real actual: the map, runtime, render session,
offline, and resource handles, plus the marshallers each needs. The suite runs
in headless Chromium against the linked Emscripten module -- 77 tests, none
skipped -- covering JSPI suspension, owner-thread dispatch, the synchronous
host callbacks, and a rendered frame checked pixel by pixel.

Three defects this found, all invisible until the binding was linked as a klib
rather than compiled together with its caller:

* A @WasmExport whose only other reference is a name inside a @JSFun string is
  dropped by dead-code elimination, so maplibreScope and both resource callback
  trampolines were absent from any host's build. Naming each from Kotlin is
  what keeps them.
* Webpack rewrites the module loader's dynamic import to its own resolver,
  which no host's build could have survived.
* Heap indexes typed-array views by element width, so a misaligned address read
  a neighbouring field rather than failing. It now refuses one.

The common API also gained WebglContextDescriptor. Every OpenGL render target
carries a context descriptor and there was no browser arm, so a browser host
could create a map and never draw it.

Surface and caller-owned texture sessions in the browser are still to come.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t found

The wasmJs suite carried no conformance markers where Kotlin/Native carries 71.
It now covers 79 of the specification's 99 requirements, with the other 20
recorded as inapplicable and reasoned about individually rather than dropped:
a page is one thread, so the binding cannot make a wrong-thread call; Kotlin/Wasm
has no finalization, so nothing is reclaimed outside an explicit release; and
header transforms, custom geometry sources, and the queued resource provider are
absent from this module by design. 145 tests, no skips. This target covers the
whole rendering table, which no other Kotlin source set does.

Writing the suite found that every failure raised on the owner thread reached
Kotlin with an empty message. NativeDiagnostics said the dispatch path copied the
diagnostic beside the status; it had no caller, and the page's thread-local slot
is always empty. The owner thread now copies the message into the completion when
it publishes a failing status, because reading it later races any subsequent call
on that thread. The dispatch protocol carries the completion's shape, so it moves
to 2 rather than letting a skewed pair degrade silently to the same emptiness.

Also fixed here: three paths crossing as a bare char* without refusing an
embedded NUL, which would have acted on a prefix of the caller's path; and a
Playwright browser lookup pinned to a directory name Playwright has renamed,
which stopped the browser suite starting at all.

WebglContext moves to the test source set. Creating a context is the host's job,
and its two KDoc references in the shipped binding pointed at methods that no
longer existed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All three OpenGL session families now reach a visible page canvas in the
browser, and none of them copies a pixel to get there. A host reserves a canvas
id, the dispatcher transfers that canvas to the render owner at pthread_create
-- the one moment Emscripten permits a transfer -- and a WebGL context is
created on it. A surface session renders straight into its default framebuffer;
a texture session blits its texture onto that framebuffer with
glBlitFramebuffer, which is GPU-side work that never enters WebAssembly memory,
JavaScript, or Kotlin.

The owner thread had to stop blocking for any of this to be observable. A
browser composites an OffscreenCanvas when the task that drew into it ends, and
the thread parked in pthread_cond_wait -- Atomics.wait -- inside the single task
it was started in, so it rendered correctly and could never present. It now
takes a runtime keepalive and returns, with work arriving as proxied wakes that
drain the queue and return. Restoring the blocking loop fails exactly the three
presentation tests while the texture readback beside them still passes, and in
the surface case that build's own readback of framebuffer 0 still holds the
right colour: the map rendered identically and the frame simply never left the
thread.

Every assertion here reads the page element through drawImage and getImageData
rather than readPremultipliedRgba8, because a readback proves rendering and says
nothing about presentation. Each was checked against two mutation classes: wrong
expected colours, and a broken path to the page with rendering left intact.
Presenting a caller-owned texture by name also closes BND-171, which was
recorded as unreachable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An external review found the published binding could not render at all. The only
way to obtain a WebGL context lived in the test source set, so the presentation
work this branch is built on was reachable from this repository's tests and
nowhere else. WebglContext is now public API in wasmJsMain, with one copy rather
than two, and the rule is written into the binding specification: where the host
graphics API is the binding's own module, the binding supplies the context,
canvas, textures, presentation, and readback. Every other target leaves those to
a host that owns EGL, Metal, or Vulkan itself. A comment saying so is what let
this be deleted once already.

Nothing ran the Kotlin/Wasm suite in CI either -- the Emscripten job ran the C
suite -- so all 151 browser tests could have broken silently. The suite now runs
from the generated workflow, against the unpacked native package rather than a
build tree, which is the layout a host actually downloads.

Three defects behind that, each mutation-tested by breaking the fix and watching
the intended test fail:

* Calls made outside maplibreScope trapped in JSPI instead of reporting. They
  now name the missing scope, including the harder case of a page task running
  while a scope is parked.
* The completion drain rescheduled a zero-delay task forever after the first
  call. It now stops one turn after the last completion, which is what the
  outstanding count is for; stopping on an empty turn would strand a caller that
  parks before its call is submitted.
* A module rejected after instantiation leaked its sixteen-worker pool. Tearing
  it down needed PThread exported from the module, so it is a real teardown and
  the export is required at load.

Custom geometry sources are still refused, but the reason recorded for it was
false: it claimed resource providers were unsupported here, and they are
implemented and tested. Both tile callbacks return void, so the mechanism is an
asynchronous proxy rather than the synchronous one the provider needs, and the
entry moves from "absent by design" to "not implemented yet".

The browser module also now reaches consumers: it is published with the wasmJs
artifact, packaged by CPack, and carries the third-party notices for everything
statically linked into the wasm. Publishing it without them is a licensing
defect that nothing about the archive makes visible, so the build and the
staging check both refuse it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A second review round found that publishing WebglContext had opened a lifetime
hole the first round created. A descriptor carries only the Emscripten integer,
and nothing retained the wrapper, so a host could close a context while a render
session still borrowed it. The next frame would run against a destroyed context,
and worse, session teardown needs that context to release its GL objects -- the
backend destructor swallows the failure, so close() reported success while GPU
cleanup was abandoned.

WebglContext now uses the same HandleStateCore bookkeeping every other owned
handle does, so it can be retained as a child. A session retains the context it
attached with and releases it in detach and close, beside the map. Closing a
context under a live target now names the target, and attaching with a
descriptor from a closed context is refused. One residual limit is documented
rather than papered over: Emscripten may reuse a handle after a destroy, and
nothing in the C descriptor distinguishes the two contexts.

A refused close is retryable again, which the one-shot borrowed-resource release
had made impossible: a transient refusal left the native context live and the
wrapper permanently dead. The seam that tests it is a real host mistake --
closing outside maplibreScope -- rather than an injected failure.

Canvas ids reached document.querySelector unescaped, so a valid HTML id like
"map:canvas" parsed ":canvas" as a pseudo-class. The fix escapes only the site
that is a selector; the other spells the same canvas as a registry key, where
escaping would have broken it. Loading also now refuses a page that is not
cross-origin isolated with a diagnostic naming the headers, rather than failing
later inside the pthread pool.

Two claims that were not true are now true or gone. The log callback's result is
ignored on this target, so the API said so where a caller reads it and the
specification records it as a browser divergence; a host returning true was
getting duplicate output. And the KDoc still told readers surface sessions were
unreachable, which is how they came to be deleted once already.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was the last C API domain the browser binding refused, so common Kotlin
that works on every other target failed here, and the tile-data and invalidate
calls this binding already had were unusable for the workflow they exist for.

Both tile callbacks return void, so nothing waits for an answer and the proxy is
asynchronous: the worker copies the tile id into a heap notification, posts, and
returns. The synchronous proxy the resource provider uses would have been worse
than one blocked worker -- CustomTileLoader::fetchTile holds the loader's
dataMutex across the call, so every other operation on that source would have
queued behind the page for the length of a host callback.

The registration races are what took the work. The host pointer is loaded on the
page at delivery rather than captured when the notification is posted, so
clearing and delivering cannot interleave. Tokens are issued once and never
reused, so a notification that outlives its registration finds nothing and is
dropped rather than reaching a freed one. Registrations are released when the
source is removed, when a style replaces it, when the map closes, and on
MAP_STYLE_LOADED for a style set by URL -- the last of which meant implementing
a poll-time side effect wasmJs did not have.

Answering from inside fetchTile does not work, because the body runs in a
callback scope that refuses owner-affine calls. That is now stated on the method
and asserted, rather than left for a host to discover.

Proven on page pixels: the canvas shows the background while the source has no
data, the fill colour once the page supplies a world polygon for the tile the
source asked for, and the background again once the layer and source are gone.
Six mutations were run, each landing on the assertion it should. One of them
found an assertion of mine with no teeth -- proxied cancels land during a map
close, not after it -- which is why each teardown is now asserted directly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A third review round found that shutdown could strand handles and that a WebGL
descriptor was only as unique as an integer the allocator reuses.

shutdownMaplibre() enforced nothing. A runtime that outlived its scope could be
closed after a shutdown, which started a second owner thread and destroyed it
there: native answers WRONG_THREAD, the thread that could have destroyed it is
gone, and the handle is unclosable for the page's life. It now refuses while any
handle created on that thread is open, naming what is open the way a parent with
live children does, so the mistake is reported while the thread that can still
fix it is alive. An accepted shutdown is final: no later call starts a
replacement thread, which turns the case the count cannot see into a clear error
rather than a silent strand. Restart was never worth keeping -- a page gives a
canvas away once, and a restart after a page-canvas shutdown fails
pthread_create outright.

A WebglContextDescriptor carried only the Emscripten handle, which the module
allocates with malloc and frees on destroy. So a descriptor kept past its
context's close named whichever context inherited that number, and attaching
with it would have retained and rendered through a context the host never named.
A descriptor now carries the context itself and attach resolves that.

Also from this round: canvas ids are validated for length where the caller can
still act, rather than being accepted, irreversibly transferred to the worker,
and then refused by the module with an error blaming WebGL support; and a
retiring provider registration now answers PASS_THROUGH, where the unknown
decision it used to return is what C turns into an error response.

One reviewer claim was investigated and rejected: that stop() leaving `draining`
set would deadlock a restart. Running their sequence showed the flag is only ever
true because a turn is already queued -- startDraining sets and schedules
together, and a turn either clears the flag or reschedules -- so the leftover
flag is evidence of a turn, not its absence. The comment that suggested otherwise
is rewritten.

The stale-descriptor test no longer rests on the allocator returning a freed
handle on demand. It hunts for reuse and asserts the refusal either way, because
the invariant is that a closed context's descriptor is refused; reuse only
sharpens it into the case the neighbouring test cannot reach.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A fourth review round found that portable Kotlin answering inside a custom
geometry fetchTile -- the natural pattern on JVM, Android and Native -- was
refused here, so shared code was green everywhere else and produced empty tiles
in a browser. The reason recorded for the refusal was borrowed from a case that
does not apply.

CallbackScope exists for the two families a MapLibre worker waits inside: the
resource provider and the URL transform. There a worker is blocked in the
module's synchronous proxy, so a page call onto the owner thread would be a page
waiting for a thread waiting for the page. Custom geometry is delivered with
proxy_async and nothing is blocked; the real obstacle was narrower, that the
proxied delivery task is an ordinary event-loop stack where a suspension traps.

Notifications are now queued and drained on a promising stack of their own, so a
host body may park exactly as it does inside maplibreScope. The delivery does not
take the suspension gate, and must not: a host scope holds that gate for its
whole life and tile requests arrive while it is parked, so a delivery queued
behind it would wait for the loop waiting for its tiles. What the gate protects
is unaffected -- the scoped allocator is only used synchronously, and no use
count is held across a park.

Order within a MapLibre thread is preserved; what changes is that delivery moves
to the first microtask checkpoint after the proxied task. The registration is
resolved inside the queued body rather than at post time, so a source removed
between posting and delivery is dropped as before. Shutdown now waits for
deliveries to drain, but only once no handle is open, since until then there is
no end to wait for.

Three comments that had gone stale are corrected with it: one claimed WebglContext
was not counted at shutdown, which the previous commit made false; one credited
the suspension gate for dispatch token uniqueness, which the counter provides; and
one said a second call in flight was what the gate prevents, which is now ordinary
and safe. A test whose framing implied it covered the worker-to-page proxy now
says what it locks and what it does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A fifth review round found the shutdown gate this branch added was counting the
wrong set. Its comment claimed every owner-affine handle stays counted through
the runtime it retains; two do not. A projection is a standalone snapshot that
retains nothing, and a render session gives up both its retentions at detach
while staying live and still needing destruction. So a host could take a
projection, close the map and runtime, shut down successfully, and never be able
to destroy it -- the one thread that could was gone. Both now count themselves,
and the rule is stated as it actually holds: a wrapper counts itself unless
something already counted is refused while it lives.

Shutdown also stopped the thread and left the module behind, so a single-page
host got back neither the sixteen-worker pool nor the 512 MiB heap. It now
terminates the pool and drops the page's reference in one step, because a worker
killed while it holds the allocator lock leaves that lock held and only dropping
the reference guarantees nothing allocates afterwards. Writing the test for that
turned a clean refusal into a JavaScript type error naming _malloc: every call
into the binding allocates before it dispatches, so the scratch allocator is
where the release has to be reported.

The successful shutdown had never executed under any test -- not here and not in
the C suite, which joins its dispatcher instead. It now has a browser run of its
own, since a stop is final and would poison a shared page, and CI runs it.

Two module-global slots could be taken twice. A second binding instance on one
page shares the module and the pending-call map while duplicating the dispatcher
and its token counter, so one instance's completion could resolve another's
promise; the loader now refuses a second instance, and the C install for the
provider, the transform and custom geometry claims its slot by compare-exchange
rather than assignment, which is the only layer that can turn away a host that
is not this binding at all. A module already on the page is now checked against
this binding's digest and protocol rather than trusted, including when a second
caller joins a load already in flight.

Custom geometry's close no longer depends on a bug to be correct. Its gate's
"closing from inside a callback" test reads a counter that is per-gate rather
than per-stack on this target, so a parked delivery made it answer for the wrong
stack; that false positive was the only thing keeping close off a wait loop that
cannot make progress under JSPI, where the frame holding the count is another
parked stack. It takes a non-draining close instead, and the draining one is
left as it is for the targets where a real thread holds the count.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…aching a released module

A sixth review round split on custom geometry: one reviewer called the
non-draining close justified, the other a specification violation. The
specification settles it -- clearing, replacing, or closing "waits for in-flight
upcalls" before releasing callback roots -- and the harm is not to the callback
object, which Kotlin keeps safe, but to whatever the host captured in it and
disposed of once teardown returned. The test asserted the callback continues
after close, so it blessed exactly what the rule forbids.

Waiting was never the impossible part. A plain Kotlin function parks on this
target whenever it runs on a promising stack, which is what every owner-affine
call already does, and every path that retires a source dispatches first -- so
the close needed no signature change. Two things had to be true rather than one:
the wait had to park rather than spin, since the frame holding the count is
another parked stack that only the event loop can advance; and the gate had to
know whether the closer *is* the callback, which a per-gate counter cannot say
once a body can park. The browser's counter is now scoped to the running stack,
handed off wherever a stack parks, so it means what it means everywhere else.
Fixing only the spin would have left the close returning early on the strength of
a counter that answered for the wrong stack.

Three paths still reached the module after a shutdown had released it. A buffer's
close and its byte copy went straight to the heap that had just been discarded,
which is a JavaScript type error rather than a binding failure; a request handle
a provider still held threw out of close after the core had marked it released;
and the process-global log callback was rooted for the life of the document with
no call left able to drop it. The rule is now stated where a host reads it: a
handle whose release needs the owner thread refuses the shutdown, state that
lives only in the module's heap is reclaimed by the release, and the log root is
that rule inverted, so the shutdown drops it.

Also here: readPixels sized its scratch with an unchecked Int product, in a
binding that refuses exactly that everywhere else; and a canvas id of __proto__
was accepted by validation while Emscripten's plain-object registry routes its
assignment to the prototype setter, so the canvas is transferred irreversibly and
registered nowhere. That is the only such id -- every other inherited name is
shadowed by an ordinary own property -- so it alone is refused, and this module's
own registry lookup no longer sees inherited properties either.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two review rounds running said the same thing about three requirements, and the
reason recorded against them -- that no seam existed to inject the failure --
was never a reason. The specification authorizes an internal seam for exactly
these, so their absence was a gap in the suite rather than evidence the code
worked. This closes it.

The seam replaces the answer and never the recovery. A faulted call does not
reach native at all, which is what makes the state behind it real: a frame whose
release was refused is still acquired natively, so the retry really does release
it, and a provider replacement that was refused really does leave the runtime
holding its predecessor. A seam that let the call through and rewrote its status
would have proved neither. Both hooks are one field read on the path they sit on,
and nothing in the binding arms them.

What is now proved rather than asserted: a copy failure after a result, list, or
snapshot handle is acquired still destroys it -- checked by replaying the handle
and requiring native to call it stale, which is the only way a page can tell a
destroyed handle from a leaked one; a refused install leaves the previous
callback registered and releases only the replacement, for the provider, the
transform, and custom geometry; and a refused frame release leaves the frame
open, its backend handle readable, and the session still holding the borrow until
a later release succeeds.

Two entries left the list for reasons rather than seams. Owned-frame construction
is a conditional requirement whose condition does not hold here, because the
wrapper is built from values already copied out of the acquire and nothing
between them can fail. And the log family makes no native install call on
replacement at all: the queue is registered once for the module's life, so there
is nothing for native to refuse and no native state to release.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…teardown holes

A seventh review round found the failure paths were still the unfinished half.
The module fixes its heap at 512 MiB and left Emscripten's aborting malloc on, so
every `if (address == 0)` in this binding was dead code: exhaustion killed the
module before the check could run, and the exception the public API documents
could never arrive. Linking with -sABORTING_MALLOC=0 makes those checks live.
C++ does not lose its abort but gains a better one -- `operator new` throws with
exceptions enabled, and the C API boundary already turns that into a native-error
status. Growth was rejected deliberately: it moves the wall rather than removing
it, and a browser build still has to report the failure.

The test for it is a real exhaustion rather than an injected one. It asks for
exactly the module's whole heap, which no heap can serve because that memory also
holds the code and the stacks, then allocates sixty-four bytes and reads them
back -- an assertion an aborted module can never reach. Relinking without the
flag fails it with a trap, which is what the finding described.

Three teardown holes went with it. A stale WebGL descriptor was refused at attach
but accepted at retarget, where only the recycled integer was compared. A close
that timed out draining callbacks left the wrapper marked closed while the map
stayed registered and its runtime retention held, so the runtime could never
close and a retry did nothing. And a resource completion was marked as having
reached C before the call that reaches C, so a failure on the way there was
reported as an answer the request never got.

The wait added last round also had a hole of its own: it parked without
surrendering the promising-stack count, so a page task running during a callback
close was told it was inside a scope and trapped instead of being refused by
name -- the exact failure the guide promises against. Parking now hands the count
back wherever it happens.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…hread stopped

An eighth review round found what the seventh's fix had exposed: once malloc
returns null instead of aborting, every allocation the binding never checked
became a live failure path. Three of them mattered.

The worst was the completion drain, which allocated its own scratch each turn
outside any finally. A throw left `draining` set with no turn scheduled, so every
parked caller stayed parked and no later call could restart the drain. Choosing a
retry policy would have been the wrong fix: the drain is the page's only way out
of a full heap, since each parked caller holds the block it packed, so a recovery
that needs the exhausted resource cannot run. The block is now taken once beside
the owner thread, where a failure is still an ordinary error on the caller's own
stack, and the turn allocates nothing. The finally stays as the invariant, with a
one-frame back-off so a turn that throws for some other reason cannot become an
error storm the page runs as fast as it can schedule.

An entry-point name was written through an unchecked allocation, so exhaustion
reported that the module carried no such entry point -- a binding/module
disagreement -- after writing the name over address zero. It goes through the
same scratch discipline as every other argument now. This one has no test that
tells the fix from the bug: the failure needs a forty-byte allocation to be
refused, and a page cannot arrange that.

A frame whose wrapper could not be built leaked the native acquisition, because
everything after a successful acquire still allocates and the catch ended only
the binding-side borrow. The release has to use the scratch descriptor rather
than the frame value, since that value is one of the things that can fail to be
built, and the C API matches a release by generation and frame id rather than by
the address it arrives through. The coverage note claiming nothing after
acquisition could fail is gone; object construction is itself a failure point.

Blocking dispatcher destruction had the use-after-free the previous round fixed
in stop: a failed stop-wake meant the join was skipped and the dispatcher freed
while an earlier drain could still lock it. Both teardown paths are now the same
shape. And the log drain had the dispatcher's wedge in miniature -- a throw
between its first statement and its tail stopped log delivery for the page's
life, silently, with no way back.

The final-shutdown test claimed cleanup it could not observe: the stop is posted
fire-and-forget and the pool terminated with no acknowledgement between. The
module now reports how many stopped dispatchers have not released themselves, and
the shutdown waits for that from page tasks before discarding. The wait covers
the one worker the page knows about and does not make the terminate safe, which
is said where it is claimed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… drain rest

The page's stand-in for the owner thread's error slot stood until something
replaced it, which made it authoritative for every call that followed and left
every other path to native responsible for retiring it. It is now a scope that
holds for exactly as long as a dispatched call's result is being read, so a
page-thread call reaching native without the dispatch path reports its own slot
rather than somebody else's message.

The log drain rescheduled itself with no delay for as long as a callback was
installed, so a page that logged nothing still ran a task as fast as the browser
would schedule one. An empty turn now steps a geometric backoff to four wake-ups
a second, and a turn that delivers resets it.

Also drops the digest the manifest carried of itself, which no reader can rely
on: whoever changes the contents rewrites the digest too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A Kotlin/Wasm module can be imported into the pthread that -sPROXY_TO_PTHREAD
gives main(), call the module's exports from there, and block on a futex while
the page stays responsive. That was verified before this deletion, and it
removes the premise every mechanism here was built on: that Kotlin sits on the
page main thread, cannot block, and must therefore reach an owner thread through
a dispatcher, park on a JSPI promise to await it, and receive worker callbacks
through a proxy back to the page.

None of that is needed by a binding that owns its thread, and no other binding
in this repository has anything like it. What goes: the owner-thread dispatcher
and its completion ring, the synchronous worker-to-page callback proxy, the
generated generic call table and the ABI sidecar manifest that described it, the
promising-stack scope and its suspension gate, and the tests that covered them.

The browser log queue and the custom geometry bridge go with them, not because
they were unnecessary but because the C API already answers what they answer:
callback_adapter.h exists for hosts that cannot run user code on a native
callback thread, which is what a separate wasm module in one JS realm is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A log callback returned true to consume a record, which asks the host to decide
on the thread that produced it. A host that cannot run its code on that thread
cannot answer there, and the browser binding documented that it ignored the
result and always reported "not consumed" -- a promise the common API made and
one target could not keep.

The decision moves to registration, where every host can express it:
setLogCallback(callback, consume). This is what the C API's adapter has always
offered through mln_adapter_log_callback_state.consume, and what the Dart
binding already ships.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The binding called native through a generated table of 278 indexed entries,
packed its arguments into eight-byte slots, and checked the module it loaded
against a digest of the headers it was generated from. All of that existed to
avoid declaring the entry points, on the grounds that a browser host has no link
step to keep declarations in step with the headers. It generates 4,384 lines of
Kotlin from clang already, so it can generate the declarations too.

Each entry point is now an ordinary external function, and the compatibility
check is the one the specification asks for: the ABI version the module reports
against the constant the binding was built with. What the digest actually
guarded -- a generated file falling out of step -- is a build-time concern, so
it is checked at build time: check-browser-exports.py reads the signatures out
of the module emcc wrote and compares them against the declarations the binding
compiles against. Both readings describe the shipped artifact, so a mismatch is
a defect rather than a disagreement between models.

The shim the module carries is four files. main() takes a keepalive and asks
JavaScript to import Kotlin into the pthread -sPROXY_TO_PTHREAD gave it, which
is a thread that may block. One bounded ring carries records from the C API's
callback adapters to the thread Kotlin runs on, because MapLibre's own threads
are separate JavaScript agents and cannot call Kotlin at all. The WebGL helpers
keep the halves that ran on the owner thread and drop the dispatched ones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A browser transfers a canvas to a thread only as that thread is created, and
the thread this binding renders on is created while the module instantiates, so
a canvas the page displays has to be registered before the factory resolves.

The registry is consulted ahead of the DOM lookup that fails thread creation
outright when a selector matches nothing, so an entry is registered either way:
a host that displays a map transfers its canvas, and a host that only renders
to textures gets a placeholder it never touches. Both were verified in a
browser -- without this, a page with no canvas of that name failed
pthread_create and the module instantiated with no thread to run on, reporting
nothing to the host.

One on-screen canvas is supported. A second is a limitation rather than a
defect: a canvas belongs to the thread it was transferred to, and there is one
such thread.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three comments cited src/browser/, which is deleted, and two doc blocks in the
new shim explained themselves at a length the rest of the repository does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The nineteen entry points the binding calls that the C API does not provide
were declared nowhere: each one's contract sat above its definition, and the
Kotlin side wrote a matching declaration by hand with nothing comparing the
two. The contracts move to a header, which is where this project documents an
interface, and the implementation files keep only what explains the code inside
them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The nineteen entry points the browser module adds are declared in a header now,
so they can be generated and export-checked like the two hundred that come from
the C API. Nothing generates yet: the generator emits what the binding names,
and the call sites arrive with the rewrite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Thread creation looks the canvas registry up by the transfer list entry
verbatim, and context creation looks the same registry up with a leading "#"
removed. A name carrying the hash satisfies the first and fails the second, so
the canvas transferred to the render thread and then could not be found from
it. Neither half reports anything: the thread starts, and creating a context
returns zero.

Verified in a browser with the real module: Kotlin boots on the render thread,
creates a WebGL2 context against the canvas the page transferred, and creates a
texture in it.

The guide described a boot the module does not perform -- a factory option
naming the program, and a main export. It documents the contract the module
carries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The install page listed what the archive holds and never said that the host's
own distribution belongs beside it under the name the module resolves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The thread the binding runs on is kept alive by a keepalive, which is what a
map wants and what leaves a test runner with no way to learn the suite
finished. A host with an exit status now has one entry point to report it
through, forced rather than waited for because a backend keepalive can outlive
the one this drops.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ters

Every owner-affine call went through a dispatcher: packed into eight-byte slots,
posted to a thread the module owned, and awaited by parking the caller's stack
on a promise. The thread Kotlin runs on may block, so a call is now a call.

Native reaches this binding through one bounded ring instead of three bespoke
mechanisms. MapLibre's worker, network, and logging threads are separate
JavaScript agents and cannot enter this module at all, which is what
callback_adapter.h exists for: its listeners copy each borrowed payload, the
shim queues it, and the drain inside pump delivers it on the thread that asked.
A retirement marker travels the same ring behind the records it retires, so a
cleared callback stops receiving exactly where native stopped producing.

Resource providers take the queued adapter with routes declared up front, the
way the Dart binding does. The synchronous provider and the resource transform
report unsupported here, alongside the HTTP header transform that already did.
BND-156 and BND-157 were declared absent by design; they are covered now.

The wasmJs sources lose about a third of their weight, and what is left reads
like the other targets: ordinary handles, synchronous calls, and a pump that
blocks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sargunv-bot and others added 2 commits August 6, 2026 01:44
One C entry point takes a parameter called `arguments`, and the generator wrote
that name straight into the JavaScript arrow function it emits. `arguments`
cannot be bound in strict mode, which an ES module always is, so the module
carrying it failed to parse -- taking every other entry point with it, and the
whole binding, for one parameter name. The suite never started and reported
nothing.

Reserved now along with `eval` and the four strict-mode-only words, the way the
keywords already were.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The branch was written against the commit before the last bump and carried that
pointer through the rebase, which would have reverted the bump on merge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sargunv sargunv changed the title Add the Kotlin/Wasm binding [prototype] [wip] kotlin wasm binding Aug 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants