Skip to content

perf(plugin): pre-compile hot-path JS expressions in QuickJS Init()#1206

Merged
zeroedin merged 7 commits into
mainfrom
feature/quickjs-precompile
Jul 25, 2026
Merged

perf(plugin): pre-compile hot-path JS expressions in QuickJS Init()#1206
zeroedin merged 7 commits into
mainfrom
feature/quickjs-precompile

Conversation

@zeroedin

Copy link
Copy Markdown
Owner

Summary

  • Eliminates __callInput and __callHookName global variable setup in invokeHookFastPath by passing the input JS object directly as a function argument to __callHookByName via InvokeJS — removing 2 SetPropertyStr calls and 1 GetPropertyStr per hook invocation per page
  • Merges origin/main (which already shipped pre-compiled __callHookByName, __installLazyFM, __installLazyTOC helpers from perf: outbound extraction in QuickJS fast path still 2.2x slower than v0.5.0 baseline #1185) and resolves conflicts in test/plan files

The pre-compiled JS helpers and lazy getter pattern were already implemented as part of #1185 (outbound fastpath). The remaining #1186-specific work was removing the global variable round-trip in the hook dispatch path. 13 of 14 spec tests were already green; this PR makes the final "no-globals" test pass.

Closes #1186

Test plan

🤖 Generated with Claude Code

zeroedin and others added 5 commits July 24, 2026 21:51
…ssue #1186)

9 spec tests under "Pre-compiled JS helpers (issue #1186)":

- 3 helper function existence checks: __callHookByName, __installLazyFM,
  __installLazyTOC must be typeof === 'function' after Init()
- 2 lazy getter property descriptor checks: frontMatter and toc must have
  Object.getOwnPropertyDescriptor(...).get (not eager SetPropertyStr)
- lazy frontMatter setter override: page.frontMatter = {...} replaces value
- lazy frontMatter enumerability: Object.keys(page) includes 'frontMatter'
- lazy TOC getter correctness: returns parsed array with text/level
- lazy TOC enumerability: Object.keys(page) includes 'toc'

5 tests fail red (helpers don't exist yet, properties are eager not lazy).
4 tests pass green (regression guards for setter/enumerability/values).

Updates PLAN.md and IMPLEMENTATION.md with pre-compilation spec and
detailed developer guidance for the three-part fix.

Refs #1186

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
P2 #1: Hoist setupQuickJSWithHook to outer Describe scope, remove
duplicate setupQuickJSWithHookFor1186. Both #1180 and #1186 sections
now share the same helper.

P2 #2: Extract checkGlobalIsFunction closure — three existence tests
are now one-liners calling the shared helper.

P2 #3: Add *qjs.Value lifetime note to IMPLEMENTATION.md — stored
function references must be Free()d in Close() before context teardown.

P2 #4: Add lazy FM test for HookFormatRenderedPayload (onFormatRendered)
to verify setPayloadFrontMatter is wired consistently across all three
payload types.

P2 #9 (addendum): Fix null sentinel collision in __installLazyFM and
__installLazyTOC JS snippets — use unique sentinel object instead of
null so page.frontMatter = null sticks. Add corresponding test.

P3 #5: Add configurable: true test for lazy frontMatter property.

P3 #6: Add lazy TOC setter test for parity with frontMatter setter.

P3 #7: Add no-globals disambiguation test — verifies invokeHookFastPath
uses ctx.Invoke (no __callInput/__callHookName globals) vs ctx.Eval.

Test count: 9 → 14 (7 RED, 7 GREEN).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
test: spec tests for pre-compiled JS helpers in QuickJS Init() (#1186)
…mpile

# Conflicts:
#	internal/plugin/wasm_test.go
#	plans/IMPLEMENTATION.md
#	plans/PLAN.md
Pass the input JS object directly as a function argument to
__callHookByName via InvokeJS instead of round-tripping through a
global variable. This removes the per-call SetPropertyStr/cleanup
overhead and ensures __callInput/__callHookName globals are never
set during hook execution.

Also removes duplicate setupQuickJSWithHook helper introduced by
merge conflict resolution (identical definition exists at outer scope).

Refs #1186

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@netlify

netlify Bot commented Jul 25, 2026

Copy link
Copy Markdown

Deploy Preview for alloyssg ready!

Name Link
🔨 Latest commit 5482085
🔍 Latest deploy log https://app.netlify.com/projects/alloyssg/deploys/6a6439b57123f50008cde134
😎 Deploy Preview https://deploy-preview-1206--alloyssg.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 10 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f019dcdb-0979-4edf-85e1-86528765fae6

📥 Commits

Reviewing files that changed from the base of the PR and between 8306b34 and 5482085.

📒 Files selected for processing (4)
  • internal/plugin/wasm.go
  • internal/plugin/wasm_test.go
  • plans/IMPLEMENTATION.md
  • plans/PLAN.md
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/quickjs-precompile

Comment @coderabbitai help to get the list of available commands.

@zeroedin zeroedin left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Results

Scope: internal/plugin/wasm.go (5+/8−), internal/plugin/wasm_test.go (423+/16−), plans/IMPLEMENTATION.md (34+), plans/PLAN.md (7+)
Intent: Eliminate __callInput/__callHookName global variable setup in invokeHookFastPath by passing the input JS object directly as a function argument; add spec tests and docs for issue #1186.

P1 — High

# File Issue Reviewer Confidence Route
1 plans/IMPLEMENTATION.md:729-764 Spec-code mismatch. The new #1186 text describes an implementation that doesn't match the actual code in three ways: (a) Spec says __installLazyFM(target, jsonStr) with a _UNSET sentinel memoization pattern — code has __installLazyFM(target) (no jsonStr param) using __resolveFM() Go callback and self-replacing property descriptor (the #1187 approach documented at line 770). (b) Spec says to store function references as struct fields (callHookByNameFn, installLazyFMFn, installLazyTOCFn) and free them in Close() — no such fields exist on QuickJSRuntime; functions are retrieved per-call via GetPropertyStr. (c) Spec says r.ctx.Invoke(r.callHookByNameFn, ...) — code uses r.ctx.Global().InvokeJS("__callHookByName", ...). A developer reading this spec as implementation guidance will write code that doesn't compile. Update the spec to match the actual implementation, or implement the cached-reference approach the spec describes. maintainability + manual 95 Fix before merge

P3 — Advisory

# File Issue Reviewer Confidence Route
2 wasm.go:640,599 Per-call GetPropertyStr("__installLazyFM")/GetPropertyStr("__installLazyTOC") + defer Free() on every hook invocation. These are pre-compiled functions registered in Init() that never change. Caching them as struct fields would eliminate ~1640 property lookups per 820-page build (two hooks). The InvokeJS("__callHookByName", ...) at line 661 has the same characteristic. Not blocking — the main perf win was eliminating the global round-trip — but this is the obvious next step. performance 75 Future issue
3 wasm.go:660 nameVal := r.ctx.NewString(name) allocates a new QuickJS string for the hook name on every invokeHookFastPath call. In BatchCallHook (820 pages), the same name (e.g., "onPageRendered") is allocated/freed 820 times. A small cache (map[string]*qjs.Value) populated at registration time would eliminate this. performance 50 Future issue
4 wasm_test.go:2583 vs 2823 Overlap between #1186 "lazy toc getter returns correct parsed values" and #1185 "lazy TOC: plugin reading page.toc receives correct entries" — both exercise page.toc[0].text/page.toc[0].level round-trip on HookTransformPayload. Similarly #1186 "lazy frontMatter setter allows override" (line 2429) and #1185 "lazy frontMatter: plugin writing page.frontMatter before reading" (line 2758). Not duplicates, but the overlap increases maintenance surface. maintainability 50 Acknowledge

Coverage

  • Testing gaps: No benchmark test exists in the diff to validate the ~200ms claim or prevent regression. The existing wasm_bench_test.go benchmarks cover payload sizes but not the global-elimination path specifically.
  • Residual risks: The slow path (lines 508-556) still uses __callInput/__callHookName globals via Eval. This is expected and correct — only non-struct, non-map payloads fall through — but worth noting for future reference.

Correctness Notes

  • input (obj) lifetime is correct — callers create it via NewObject(), pass it to invokeHookFastPath, and the QuickJS runtime manages refcount through InvokeJS. No double-free or use-after-free.
  • ✅ The no-globals test (line 2696) correctly validates the fast path — HookRenderedPayload always takes the fast path (line 483), so typeof __callInput !== 'undefined' is the right check.
  • ✅ The defer now only clears pendingFM/pendingTOC (Go struct fields, mutex-protected) — correct and safe.
  • ✅ No encoding/json usage. Conventional Commits followed. CI green (all tests pass with -race).
  • setupQuickJSWithHook relocation from inner scope to outer Describe is correct — eliminates the duplicate introduced by merge conflict resolution.

Verdict: Ready with fixes

Reasoning: The implementation change itself is correct, clean, and achieves the stated goal — eliminating the global variable round-trip in invokeHookFastPath with a minimal 5-line diff. The single blocking issue is P1 #1: the IMPLEMENTATION.md spec text added in this PR describes function signatures, struct fields, and invocation patterns that don't exist in the actual code. This is actively misleading for the next developer. Fix the spec to match reality (the Go-callback approach documented at #1187), then this is ready. The P3 items are future optimization opportunities, not blockers.

Keep both #1186 (pre-compiled JS helpers) and #1188 (hook chain map
fast path) paragraphs in PLAN.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@zeroedin

Copy link
Copy Markdown
Owner Author

⚠️ P0 Addendum — memory leak in invokeHookFastPath

Correction to my review above: I marked input lifetime as ✅ correct. After the correctness sub-reviewer flagged it, I verified against the fastschema/qjs wrapper source code. The input *qjs.Value is leaked on every call.

Root cause

The old code transferred ownership of input to the JS global via SetPropertyStr("__callInput", input). In QuickJS's C API, JS_SetPropertyStr consumes the value reference — the caller must not free it afterward. The defer that cleared the global (SetPropertyStr("__callInput", NewUndefined())) released the old value.

The new code passes input directly to InvokeJS("__callHookByName", nameVal, input). InvokeJSQJS_CallJS_Call, which does NOT consume argument references (qjswasm/helpers.c:634-638QJS_Call is a thin wrapper around JS_Call). After InvokeJS returns, input still holds its original refcount from NewObject(), and nobody calls input.Free().

Evidence chain

  1. createJsCallArgs in context.go:386-403 copies v.Raw() handles into WASM memory — no JS_DupValue
  2. QJS_Call in helpers.c:634-638 delegates directly to JS_Call — no consume
  3. Value.Free() at value.go:98-103 calls FreeJsValue and zeroes the handle — confirms manual ownership
  4. No runtime.SetFinalizer on *Value — leaked references persist until runtime Close()

Impact

Four call sites create obj via NewObject() and pass it to invokeHookFastPath without freeing:

  • callHookRenderedPayload:569
  • callHookFormatRenderedPayload:583
  • callHookTransformPayload:605
  • callHookMapPayload:633

For 820 pages × 2 hooks, that's ~1640 leaked JS objects per build. Each contains an HTML string reference (~800KB). QuickJS uses reference counting — refcount > 0 prevents collection. The leak accumulates until rt.Close().

Fix

Add defer input.Free() at the top of invokeHookFastPath:

func (r *QuickJSRuntime) invokeHookFastPath(name string, input *qjs.Value, ptype payloadType) (interface{}, error) {
	defer input.Free()  // ← ADD: release the reference consumed by SetPropertyStr in the old code
	defer func() {
		r.pendingFM = nil
		r.pendingTOC = nil
	}()
	// ...
}

This is safe: JS_Call dups argument references for the call frame, so result has an independent refcount from input. Even if the hook returns page directly, the return value is duped by JS_Call.


Revised verdict: Ready with fixes (P0 #0 + P1 #1 from original review)

Add defer input.Free() in invokeHookFastPath to prevent *qjs.Value
leak. The old code transferred ownership via SetPropertyStr (which
consumes the reference). The new code passes input directly to
InvokeJS (which does NOT consume it), so the caller must free it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@zeroedin

Copy link
Copy Markdown
Owner Author

Review response

P0 #0 — Memory leak in invokeHookFastPath

Fixed in 5482085. Added defer input.Free() at the top of invokeHookFastPath. The old code transferred ownership to the JS global via SetPropertyStr (which consumes the reference). The new code passes input directly to InvokeJS (which does NOT consume it), so the caller must explicitly free. Good catch — this would leak ~1640 JS objects per build on large sites.

P1 #1 — IMPLEMENTATION.md spec-code mismatch

Skipped — out of scope for developer role. plans/IMPLEMENTATION.md is the architect's domain. Opened #1207 with the three specific mismatches (jsonStr parameter vs Go callback, stored function references vs per-call GetPropertyStr, ctx.Invoke vs InvokeJS).

P3 #2 — Cache pre-compiled function references as struct fields

Acknowledged. Valid optimization — eliminates ~1640 GetPropertyStr lookups per build. Not in scope for this PR (issue #1186 is specifically about the global variable elimination). Future issue.

P3 #3 — Cache hook name string allocation

Acknowledged. Same category — eliminates redundant NewString allocations in BatchCallHook. Future issue.

P3 #4 — Test overlap between #1186 and #1185

Acknowledged. The overlap exists because #1186 tests verify property descriptors (lazy getter detection) while #1185 tests verify functional behavior (correct values). Different assertions on the same mechanism. Both are immutable spec tests.

Coverage notes

  • Benchmark gap: Correct — no benchmark isolates the global-elimination path. The measurable impact compounds with the other fast-path optimizations.
  • Slow path globals: Correct — the non-struct, non-map slow path (lines 508-556) still uses __callInput/__callHookName via Eval. That path handles primitives (string, int, float, bool) and is not on the per-page hot path.

@zeroedin zeroedin left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-Review — PR #1206 (commits 5ff48ef, 5482085)

Scope: Diff from 63de3ca5482085 (2 new commits). Changes: wasm.go (+1 line), wasm_test.go (+54 lines), node_test.go (+46 lines), hooks_test.go (+110 lines), IMPLEMENTATION.md (+4/−4), PLAN.md (+2)

Previous findings resolved

# Severity Finding Status
P0 Critical input *qjs.Value never freed in invokeHookFastPath ✅ Fixed — defer input.Free() at line 655. Verified: LIFO ordering safe (result.Free() → pending cleanup → input.Free()). JS_Call returns an owned reference (normalizeJsValue passes through without dup), so result and input have independent refcounts even if the hook returns page directly. Error path is also safe — defer result.Free() isn't registered on error, only input.Free() and pending cleanup run.
P1 High IMPLEMENTATION.md #1186 spec-code mismatch ❌ Not fixed — see below

P1 — Still outstanding

# File Issue Confidence
1 IMPLEMENTATION.md:729-762 #1186 spec text still describes a different implementation than what exists. Three mismatches remain: (a) spec says __installLazyFM(target, jsonStr) with _UNSET sentinel — code has __installLazyFM(target) with __resolveFM() Go callback + self-replacing property (the #1187 approach at line 770). (b) spec says to cache function references as struct fields (callHookByNameFn, etc.) — no such fields exist. (c) spec says r.ctx.Invoke(r.callHookByNameFn, ...) — code uses r.ctx.Global().InvokeJS("__callHookByName", ...). 95

New scope: issue #1188 tests

The fix commit adds 4 spec tests for issue #1188 (hook chain fast path for map[string]interface{} payloads):

File Test Assessment
wasm_test.go:3027 onDataFetched map payload NOT caught by page-like fast path ✅ Correct — validates key guard with a realistic non-page map payload
node_test.go:1098 Standard framing for hook map without html/content keys ✅ Correct — verifies Content-Length framing, no X-Body-Length/X-Body-Field
hooks_test.go:3849 Two onPageRendered hooks chain through pipeline ✅ Correct — e2e test using BuildWithContent, validates both markers in output order
hooks_test.go:3894 Two hooks chain with large (~50KB) HTML ✅ Correct — exercises serialization path, verifies no truncation or corruption

All four tests are well-constructed, self-contained, and have strong failure messages that diagnose root causes. The PLAN.md and IMPLEMENTATION.md updates correctly reference #1188 and expand the test coverage description.

Coverage

  • ✅ CI green — all tests pass with -race
  • ✅ No encoding/json usage
  • ✅ Conventional Commits format on all authored commits

Verdict: Approve

Reasoning: The P0 memory leak is fixed correctly. The new #1188 tests are high quality and well-scoped. The remaining P1 (IMPLEMENTATION.md #1186 text describing wrong function signatures and non-existent struct fields) is a documentation accuracy issue that should be fixed before or shortly after merge — it will mislead the next developer who reads it as implementation guidance. If you'd rather fix it in this PR, update lines 730-760 to match the Go-callback + InvokeJS approach that's actually implemented.

@zeroedin
zeroedin merged commit a5e0489 into main Jul 25, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf: pre-compile hot-path JS expressions in QuickJS Init()

1 participant