JAB-036: qjab — port the jab runtime from JavaScriptCore to quickjs-ng

Now
OPEN
Ask
gritzko
Rep
///jab
See
JAB-035

jab is a thin C shell around its engine: 172 JABC_FN leaves, but the raw JSC calls already funnel through JABC.hpp macros, the arg.cpp gates and the JS-108 string helper. Meanwhile JSC is 90% of the shipped bytes and ALL of the release pain (JAB-035's static job). quickjs-ng is a 1 MB engine with its own compact unicode tables — no ICU, no Intl, builds in seconds. This ticket inventories the actual engine surface and plans the port.

Input

Context

Motivation, measured 2026-08-11 on the JAB-035 static path; engine choice: quickjs-ng (active fork) over Bellard quickjs (maintenance) and MicroQuickJS (mcu-class trims, too restricted for beagle's JS).

The JSC C-API surface, counted 2026-08-11 (///jab#, 13 .cpp + 13 dog-binding .hpp):

Goals

A qjab that runs the whole be surface — jab suite 115/115, be suite 375/375 — and collapses the static release to a plain ubuntu job.

Constraints

WIP

Design decisions

Port shape ORDERED by gritzko 2026-08-11: a separate all-C tree /quickjab (cmake), quickjs-ng v0.10.1 vendored+pinned under quickjab/qjs/; jab/ stays untouched and IS the oracle; gate = jab's own test/*.js suite run unmodified in place by the quickjab binary; goal = lighter in code AND binary. This supersedes the in-tree dual-build shape below (kept for the mapping notes) and settles the "separate binary vs build flavor" blocker: separate binary.

Earlier funnel-in-place sketch: an engine-neutral JABC layer in JABC.hpp/arg.cpp with dual builds, NOT a clean cutover branch.

TODOs

Perf: pager todo path, measured 2026-08-11

time quickjab todo vs time be todo on a tty (journal repo, thinkcentre). Redirected (no pager) BOTH finish in ~0.15 s — the whole cost is the tty pager path arming the repo cache/watcher: identical syscall work in both engines (42k getdents64, 45k stat, 60k lstat, 30k read, 20,801 inotify_add_watch). The gap is pure engine CPU over that crawl. Output byte-identical in all runs.

binary first frame (pty)
be (jab/JSC, JIT) 2.8 s
quickjab, gritzko's build/ (NO -O flag) 7.4 s
quickjab -O3 7.0 s
quickjab -O3 + rpmalloc on the JS runtime 4.6 s

NOTE: quickjab/build/ was configured with an empty CMAKE_BUILD_TYPE — the daily binary is -O0. Barely matters here (7.4 vs 7.0) because the path is allocation-bound, not compute-bound.

SIGPROF flat profile of stock -O3 (7422 samples): 44% inside musl (malloc machinery 13%, memcpy 13%, mmap/munmap 5% — 65,588 mmap+munmap pairs vs be's 4k, musl mallocng punting big allocations to mmap), 19% JS_CallInternal, ~10% shape churn (js_clone_shape+js_free_shape0+add_property), 5.6% sha1dc.

Experiment (NOT landed, shadow tree only): rpmalloc 1.4.5 (single .c) wired as JSMallocFunctions via JS_NewRuntime2 — engine untouched, io/libdog still on musl malloc. Result: 7.0 → 4.6 s (-33%), ctest 114/114 green, pager output byte-identical, +20 KB stripped (2.33 → 2.35 MB). Vendoring rpmalloc is a new pinned dep = gritzko's call.

Counted (rp shims instrumented, QJ_ALLOC_STATS): one tty todo run = 29.3M js_malloc + 4.4M js_realloc + 29.4M js_free ≈ 63M engine allocator ops — vs 0.63M total on the redirected (no-pager) path, which prints the FULL 35 KB listing in 0.15 s with 214 getdents and zero inotify. All traffic is the engine's (JS objects/shapes/strings); libdog allocates nothing, confirmed by the swap only touching JSMallocFunctions. 1,700 engine allocs per stat'd file on the crawl.

Remaining 4.6 vs 2.8 gap, post-rpmalloc profile: 29% interpreter (the irreducible JIT gap), 14% shape churn — QuickJS re-cloning hidden classes, i.e. some crawl-path JS builds objects with varying property order or deletes; worth finding since fixing the JS pattern helps BOTH engines — 5% sha1dc, 16% musl (mostly memcpy).

FOUND 2026-08-11 by alloc-weighted JS stack sampling (interrupt handler at safe points, triggered every 1024th js_malloc): 100% of the allocation traffic is be/shared/util/ignore.js — glob/tryMatch/setDecide/setDecideDeep via wtScan→walk→match. Slice-recursion glob + per-file parent-prefix re-derive = 343 allocs/path in isolation, 1,700/file in the crawl. Fix ticket with the measured 55-115x alloc cut: STATUS-020 — helps be's 2.8 s too, so the accept threshold should be judged AFTER that lands.

Perf re-measure 2026-08-11, AFTER STATUS-020 landed

Same method (python pty harness, first byte to the tty = the first frame; journal tree, 5 runs, median, thinkcentre). Both binaries FRESH on the dog._igno_* C matcher; output byte-identical (7259 B) in every run. The matcher is NO LONGER the top term; the crawl's PATH MATH is.

binary first frame user sys minflt
jab (JSC, JIT) 2.95 s 2.95 0.65 85 k
quickjab (-O0) 5.97 s 4.70 1.67 1.03 M
either, piped 0.13 s 0.13 0.03 —

Native flat profile, SIGPROF 1 ms IP sampler via LD_PRELOAD, rolled up per module (jab 3302 samples, quickjab 6301):

term jab quickjab
JS engine + JIT 45.3% / 1.50 s 34.5% / 2.17 s
musl (mallocng) 21.0% / 0.69 s 43.5% / 2.74 s
sha1dc 20.9% / 0.69 s 11.5% / 0.72 s
jab + libdog C 11.5% / 0.38 s 9.8% / 0.62 s

JS-level phases, quickjab only: Error().stack captured at engine safe points, tick-weighted, CPU-clock and alloc-clock triggers (6632 ms / 2962 alloc samples):

phase cpu allocs
path/URI resolve 39.7% 36.7%
sha1 + wt blob hash 17.1% 17.9%
pager index/render (bro) 9.4% 8.8%
classify + ulog 8.8% 12.9%
ignore match (JS isMeta) 8.4% 7.5%
git object read 7.9% 11.9%
cache arm + fs walk 7.7% 4.4%

DOMINANT TERM: core/discover.js wtpath() — uri._parse(ctx) plus resolve_hash.resolve() over util/path.js split/resolveInTree/safeRel, re-run PER PATH. Counted with an instrumented uri._parse leaf: 203,535 calls in ONE pty todo (piped run: 2), ~2 per stat'd file.

Blockers and bummers

Open calls for gritzko, plus the known semantic risks.

Outcome

Landed 2026-08-11 in /quickjab (uncommitted, awaiting gritzko's review; no VC ops run): the all-C port passes jab's whole unmodified suite.