Skip to content

perf(plugins): handle-based VM dispatch, native base64, and indexed content-API lookups#29

Merged
DavidBabinec merged 5 commits into
mainfrom
perf/plugin-bridge-fixes
Jun 11, 2026
Merged

perf(plugins): handle-based VM dispatch, native base64, and indexed content-API lookups#29
DavidBabinec merged 5 commits into
mainfrom
perf/plugin-bridge-fixes

Conversation

@DavidBabinec

Copy link
Copy Markdown
Contributor

Summary

Plugin-bridge performance cluster from the audit behind #24 (stacked on perf/critical-performance-fixes — merge that first). Three adversarially-verified findings. Wire format, plugin-visible semantics, and rendered output are unchanged (pinned by round-trip and cross-check tests).

Benchmarks — before / after

Bench scenarios committed first (scripts/bench/benches/plugin.ts: hook-filter payload sweep + base64 rows), before/after runs on identical bench code.

Metric Before After Δ
base64 encode 1 MB (host side) 8.29 ms 74 µs ~112×
base64 encode 10 MB 82.7 ms 1.27 ms ~65×
base64 decode 10 MB 7.03 ms 1.55 ms ~4.5×
runHookFilter round-trip, 1 MB payload 17.18 ms 15.07 ms −12%
runHookFilter round-trip, 1 KB payload 37.2 µs 30.6 µs −18%

(The hook-filter remainder is the VM-side JSON.parse/stringify inherent to the wire contract — the eliminated part is exactly the payload-sized WASM source compile + the host's double-stringify.)

What changed

  1. Host→VM dispatch compiled every payload as source (quickjs/vm.ts, modulePackVm.ts): payloads were JSON.stringify'd twice and spliced into a code string for evalCode, making QuickJS (WASM-interpreted) tokenize/parse/compile payload-sized source per call — for publish.html filters that's the whole page HTML, and module-pack renders re-marshaled accumulated children HTML at every nesting level. Dispatch now goes through persistent function handles grabbed right after bootstrap (before plugin code runs — a plugin reassigning the dispatch globals can no longer intercept host dispatch, pinned by a hijack-guard test) via new callString/callVoid/callStringSync in eval.ts: arguments cross via ctx.newString (a memcpy), promise-poll/deadline machinery unchanged. Marshaling round-trip fidelity (quotes, backslashes, U+2028/9, control chars, lone surrogates) pinned byte-identical.

  2. Pure-JS base64 on the Bun side (protocol/bodyEncoding.ts): chunked String.fromCharCode+btoa / atob+per-byte loop ran for every binary body crossing the bridge (route uploads, binary fetch responses, media adapters). Now native Buffer base64 with the documented no-sibling-view guarantee preserved; the in-VM shim (quickjs/bootstrap/base64.ts) stays pure JS as it must. Outputs cross-checked byte-identical against the old implementation.

  3. cms.content.* re-listed ALL tables per api-call; bulk handlers were N+1 (host/handlers/content.ts): resolveTableBySlug now uses a new indexed getDataTableBySlug; updateMany/deleteMany validate ids with one IN-list getDataRowMany (input-order error/emission semantics preserved); tables.get drops its second full scan for one countDataRows. Reply shapes byte-identical.

Verification

  • Full bun test on this branch: 5,123 pass / 0 fail; build + lint clean; bootstrap:sync freshness gate green.
  • New tests: VM payload-marshaling round-trips + dispatch-handle hijack guard, base64 cross-checks + fresh-buffer guarantee, repository read tests.

🤖 Generated with Claude Code

Base automatically changed from perf/critical-performance-fixes to main June 11, 2026 09:22
DavidBabinec and others added 5 commits June 11, 2026 13:28
…bench

Adds two sections to the plugin sandbox bench so the marshaling and
base64 hot paths have before/after evidence:

- runHookFilter identity-filter sweep at 1 KB / 100 KB / 1 MB payloads,
  isolating the host->VM->host payload marshaling cost that publish.html
  filters pay on whole-page HTML.
- bodyEncoding bytesToBase64/base64ToBytes encode+decode at 1 MB / 10 MB
  of deterministic pseudo-random bytes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…source

Every PluginVm entry point (runRoute/runHookListener/runHookFilter/
runLoopFetch/runLoopPreview/runSchedule/runMediaAdapterCall/
runMediaUrlTransformer/updateSettings/runLifecycle/runMigrate) and the
module-pack render()/preview() used to JSON.stringify the payload TWICE
(value + source escaping) and splice it into a source string for
ctx.evalCode — forcing the WASM-interpreted QuickJS tokenizer/parser to
compile payload-sized JS per call. For publish.html filters that is the
entire page HTML; for module-pack renders the accumulated children HTML
re-marshals at every nesting level.

Now:
- vm.ts grabs persistent function handles for the bootstrap's __run*
  dispatchers right after the bootstrap evaluates (before the plugin
  bundle runs, so a plugin reassigning the globals cannot intercept host
  dispatch) and disposes them in dispose().
- eval.ts grows callString/callVoid (+ sync callStringSync for module
  packs) built on a shared settleResolved pipeline: arguments cross via
  ctx.newString (a memcpy) — no compile, no second stringify. The
  promise-poll + wall-clock deadline machinery is unchanged.
- modulePackVm.ts renders through __renderModule/__previewModule handles.
- evalVoid/evalString are gone; evalCode-based eval remains only for
  one-time setup (hook detection, pack init).

Bench (plugin suite, same scenario code before/after):
  runHookFilter identity 1 KB:   37.2us -> measured after fix
  runHookFilter identity 100 KB: 1.71ms -> measured after fix
  runHookFilter identity 1 MB:   17.18ms -> measured after fix

Adds pluginVmPayloadMarshaling.test.ts pinning byte-identical round-trips
for quotes/backslashes/template syntax/U+2028/U+2029/control chars/lone
surrogates, plus the dispatch-handle hijack guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
bytesToBase64/base64ToBytes ran a chunked String.fromCharCode + btoa /
atob + per-byte loop in the Bun host process on every binary body
crossing the bridge (route uploads, binary fetch responses, crypto
bridge, media adapters) — tens of ms and ~3x transient allocation per
multi-MB crossing, all on the event loop.

Both now go through native Buffer: encode wraps the existing bytes
without copying; decode copies Buffer.from(b64, 'base64') into a fresh
ArrayBuffer to preserve the documented no-sibling-view guarantee.
Output is bit-identical — pinned by a new cross-check test against the
pure-JS reference (the algorithm quickjs/bootstrap/base64.ts still uses
inside the VM, where Buffer does not exist; that shim is untouched).

Bench (plugin suite base64 rows): encode 10 MB 82.7ms before; decode
10 MB 7.0ms before — after numbers in the bench report.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…content.*

resolveTableBySlug ran listDataTables (full scan + fields_json parse of
every table) on EVERY cms.content.* api-call (twice for moveTable); it
now uses a new getDataTableBySlug repository read — one indexed lookup
via data_tables_slug_active_idx. handleContentTablesGet drops its second
full scan (listDataTablesWithCounts with a per-table COUNT subselect) in
favour of one countDataRows COUNT for the requested table plus the
id->slug lookup the relation-field projection genuinely needs.

The bulk handlers (updateMany validation/diff, deleteMany validation)
read N rows with N sequential getDataRow round-trips; both now use a new
getDataRowMany — ONE hydrated IN-list query built from the dialect-aware
placeholder() helper. Iteration stays in input order, so first-bad-id
error messages, per-row hook emission order, and reply shapes are
unchanged.

Also dedupes softDeleteDataTable's inline row-count guard onto
countDataRows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…dispatch

The architecture gate pinned the old eval-source dispatch pattern
(`__runSchedule(${JSON.stringify(scheduleId)})`). Dispatch now goes
through a persistent handle to the bootstrap's __runSchedule dispatcher
(callVoid in eval.ts) — same invariant, the host still never holds the
plugin's own handler function, only the bootstrap dispatcher.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@DavidBabinec DavidBabinec force-pushed the perf/plugin-bridge-fixes branch from ddd1230 to 81c0557 Compare June 11, 2026 11:31
@DavidBabinec DavidBabinec merged commit e8badd3 into main Jun 11, 2026
5 checks passed
@DavidBabinec DavidBabinec deleted the perf/plugin-bridge-fixes branch June 11, 2026 11:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant