Design: memory & ownership
Status: describes the implemented system (0.1.0). Implements D10; consumes the ownership rules from GEOS research §4.
1. The two heaps
JS objects live in JavaScriptCore's heap; GEOSGeometry/GEOSCoordSequence live in the
C heap. JSC cannot see the C heap. Consequences:
- A million live geometries may occupy gigabytes while the JS wrappers look tiny → the GC never feels pressure and never runs.
- Finalizers exist but are unpredictable and hazardous at teardown.
Therefore: explicit disposal is the contract; the GC is insurance.
2. Ownership mapping
| C object | Created by | Freed by | JS representation |
|---|---|---|---|
GEOSContextHandle_t |
GEOS_init_r (shim geos_shim_init) |
GEOS_finish_r |
Geos (Symbol.dispose) |
GEOSGeometry |
constructors/readers/ops | GEOSGeom_destroy_r — by the creating context |
Geometry (numeric ptr + owning Geos) |
GEOSCoordSequence |
CoordSeq_copyFromBuffer |
usually transferred into a geometry (destroying the geometry frees it) | never exposed; transient |
| WKT/WKB/GeoJSON output strings | readers/writers | GEOSFree — never free() (Windows CRT mismatch) |
cloned into JS strings/Uint8Array at the boundary, C side freed immediately |
GEOSWKTReader/Writer, GEOSWKBReader/Writer |
create | matching *_destroy |
pooled per Geos, not per call |
GEOSPreparedGeometry |
GEOSPrepare |
GEOSPreparedGeom_destroy |
Prepared (using-able; holds source alive) |
| STRtree items | — | borrowed — tree takes no ownership | integer ids; Map<id, Geometry> in JS |
| Shim-internal buffers | shim | shim (destroy_many frees both geoms and the pointer array) |
invisible |
Two hard rules enforced at the API layer:
- Same-context destroy — a
Geometryused with a differentGeosthrowsTypeError(the Rust lifetime lesson, enforced structurally: D11). - Transfer semantics — construction functions take ownership exactly as GEOS does; the shim's builders free what they allocated and hand GEOS the rest.
3. Disposal discipline
// One-off: explicit dispose
const g = geos.readWKT(wkt);
try { /* … */ } finally { g.dispose(); } // or: using g = … (TS explicit resource mgmt)
// Hot loop: arena
using arena = geos.arena();
for (const w of wkts) {
const g = arena.read(wkb); // registered, not individually freed
results.push(geos.intersection(a, g));
} // ← shim destroy_many: one FFI call
// Batch op outputs can be arena-allocated too:
const merged = geos.unionAll(parcels, { arena }); // caller frees `merged` or the arena does
using/Symbol.disposeis the primary ergonomic path (Bun supports TS explicit resource management).- Arenas never free geoms they didn't create (only registration counts).
- Nested arenas are allowed; child disposal is independent.
4. FinalizationRegistry — backstop only
A registry maps Geometry → (ctx, ptr) and destroys orphans on collection. Constraints
documented on the API:
- It is a leak net, not a strategy: collection timing is unspecified; at process teardown FFI calls may be unsafe, so orphans at exit are reported, not freed.
Geometryfinalization never touches the STRtree (borrowed-pointer rule): trees hold ids, and treedispose()validates that registered geoms are still alive — stale ids are aGeosError, never a dangling pointer deref.
5. Making the invisible heap visible
Each Geos maintains counters fed by JS-side registration plus shim
crossings (D10; implemented per Phase 3's D24):
geos.stats— live geometry count, floor byte estimate, finalizer reaps, peak live count, arena high-water mark (Phase 3). Pure JS counting, safe on hot paths.geos.statsPrecise()(Phase 3) — ONE shim crossing over the live pointer set: 64 bytes + vertices × coordinate dimension × 8 per geometry. Data-derived but still documented approximate (M ordinates are not per-geometry detectable on the C API).
Both are included in leak-test assertions (test/soak/, the CI nightly job,
D24). This is the go-geos lesson (their README's "Memory management" warning)
answered with instrumentation instead of apology.
6. Pitfall checklist (encoded in tests)
| Pitfall | Guard |
|---|---|
free() on GEOS memory (Windows CRT mismatch) |
GEOSFree only; shim wraps all frees |
| Destroying a geometry with a foreign context | wrapper throws TypeError |
| Tree outliving indexed geoms | id registry + dispose-time validation |
| STRtree item pointers stored raw in JS | items are integer ids, never pointers |
WKB output Uint8Array aliasing C memory |
boundary always copies; C side freed immediately |
| Geometry used after dispose | wrapper flags disposed; use throws (debug) / is a no-op-safe error |
| Borrowed view used after its parent was disposed | view holds a weak parent ref; use throws TypeError (Phase 1, audit H1 — GEOS has no liveness query, so this is wrapper-side) |
| Finalizer ABA: stale queued finalizer destroys a recycled address | live-map entries carry the owning token; reap/destroy/renounce verify token identity before freeing (Phase 1, audit C1) |
| Transferred collection parts freed twice (duplicates / borrowed views) | fromParts(transfer) rejects duplicates and non-owned parts up front; renounce runs before the (throwing) take (audit C2) |
| Finalizer re-entering FFI at teardown | finalizers never run during process exit path; orphans reported |