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.
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).
libicudata.a, SIGABRT without data) are all JSC-forced.grep 'Intl\.' be/ --include='*.js' = ZERO hits (and zero Promise/async/await anywhere in be/ or jab's own JS) — the 13 MB ICU rides for nothing be uses.The JSC C-API surface, counted 2026-08-11 (///jab#, 13 .cpp + 13 dog-binding .hpp):
JS[A-Z]* tokens, JABC_FN leaves.JABCExecute — zero direct JSC calls, they port for free; the dog .hpp leaves speak almost only the funnel dialect (JABC_FN/JABC_THROW/JABCu64Of/JABCDataOf/JABCStrOfSlice/Get-SetProp + JSValueMakeNumber/Undefined)._mmap (ro + slotted finalizers JABCMapROFree/JABCMapFree, ABC-020 gen-guard), io.cpp _ram, jsrcpack.cpp rodata pack (NULL dealloc), pup.hpp lane leaves; subview surgery via JSObjectGetTypedArrayBuffer + WithArrayBufferAndOffset in hunk.hpp + hit.hpp.JSReportExtraMemoryCost + JSSynchronousGarbageCollectForDebugging are hand-declared SPI in JABC.hpp (webkitgtk ships no JSBasePrivate.h); JAB-034's ulog handle is still a finalizer-less pointer-as-Number.JABCStrOfSlice transcodes u8 slices to explicit-length UTF-16 (embedded NULs survive, bad bytes → U+FFFD); utf8.encodeInto walks JSStringGetCharactersPtr no-copy UTF-16.JSEvaluateScript x4 — JABCExecute bundles + JABCRun's (0,eval)(__src) global wrapper; require.cpp compiles modules with new Function — both standard JS, engine-independent.JSValueProtected router refs (_fd/_timer), io.cpp readdir cb — 5 JSObjectCallAsFunction sites total.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.
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.
JSValue is refcounted — JS_FreeValue on every temp (JSC releases only JSStringRefs) — and errors are the JS_EXCEPTION sentinel + JS_GetException, not JSC's *exception out-param. Plan: JABC_FN keeps the out-param signature, the qjs shim materializes it; scope macros whose frees are REAL under qjs, no-ops under JSC; the qjs CI leg runs ASAN + the JS_FreeRuntime leak report so refcount bugs cannot hide under the JSC build.JS_New*/JS_Is*/JS_To*; props → JS_Get/SetPropertyStr + JS_DefinePropertyValue — NOTE the attribute INVERSION: kJSPropertyAttributeReadOnly|DontDelete (jsrcpack, process.version) → omit JS_PROP_WRITABLE|JS_PROP_CONFIGURABLE; functions → JS_NewCFunction; calls → JS_Call; arrays → JS_NewArray + JS_DefinePropertyValueUint32; the u64 BigInt pair → JS_NewBigUint64 / JS_ToInt64Ext (takes Number AND BigInt, like JSValueToUInt64); eval → JS_Eval(JS_EVAL_TYPE_GLOBAL) ((0,eval) + new Function need the compiler kept in — never a qjsc-stripped build).JS_NewArrayBuffer(ptr, len, free_func, opaque, false) + a Uint8Array over it; free_func replaces the JSC deallocator 1:1 (opaque carries the ABC-020 gen scheme unchanged); jsrcpack rodata → free_func NULL; hunk/hit subviews → get the buffer, construct the view at (off, len).JSReportExtraMemoryCost has no qjs equivalent — but refcounting makes wrapper death DETERMINISTIC (last JS_FreeValue munmaps), so the VMA price mostly evaporates; keep the fd-mark forced collection as public JS_RunGC (both hand-declared SPIs in JABC.hpp die). Cycle-captured views still wait for GC — keep the mark sweep.JS_NewStringLen (explicit length, NULs survive; VERIFY ng's lossy-byte behavior matches the U+FFFD policy); encodeInto rewrites over JS_ToCStringLen (UTF-8 out, lone surrogates → U+FFFD — same policy, no JSChar walk).JABCRun/JABCExecute/pol trampolines get a JS_ExecutePendingJob drain after every JS re-entry. Zero Promise/async in be/ or jab's JS today (measured), so future-proofing, not a port blocker.-DJAB_ENGINE=jsc|qjs (default jsc until the flip); quickjs-ng VENDORED as a pinned snapshot under jab/qjs/ — jab is a beagle repo (git submodules do not apply) and a build must not fetch; the engine is small enough to carry./quickjab skeleton): JABC.h + arg.c + utf8.c + main.c; boots --eval; quickjs-ng v0.10.1 vendoredJS_ExecutePendingJob pump; pol/net/netgrow/fsw green--version still says jab)todo path, measured 2026-08-11time 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 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.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,
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.
pathlib.wtJoin alone is 0.14 / 2.98 us.isFullSha (shared/util/sha.js) reads 40 chars as s[i], one JS string per char: 11.2% of ALL engine allocations — the STATUS-020 glob bug verbatim, cure is charCodeAt.Open calls for gritzko, plus the known semantic risks.
Intl. uses today — nothing breaks.qjab as a separate binary vs a jab build flavor?JS_SetMaxStackSize) vs deep view recursion, and JABC_MAP_MAX (2 GiB) vs ng's own typed-array limits — probe both in step one, not at the be suite.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.
quickjab/qjs/.JS_NewStringLen as predicted; both JAB-033 hand-declared SPIs died (public JS_RunGC); PTR-010 / GET-041 semantics unchanged.js_array_buffer_transfer dropping the free_func's opaque on the transferred husk; fixed io-side (heap JABCMapRec owns the base, NULL-opaque husk is a no-op), ABC-020 ABA guard kept exact. Engine never patched. Related latent risk noted: ng's transfer(new_len) reallocs — fatal on mmap memory — unexposed today (cont calls it same-length).weave.hpp found to be dead text (WEAVE.h gone since DIS-082) — weave.c is a documented no-op install.--version/argv0 still say "jab", pager perf table still open (Blockers).