[prototype] [wip] kotlin wasm binding - #557
Draft
sargunv-bot wants to merge 32 commits into
Draft
Conversation
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>
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>
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.
Summary
Adds a Kotlin/Wasm (wasmJs) binding that runs on the Emscripten pthread
-sPROXY_TO_PTHREADgivesmain(), so it blocks and calls the C API synchronously like every other Kotlin target, and takes native callbacks throughcallback_adapter.hrather than any bespoke proxying.Test plan
//bindings/kotlin:wasmJsTestruns the suite in headless Chromium against the prelinked module, with the generated externs checked against that module byscripts/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:
presentsASurfaceSessionToThePageCanvas,presentsABorrowedTextureAndThenASecondOne).std::bad_allocin a rendering update.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.Also unverified: the kotlin-test adapter hook uses the external namespace (
globalThis.kotlinTest.adapter); if kotlin-test falls back toBareAdapterinstead, 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