[prototype] TypeScript bindings for Node, Deno, Bun, ArkTS, and the browser - #553
Draft
sargunv-bot wants to merge 96 commits into
Draft
[prototype] TypeScript bindings for Node, Deno, Bun, ArkTS, and the browser#553sargunv-bot wants to merge 96 commits into
sargunv-bot wants to merge 96 commits into
Conversation
Adds the header-driven generator, the shared host-support shim, and the Node-API addon that together give the TypeScript binding one call ABI both its transports can use. JavaScript cannot pass or receive a C struct by value and cannot spell a wasm32 struct lowering, so every entrypoint is normalized to an entrypoint id plus an array of eight-byte slots. The dispatch, the layout tables for both ABI classes, and the schema fingerprint are generated from the public headers and checked in; generated static assertions compile for each target, so a layout table that disagrees with the compiler fails the build. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Gives the TypeScript binding memory the C API can address, one call path, and the handshake that decides whether an installed runtime payload describes the same ABI this package was generated from. Slabs are ordinary ArrayBuffers whose address the addon reports, so one allocator and one struct encoder serve both transports. Addresses cross as bigint: Android tags heap pointers in the top byte, so a pointer converted through a JavaScript number loses bits the allocator requires. A failing call copies the C API's thread-local diagnostic before returning, because reading it afterwards would read whatever the next call left behind. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Gives the TypeScript binding its first public surface: the loaded library, a thread-affine runtime, pump and poll, and the copied event values a poll returns. Handles carry the specification's live/releasing/closed state, so a released wrapper reports its own closed-handle error before crossing into C, a failed native destroy leaves the handle usable for a retry, and a wrapper collected while still open is reported as a leak rather than destroyed from whatever context the collector chose. A wake source moves between host contexts through a token the native library issues rather than through its handle id, because an id is copyable data and every receiver that copied one would become an owner. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixes what an external review found in the first slice. A struct-returning entrypoint stores through slot 0, so the generated metadata now names the record the caller must supply storage for, the TypeScript side refuses a call whose storage is absent or too small, and the shim rejects an address that is null or misaligned rather than storing through it. The staged payload gains a payload-relative rpath, so a package that moves loads the native library it ships rather than the one in the build tree. The handshake covers the support contract as well as the public headers, because a transport that changed `call` would otherwise pass a fingerprint built only from the C API. Slabs that empty go back to the transport. Transfer tokens stop at the end of their sequence rather than wrapping onto a live one. An OpenGL payload records opengl rather than the context provider that reaches it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Completes the phase-one public surface: a map created from its runtime, style loading by URL and by document, the loaded document read back through the C API's size-probe copy-out, and the camera family. Camera options carry the C API's field mask, so an omitted field and a field set to zero stay different values in both directions. A map retains its runtime, so closing a runtime with live maps fails rather than leaving them naming a released object. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Builds one TypeScript source into ESM and CommonJS with declarations, and tests that a consumer reaching the package either way drives the same native library through the same names. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The adapter's log and queued-provider listeners took only the record, and a retirement arrives as a null record, so a host whose listener is one C function for every registration could not tell which registration a delivery or a retirement belonged to. Dart escaped it by minting a native callback per closure; a host with no closures to mint could not. Each listener now takes the registration's own listener_data, which the registration carries beside its listener. Retirement stays the null record: it is unambiguous once the context is there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MapLibre logs from its worker, network, and rendering threads and asks the callback whether it consumed the record. JavaScript can neither run there nor answer in time, so the C callback adapter answers with the registration's fixed policy, copies the record, and hands it to a listener that returns void. The support layer queues that record and wakes this execution context through a non-blocking thread-safe function; the drain decodes it, runs the host callback inside a boundary that contains its failures, and destroys the record exactly once. A registration's native state is released when the adapter reports it retired, not when the host stopped using it, because native code can still be inside a delivery until then. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A public wrapper's handle state and the loaded library's transport were reachable through members marked internal, which is documentation rather than access control. Public code could build a second wrapper for one native handle and destroy it twice. The association now lives in module-private maps, and every wrapper is constructed through its own module. A wake source in transit is an owned object rather than a loose ArrayBuffer. Between issuing the token and adopting it, nothing owns the handle, so a transfer that is never posted or never adopted now releases it — explicitly through discard, or through the collector when the host drops it. Public numbers are checked for representability before they are encoded, because a DataView write narrows modulo the target width: a width of 2^32 + 1 would have created a one-pixel map. Loading checks the C ABI version before it hands back a library. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A map-sourced event carried the id of the map that produced it and no way to reach that map, so a host running several maps could not tell which one an event belonged to. Each map now carries a copied identity, and the runtime resolves an event's source to the live wrapper through weak references it holds itself. An event for a map that has already closed carries its identity and no wrapper, because there is nothing live to name. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MapLibre asks what a URL should become on one of its own threads, with the answer due before the call returns, so a JavaScript handler is too late. The adapter evaluates a table of rules instead, and this exposes that table. The C API borrows a rule table for as long as the registration lives, so the table, its rules, and their strings are allocated for the registration and released only once the call that replaced or cleared it has returned. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MapLibre asks whether a provider will serve a request on its own thread, with the answer due immediately, so the routes answer there in native code and a claimed request is copied and handed to the handler here. Everything no route matches passes straight through to the native loader. A claimed request is answered whenever the handler can: inline, or long after the callback returned, because the request handle outlives the record. The completion path is consumed before the call reaches C, so a native failure cannot become a second completion, and a request the host gives up on is released rather than left outstanding. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds the projection helper the C API exposes, plus the two process-global conversions that depend on no map. A projection owns the camera and viewport snapshot it was created from, so it keeps answering after the map it came from closes; it is the one handle whose validity does not follow its parent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MapLibre holds a style value as a tagged alternative and an object as an ordered list of members that may repeat a name, and it reads some values only from one alternative — a cluster id that arrives as a double reads as absent. A plain JavaScript object expresses none of that, so a value crosses as the model MapLibre itself holds, with the full signed and unsigned 64-bit domains. Style documents keep crossing as text, because native holds a document as the bytes it parsed. On top of the value model this adds adding, finding, and removing style sources and layers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The schema fingerprint covers the support contract, so a change to mln_abi.h changes it — but the generate task did not list that header among its inputs, and a stale fingerprint reached the tree. The handshake would have refused the payload it described. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The suite is a tree of named cases with plain bodies rather than framework calls, and each runtime's runner registers the same tree. A case that passes under one runtime and fails under another is then a difference between the runtimes rather than between two suites. All three load the same compiled Node-API addon. Deno needs the FFI permission to load a native addon at all, and a local node_modules directory to resolve the runtime payload package. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A payload is named for the target and the public render backend it carries, because MapLibre Native compiles exactly one renderer per build. Discovery tries the published names and falls back to a checkout's locally staged payload, so an installed one wins over a development one. Each runtime's test runner is written against that runtime's own globals and module specifiers, so the runtime that runs it checks it rather than the shared project pulling in every runtime's type packages. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The same public layer now runs over a WebAssembly module and over the Node-API addon, and the same conformance cases run against both. What differs is only what a pointer is: every address is an offset in the module's one linear memory, so a slab is a region of that memory rather than a buffer of its own, and library-owned memory is reachable through the same views instead of through a copy call. The module is a portable JavaScript-host build. It names no DOM global, so Node instantiates the artifact a browser would. Its HTTP transport is Emscripten's fetch, which needs a host adapter outside a browser, so the conformance cases provoke logging through a malformed style rather than through the network. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The payload needs the Emscripten SDK, which is a multi-gigabyte tool an environment may leave out, so the default test task runs Node, Bun, and Deno and the browser target runs the same conformance cases over WebAssembly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A session drives one map's rendering into a target the host owns, and the binding takes no ownership of it: a native pointer is an address the host already has, and the host keeps it valid for the window the C API documents. A session is affine to the context that attached it rather than to the map's, so a host may render from a context that never touches the map. The C API keeps the map alive by rejecting a destroy while a session is attached, so the session holds no parent reference of its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An asynchronous record reaches the callback after the code that logged it has moved on, which is why MapLibre keeps errors synchronous by default. The mask combines and tests, and an unknown bit passes through for the C API to reject rather than being rejected here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Offline work runs against a database, so every operation is a command: the call reports acceptance and an id, the completion arrives as a runtime event naming that id, and the result is taken afterwards. Taking transfers ownership once, and a take that fails leaves the operation there to retry. A region's metadata belongs to the list handle, which the take releases, so the bytes are copied before it does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pixels are premultiplied RGBA and are copied at the boundary, so a caller may reuse or mutate its buffer afterwards. A buffer too small for the extent it claims is rejected here, because the alternative is reading past it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The default vitest config excludes the WebAssembly suite, and an exclude wins over a filename on the command line, so the task that exists to run it was running nothing. It has its own config now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three defects an external review found, all of which would have surfaced as something other than what they were. Linear memory came from `HEAPU8`, which Emscripten refreshes only in the agent that grew it, so a MapLibre worker growing memory left this agent reading a buffer that ended before the record it was handed. It comes from the memory object now, which always names the live buffer. A foreign read used `slice`, which clamps rather than rejecting, so a stale pointer became a short copy that failed later with a message naming nothing. Reads are bounds-checked, and a C string must find its terminator inside the module's memory. The notification sink was stored and never read: nothing on the WebAssembly side could wake this agent, so a record produced by a worker waited for the host to ask. A pump now delivers what arrived, which is where a host returns from waiting anyway, and the transport says plainly that it schedules nothing. The payload is also described for what it is: the whole binding API works in Node, Bun, and Deno, but the module's default resource loading is Emscripten Fetch, which is XHR, so a non-browser host serves resources through a provider. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Feature state lives on the renderer rather than on the map, so it belongs to a session: a resize that retires the renderer starts it empty again. The selector carries the C API's field mask, so a vector source's layer and a single state key are each present or absent rather than empty. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each callback family owns its records differently — an adapter destroy for a log record or a claimed request, plain free for a copied tile — and the shared layer had to know which. Naming the kind is enough now, and the third kind the custom geometry source will need is already there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MapLibre asks a custom geometry source for a tile on one of its own threads and expects nothing back, so the request is copied and the handler runs on the host's own context. The host answers whenever the data is ready. Answering needs GeoJSON as MapLibre holds it rather than as text: a geometry is a tagged variant, a feature's properties are ordered members that may repeat a name, and an identifier keeps the alternative it arrived as. The whole descriptor graph is written into call-scoped storage, because every interior pointer is borrowed only for the call. The retirement sentinel arrives as a tile at a zoom no real tile uses, which is what releases the host state behind a source after its in-flight fetches drain. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The data crosses as the descriptor graph MapLibre holds rather than as text, so an integer keeps the alternative it arrived as and an object keeps its member order. Replacing the data keeps the options the source was added with. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MapLibre stops wanting a request when the map is pointed elsewhere and does not tell the host, which is why a provider that takes its time asks before doing the work. An answer that arrives after that is refused, and the refusal carries the library's own status and diagnostic rather than something this binding decided. 76 of 99 proven. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A future library can shrink or replace a payload, and no call this build can make produces one, so the decoder is handed a short payload directly. It keeps the bytes it was actually sent rather than reading the fields this build expects past their end, and the same payload at full size still decodes, so the guard is a size check and not a decoder that never runs. 77 of 99 proven. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing a caller can do makes a native destroy fail, so the conformance suite arranges it. `internal/faults.ts` queues a status per entrypoint and `checked` reports it without running the call; no shipped path reads it, and a normal call sees one branch that is never taken. A map whose destroy refused is still live and still usable, because nothing was released — treating it as gone would leak it — and a later release closes it. The WebAssembly thread pool goes to 192. A browser cannot grow it, and the suite had outgrown 64: the symptom is the whole run hanging with no per-test timeout able to fire, because the blocked thread is the one that would run them. This was misread twice as the change under test being at fault. 79 of 99 proven. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The session still holds the frame, so the wrapper must too: one that marked itself released would leave the session holding a frame nobody can give back. A later release succeeds. This case was written once before and removed for hanging the browser. The hang was the thread pool, not the frame. 80 of 99 proven. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Taking an offline result acquires a native list and copies out of it. A copy that fails must still give the list back; no call a caller can make fails there, so it is arranged. The runtime then closes cleanly, which a leaked list would prevent. 81 of 99 proven. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A handle's native id lives in a private field of its own class. No code outside that class can reach one to pass it to another kind's operation, so forging the mismatch means defeating the language rather than the binding. Every specification case this binding can prove is now proven. The two left are the HTTP header transforms, which need the adapter's callback family wrapped: 81 proven, 16 that cannot happen here, 2 waiting on that feature. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The last two specification cases needed a feature, not a test. The adapter takes a rule table rather than a callback, so this mirrors the resource rewrite rules beside it: the first rule whose URL matches supplies its whole header list, and names and values are copied at registration because native code reads them for as long as the table is installed. The library refuses the feature on WebAssembly, where a browser's fetch follows redirects itself, and on OpenHarmony, whose HTTP client cannot stop transformed headers crossing origins. That is a host capability rather than a transport property — OpenHarmony runs the same transport Node does — so the cases name `httpHeaderTransforms` and the hosts that have it declare it. 83 of 99 proven, and nothing is left unwritten: the other 16 describe behaviour that cannot occur in this binding, each with the reason. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… C has it An external review found both. The registry chose a destructor with a two-way branch: log records one way, everything else through the resource-provider request destructor. A custom geometry record is allocated with `malloc` and freed with `free`, so it was reaching the wrong destructor — type confusion across the boundary. The shim already dispatches on the kind and the transport already exposes it; the registry now uses that instead of deciding for itself. `ResourceRequest.complete()` marked itself settled before encoding the response. A response this binding refuses never reaches C, so the native request was still outstanding while the wrapper reported it answered: nobody could complete it and nobody could give it up. It is settled once the completion reaches C. The regression case fails without the second fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
BND-044 was called inapplicable on the grounds that this binding runs no native release from a finalizer. It does not release there, but it does report: `internal/handle.ts` installs a FinalizationRegistry that reports a leaked handle, which is the behaviour the case describes. It is unwritten, not inapplicable, and saying otherwise made the coverage number look better than it was. BND-192 was filed under "no second thread". It applies to a binding that ships an owner-thread execution adapter, and the reason this one does not run it is that it has no such adapter. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two external reviewers, independently, found the coverage number inflated: the check verifies an identifier is accounted for, not that the tagged case proves the requirement. Thirteen tags claimed more than their case showed. Those tags are gone. Each now says what is actually missing — a released id replayed rather than an unknown one, one test per input-struct family rather than examples, every option type rather than camera, teardown across reload and reuse rather than registration, headers observed on a request rather than a request arriving. A partial proof recorded as a whole one is worse than no proof, because nobody goes back to it. Two of the thirteen are now genuinely proven: a released offline operation id is refused after a new operation exists, and a queried feature's strings outlive the native result they were copied from, which is destroyed before the query returns. BND-088 is inapplicable rather than unwritten: it wants a park released from another thread. 73 of 99 proven, honestly. 16 cannot happen here. 10 are unwritten and say what they need. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The old case admitted MapLibre might never ask for a tile, so it never delivered a custom geometry record — which is how the wrong-destructor defect survived on that exact path. Rendering is what makes MapLibre ask, and the browser can render now. The case asks, answers, then removes the layer and source and keeps drawing: nothing arrives for a retired registration. The same source id is used again and the new registration receives while the retired one still does not. Closing the map delivers nothing afterwards. 74 of 99 proven. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The old case compared camera options and was tagged for all of them. This takes each value type this binding exposes and changes one field at a time, so a comparison that ignored a field is caught rather than one example standing in for the rest. Absence is expressed by omitting the key, which is the distinction the case is about and which this package's strict optional types already enforce. 75 of 99 proven. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The old tag stood on a few examples. This takes the families from the generated tables rather than a list here, so a family added to the C API joins the case by existing rather than by somebody remembering. Each is initialized from its library defaults, reports the size this build expects, and starts with no field claimed. A family with no size field is collected and reported rather than skipped, so the assertion covers the whole set instead of whichever members happened to have one. 76 of 99 proven. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both describe data crossing into user code through a transform callback. This binding installs the adapter's native rule tables instead — for URL rewrites and for HTTP headers — so no transform request data reaches user code to be copied. The requirement is vacuous here rather than unmet, and each entry says which choice makes it so, because a binding that grew a callback transform would have to claim them. 76 proven, 18 that cannot happen here, 5 unwritten. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every unwritten entry now says what stands in the way rather than what is missing in general: the offline region create, delete, and status entrypoints exist in C and are not wrapped; the surface attach path needs a host with a native window, which no runner has; proving a transformed header reaches a request needs an HTTP endpoint the suite controls, because nothing exposes the headers a request carries. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The binding wrapped only the region list, so there was no region state to observe. `startOfflineRegionStatus` and `takeOfflineRegionStatus` complete the smallest slice of that: an operation id, a completion through the runtime event model, and a status copied out as public values with counts keeping their full domain. Whether a missing region is an error or an empty status is the library's decision; what this binding owes is that the answer reaches the caller either way rather than being invented or lost, and that a spent operation cannot be taken twice. 77 of 99 proven. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An attempt: a `node:http` endpoint served a style, a transform rule matched its URL, and a map was pointed at it with `setStyleUrl`. After ten seconds of pumping the endpoint had never been contacted, so the header was never the obstacle — whatever issues an HTTP request for a style is not running under the conformance runtime. The endpoint machinery is not kept, because a capability nothing uses is clutter. The note now says what was tried and what it found, so the next attempt starts from the real blocker rather than repeating this one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three agents worked one case each, with file ownership kept apart so they could not overwrite one another. The leak channel. `internal/handle.ts` installs a FinalizationRegistry, and a test that waits for a collection is a test of the collector. The registry is now constructed with the same function a case can invoke, so the case proves what that path does when it runs: a dropped handle is reported and left alone, the native object still answers, and an explicit release still closes it. A release that native refuses is reported through the same channel and a retry closes it. The surface attach path. The note claiming it was unreachable was wrong. `mln_opengl_surface_attach` in the WebGL build validates the whole descriptor before reporting unsupported, so a browser can drive the surface descriptor across the boundary and read native's own answers. Each family's C entry point is made to refuse in turn and the error names that exact function, which is what says a path reached its own family. Deleting the surface handle write makes the case fail, and nothing else in the suite caught that. Transformed headers. The library was making the request all along. `pump` blocks inside native code, so a synchronous pump loop starves an in-process origin: the host never gets a turn to accept the connection. Yielding between pumps lets the request arrive, and the case reads the headers that actually landed as the table is installed, replaced, and cleared. 81 of 99 proven. The other 18 cannot happen in this binding, each with the reason. Nothing is left unwritten. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A registration is made before the add call, because native code needs the listener addresses to install. A call that refuses left it in the registry holding the handlers, and through them the map, for the runtime's life. Retirement alone is not enough here: it waits for native's own sentinel, which never comes for an identity native never received. The registry now takes a discard for that case, and the case fails without it. Found by an external review. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The browser suite reached a working runtime through `internal/wasm-transport` and `Maplibre.fromTransport`, neither exported, against a payload with no package. That proved this repository could assemble a browser runtime, not that anyone else could. `@maplibre/native-ffi/browser` is its own entry point, so a server never pulls WebAssembly loading in. It carries `createWebGlContext` because a page otherwise cannot build a render context at all: Emscripten addresses a context by the number it assigned, so one from `canvas.getContext()` is unusable and the only route was the module's undocumented GL registry, which is what the test runner had been doing. The payload is a package like the Node-API ones, CI packs it, and cross-origin isolation is stated where a consumer meets it, including a pre-flight check that names both headers. Proved by packing both packages, installing the tarballs, and running a live map three ways: package discovery, `require`, and a served `moduleUrl`. The conformance harness is no longer exported from the package. A test locks both halves, and asserts `Maplibre` is still there so it cannot pass by exporting nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A tile server runs one runtime per worker, which is the shape the C API asks for: each owner thread may hold one live runtime. Node workers are separate JavaScript realms but share the loaded library, so every realm minted registration identities from 1 against one process-wide queue and one notifier slot. Both halves were reproduced before anything changed. Worker 2 registering displaced worker 1's notifier, so worker 1 stopped being woken. And a record carrying identity 1 was resolved by whichever realm drained first against its own map, deleting and retiring a live registration belonging to the other realm, freeing state native still held. Which realm lost depended on load order. The shim now mints identities and keys the queue and notifier by an owner. The identity is pointer-width with the owner in the high half, because `listener_data` crosses as a `void*` and a 64-bit identity would lose its top half on wasm32. Routing is arithmetic rather than a table, a stale identity is simply not found, and exhaustion is reported rather than wrapped onto a live value. `Maplibre.close()` exists now, which it did not before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closing the facade while a runtime was live succeeded, and the runtime then reported a closed handle from `pump`, because pump drains the registry the close had destroyed. That is the failure arriving at the wrong call. The specification is explicit: releasing a parent while children are live MUST fail without consuming or destroying the parent. A runtime drains this context's registry, so the facade is its parent, and this binding already answers `childrenLive` for a runtime closed while a map is live. The check runs before anything is released, so the refusal consumes nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The runner compared values by serializing them, and wrote a bigint as the digits alone, so `7n` and the string `"7"` compared equal. Every address in this binding crosses as a bigint, so a case comparing one could pass against a value of the wrong type. The ArkTS runner already marked them; this one now does too. Found by an external review, which reported both runners; only this one was wrong. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A host stopped notifications and dropped the boxed threadsafe function straight after, while a MapLibre thread could already be between reading that notifier and calling it. The producer then called into freed memory. The window is small, so it read as a flake rather than as a use after free, which is what an ASan build proves it is. The support layer now counts the notifier calls each owner has begun and finished, and whoever replaces a notifier or destroys an owner waits for the calls that began before it. Two monotonic counts rather than one in-flight count: a steady stream of records would hold an in-flight count above zero forever and never let the waiter go. The wait happens under the queue lock the producer drops before it calls, so a wake cannot deadlock against the thread that produced the record. `destroy_callback_owner` now drops the box too. A host that destroys an owner without stopping notifications first is still done with it, and leaving the entry in the list kept it alive for a process that never asks about that owner again. Proved by `//bindings/typescript:test:notifier-lifetime`, which builds the shim under ASan with a hook that widens the window so the race lands every time. It passes with the wait and fails without it. Closes maplibre#540. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The render-session cases only ever ran over WebAssembly, because a page has a WebGL context and a server process has nothing. That left the whole render domain unproven on the three runtimes most consumers will use, on the reasoning that a native host has no context — which is not a fact about the host, only about what the suite was willing to build. The suite now asks a driver for one. `openHeadlessOpenGl()` opens a surfaceless EGL display, makes a context and a pbuffer, and hands back the context, a texture factory, and the surface. Each runtime reaches libEGL the way it can: `bun:ffi` under Bun, `Deno.dlopen` under Deno, and koffi under Node, which has no FFI of its own. koffi is a devDependency of the api package, so nothing a consumer installs grows by it. It is not used under Bun because declaring a function at module scope and then triggering a collection aborts Bun 1.3.9 outright, reproduced in six lines with no MapLibre code involved. The fixture is asked for only when the loaded build carries the OpenGL backend, and a host with no driver reports no context, so the render cases stay registered by capability rather than by runtime. `hostSurface()` joins the case context because a pbuffer is a real thing to present through. BND-162 takes the descriptor all the way where one exists — a session attaches and answers an update — and keeps the refusal path for the hosts that have none. That is a stronger statement than any refusal, and the only one that says the surface arm of the context union is written correctly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pool went to 192 to stop the browser suite wedging partway through, on the reading that a runtime leaks a thread. It does not. A closed runtime's threads exit, but the worker each one ran on returns to the pool through a message, and a message is only delivered when the closing thread yields. Awaiting a promise is not a yield: a promise settles on the microtask queue, which runs to exhaustion before the browser looks at its messages. So the suite took workers for 114 cases straight and gave none back. Measured by exporting `PThread` and counting: forty create-and-close rounds with no task boundary hold forty workers, and all forty come back within 100ms of one timer. The suite ends holding 67 for the same reason. The browser runner now waits on a timer between cases, which is the task boundary the reclamation needs. With that, the pool walks down to sixteen before the suite stops passing — one runtime with a map takes twelve to sixteen workers — so 64 ships instead of 192, roughly four times what a page with a map needs. That matters beyond the suite: 192 workers start before the module fetches its own WebAssembly, and on a host short of memory the fetch is what loses. `loadBrowser()` states the constraint where a consumer meets it. Ordinary event-driven code yields constantly and never sees it; a loop that opens and closes runtimes without reaching a task boundary would empty any pool that fits in a browser. 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
TypeScript bindings for the C API, shared across five runtimes — Node, Deno, Bun, ArkTS, and the browser — over one normalized C dispatch ABI, with per-target payload packages published from CI (#50).
Test plan
mise run //bindings/typescript:testand the per-runtime tasks: 128/128 on each of the three Node-API runtimes, 114/114 in Chromium, 102/102 over WebAssembly under Node, plus the ArkTS suite on an OpenHarmony device and an ASan test for the notifier lifetime.AI assistance