Phase 0 spike — results
Status: complete, GO — October 2026. Machine: Arch Linux, x86_64, glibc 2.44, Bun 1.4.3, gcc 16.2.1 (clang broken on this box: missing
libLLVM.so.23.1), no system GEOS. Every check below is reproducible viaspike/setup.sh+bun test spike/(23 tests, all green). Results doc by check:
Exit criteria
| Criterion (ROADMAP Phase 0) | Result |
|---|---|
| Overlay roundtrip with correct results, zero crashes | ✅ exact known-answer (triangle (1,1),(3,1),(1,3), area exactly 2.0) + 500 seeded random triangles |
| Error path surfaces a real GEOS message through a thrown error | ✅ "ParseException: Expected number but encountered end of stream" → GeosError |
| No crashes across a 10⁵-geometry soak | ✅ 100k build/overlay/destroy cycles in 0.66 s (~150k ops/s); RSS flatlines after warmup (0.0 MB/epoch tail) |
| FFI value-roundtrip set (the #44906 class) | ✅ no silent mis-coercions on Bun 1.4.3 (details below) |
libc gating on Linux optionalDependencies |
⚠️ Bun ignores libc (npm respects it) — loader must self-select; see F2 |
bun build --compile consuming an embedded .so |
⚠️ works; leaks one temp file per run (see F9) |
cc()/TinyCC dev fallback |
❌ dead as designed — TCC rejects __thread (see F7) |
Neither kill switch fired. Phase 1 is unblocked.
F1 — dlopen + flat-buffer roundtrip (the core bet)
Float64Array → GEOSCoordSeq_copyFromBuffer_r → ring → polygon → GEOSIntersection_r →
copyToBuffer returns bit-identical coordinates. TypedArray-passed-as-ptr is genuinely
zero-copy: C reads live memory (JS mutation visible across calls), and the address C
receives, XOR-masked and recovered, reads back the buffer's contents. Roundtrip identity,
exact-area overlay, and randomized triangle properties all hold.
Carry-forward: the D9 flat-buffer currency works exactly as designed; nothing to change.
F2 — libc gating is ignored by Bun (packaging impact)
Empirical A/B on real packages: bun add sharp@0.35.5 on this glibc machine installs
both @img/sharp-linux-x64 (libc: ["glibc"]) and @img/sharp-linuxmusl-x64
(libc: ["musl"]); npm installs only the glibc one. os/cpu gating works in Bun;
libc does not. Consequence until Bun changes: Linux users of @shzd/bun-geos download
both the -gnu and -musl platform packages (~2× size for Linux only).
Loader mitigation (verified available): process.report.getReport().header.glibcVersionRuntime
→ "2.44" in Bun 1.4.3 — clean glibc detection in JS; pick the matching variant, keep the
runtime wrong-libc dlopen error (with remediation text) as backstop. Keep publishing libc
fields — npm users still benefit.
F3 — DT_RUNPATH is not transitive (loader/packaging)
shim linked with -Wl,-rpath,$ORIGIN/... resolved its own libgeos_c.so.1, but
libgeos_c's dependency libgeos.so.3.14.1 was not found: RUNPATH only applies to the
owning object's direct dependencies. Two fixes, both verified:
-Wl,--disable-new-dtags→DT_RPATH, which the loader consults for the whole chain (used for the shim); and- baking
$ORIGINinto the libs themselves via-DCMAKE_INSTALL_RPATH='$ORIGIN'(used for the install tree; CI packaging can use patchelf to the same effect).
A pure-JS fallback also works if ever needed: dlopen libgeos.so.3.14.1 by absolute path
first; the later libgeos_c load resolves its NEEDED soname against the mapped library.
F4 — JSCallback as GEOS handler demonstrably loses the message (D4 confirmed)
With a JSCallback((fmt) => new CString(fmt)) installed via
GEOSContext_setErrorHandler_r, the JS side received "%s" — the format string, not
the message. GEOS internally invokes the old-style handler as handler("%s", message), so
the text lives in the varargs, unreachable from a non-variadic JS callback (and the call
site is UB per C standard regardless). The shim's C-side vsnprintf captures the full text.
D4 is no longer just theoretical — it is empirically load-bearing.
F5 — FFI coercion table (Bun 1.4.3, linux-x64)
i32/u32/i16exact at boundaries (INT32_MAX,UINT32_MAX, ±INT16_MAX/MIN).f64bit-exact across 1e-300…1e300 and2**53;NaNround-trips.- The #44906 class does not reproduce: a fractional JS number into an i32/u32-typed arg truncates C-style (
3.7 → 3), never the reported silent0. - Out-of-range wraparound documented (
2**31into i32 →-2147483648) — GEOS u32 sizes stay within range in practice; keep them declaredu32. boolround-trips.
Carry-forward: keep the per-Bun-release smoke test in CI (D1); the table in
spike/ffi-values.test.ts is the seed.
F6 — FFI declaration gotchas (feed src/symbols.ts)
const char*args must be declaredcstringto accept JS strings; declaredptr, the engine rejects strings (TypeError: To convert a string to a pointer…).- Failed
ptrreturns arrive as JSnull, not0— all null-checks in TS must be=== null/falsy, never=== 0. GEOSversionis the one C API function with no_rvariant (process-global, static string, nothing to free).- Pointers are plain numbers (52-bit) as documented; storing them in typed arrays works.
- Bun quirk:
process.memoryUsage.rssis a function (callable directly); useprocess.memoryUsage().rssfor the Node-style call.
F7 — cc()/TinyCC fallback is dead as designed
Bun's embedded TCC (tinycc 05f0fafa) rejects thread-local storage at runtime:
error: thread-local storage is not supported at run time. Thread-local error/notice
capture is load-bearing in the shim (per-worker contexts, D3), so the fallback cannot
compile the real shim on linux-x64. A #ifdef SHIM_NO_TLS variant (plain globals) could
serve single-threaded dev, but is not worth building: real-compiler iteration is one gcc
line and production is CI-prebuilt. Decision: drop cc() from the plan-of-record; dev
compiles with the system compiler via spike/setup.sh (Phase 1: a bun run build:native script).
F8 — the wrapper-pointer lesson (our bug, caught by the soak process)
Passing the shim's GeosShim* wrapper where GEOSContextHandle_t was expected "worked"
for several calls (first 8 bytes happen to alias the ctx) and then segfaulted Bun with a
fixed bogus address. Real compilers and gdb masked it differently — the pure-C driver using
s->ctx was always correct. Fix / rule: the shim exposes geos_shim_context(); JS passes
that handle to every geos_c function and the wrapper only to geos_shim_* functions.
Phase 1 codifies this in the Geos class (one opaque handle, one place).
F9 — bun build --compile + embedded .so
A --compile binary dlopens an embedded .so from /$bunfs/root/values-*.so: works,
runs from any cwd, load+call ≈ 0.1 ms. But each run leaves .bun-<uid>-<hash>.so in
$TMPDIR (full extraction; verified with a fresh TMPDIR) — #40076 live on 1.4.3.
Not load-bearing for our distribution model (users bun add; only user-compiled apps hit
this). Document per Bun version; re-verify each release.
F10 — bun[unknown component: ffi] string-return semantics on engine-native Bun 1.4.x
Bun 1.4.0 rebuilt bun:ffi inside JavaScriptCore (the TinyCC thunks are gone
from the FFI call path; hot call sites JIT to direct native calls) and changed
string returns. Verified in spike/cstring-semantics.test.ts on 1.4.3:
returns: "cstring"now yields a plain string — no.ptr,.byteLength, or.arrayBuffer— and a NULL return arrives as JSnull(asserted via the shim's fresh-sessiongeos_shim_last_error).cstringon a GEOS-OWNED buffer leaks. 6,000GEOSGeomToWKT_rwrites of a ~36 KB WKT grew RSS linearly at ~85 MB/epoch (257 MB total): the engine clones the text and can never callGEOSFree. The pre-1.4 escape hatch (reading.ptroff the returnedCString) no longer exists.- The rule that does not leak: bind the return
"ptr", view withnew CString(ptr)(plain string; the C buffer is untouched), thenGEOSFree_r(ctx, ptr)— RSS flatlined (0.3 MB/epoch after warmup).
Binding rules for src/symbols.ts (supersede the F6-era cstring-return
note):
const char*args →cstring(unchanged; auto UTF-8 encode).- Returns that need no free →
returns: "cstring"(GEOSversion's static string; the shim's thread-locallast_error/last_noticebuffers). - Returns GEOS owns (every writer: WKT/GeoJSON outputs) →
returns: "ptr"+CStringview +GEOSFree_r(modeled bytoWKT()inspike/lib.ts).
The leak test is a guarded verdict: if a future Bun frees the buffer, the test trips and rule 3 can be relaxed.
Soak numbers
- 100,000 × (2 polygons built from flat buffers + intersection + ring copy-back + 3 destroys) in 0.66 s.
- RSS: ~11–20 MB warmup (JIT + malloc arenas), then 0.0 MB/epoch — no leak signature. Assertions: total growth < 150 MB; second-half growth ≤ first-half + 20 MB.
- Error-path soak: 1,000 consecutive malformed-WKT failures through the shim — every one thrown as
GeosError, no degradation, valid ops unaffected afterwards.
Environment pin for reproducibility
- GEOS 3.14.1 (tag), built Release, shared,
-DCMAKE_INSTALL_RPATH='$ORIGIN', gcc 16.2.1 / CMake 4.4.4 / ninja 1.13.2. - Bun 1.4.3 (
bun test spike/— 23 tests). - Shim compile:
gcc -O2 -fPIC -shared -Wall -Wextra native/shim.c -o spike/build/shim.so -Lspike/build/geos-install/lib -lgeos_c -Wl,--disable-new-dtags -Wl,-rpath,'$ORIGIN/geos-install/lib'.
Carry-forwards into Phase 1
native/shim.cgraduates as-is (66 lines: init/finish/context/last_error/last_notice, thread-local capture).src/symbols.ts: cstring-decl rule for everyconst char*arg; owned-buffer returns boundptr+CStringview +GEOSFree_r(F10 — a cstring return leaks them);GEOSversionbound without_r; null-not-zero pointer checks.- Loader: prefer shim DT_RPATH; keep
BUN_GEOS_LIBRARYescape hatch with the 3.14 assert; libc self-selection viaglibcVersionRuntime(F2). - Packaging (Phase 4): keep
libcfields (npm benefit), accept Bun's double download, CI bakes$ORIGINRPATH into all three artifacts, link shim with--disable-new-dtags. - Drop cc() from the plan-of-record (F7); dev/native build script instead.
- Tested pin: Bun 1.4.3 + GEOS 3.14.1; FFI smoke table gates every Bun bump.