Skip to content

feat(framework): add Octane as the third first-class framework - #203

Draft
doodlewind wants to merge 1 commit into
mainfrom
grass-responsibility
Draft

feat(framework): add Octane as the third first-class framework#203
doodlewind wants to merge 1 commit into
mainfrom
grass-responsibility

Conversation

@doodlewind

Copy link
Copy Markdown
Collaborator

Octane (https://github.com/octanejs/octane — React's programming model, compiled; by Dominic Gannaway) joins Solid and Vue Vapor as a first-class PocketJS framework. This is the React-model path PR #6 concluded was impossible with original React: Octane's universal client target compiles JSX + hooks into static host plans + dynamic slot batches executed by a UniversalHostDriver — no DOM, no VDOM — which maps directly onto the native ui.* tree.

What's in here

Adapter (framework/src/*octane*)

  • renderer-octane.ts — the "pocket" universal driver: host command batches (create/update/insert/move/remove/destroy) applied to the shared NodeMirror arena via applyProps; #text hosts through createTextNode/replaceText; portals as a driver capability minting overlay hosts transactionally. Re-exports the host-neutral universal ABI (octane/universal/native — QuickJS-safe).
  • components-octane.tsx — the full 15-component surface (View/Text/Image/Sprite as hand-lowered universal plans; Screen/Focusable/FocusScope/FocusGrid/ActionHandler/Portal/Modal/ActionBar/Grid/Lazy/Gallery as Octane function components).
  • frame-octane.tsx / lifecycle-octane.tsuseFrame, useButtonPress, useSpriteAnimation (use-prefixed: the Octane compiler slot-keys custom hooks by the use[A-Z] call-site convention; non-hook names silently collide their internal slots).
  • index-octane.ts — render/mount; the frame handler drains Octane's microtask renders inside the frame via flushUniversalSync, so state commits land in the same tick as their input.

Compiler/build

  • jsx-plugin.ts: third FRAMEWORKS entry, OCTANE_RENDERER_DESCRIPTOR, octane compile branch (collector pass on pristine source → octane/compiler universal lowering), generalized frameworkVariantPath (app.octane.tsx siblings), cache-key includes the octane version.
  • pocket.json schema ("framework": "octane"), package exports ./octane/*, tsconfig paths.

The memory hunt (the fun part) — octane demos initially died mid-run on PPSSPP (InternalError: out of memory with megabytes of arena free, opaque null exceptions, wedged frame loops). A scratch cargo probe pinned to the exact libquickjs-sys revision (eval the real bundle + stub ui + frame loop + heap histograms + a gc_decref root dump) unpicked a three-layer cause:

  1. Octane's profiler is on by default, and its trackedComponents WeakMap is written even after profiler.stop() — with per-render closures as values.
  2. The pinned QuickJS (2026-06, new GC) marks WeakMap record values strongly — not ephemeron-aware — so those records pinned every render's owner graph (ten-line upstream repro: wm.set(key, {back: key}) never collects; bellard master has the same code).
  3. Even after unpinning, the engine's internal slab allocator (JSMallocArena) amplifies any residual retention 10-20× on a fixed arena: a few live objects pin whole chunks, and the auto-GC threshold (live × 1.5) is far too lazy for a 14.4 MiB heap.

Fixes, in order: framework=octane builds alias octane/profiling to a no-op stub (framework/src/octane-profiling-stub.ts — also removes per-render profiler work from the frame budget); the PSP frame loop gained an arena-pressure GC (JS_RunGC when a frame leaves the bump pointer >256 KiB past the last collection — steady-state Solid/Vapor guests never trigger it); and the Octane ports keep always-animating state in leaf components, committing state on visible steps (music's Equalizer/ProgressLine split, stats' StatTiles). Along the way the PSP host's exception logger learned to print tag + message + stack (a null exception is QuickJS's no-room-for-the-Error-object throw), and one solid golden (hero.boot, a single background pixel ±2) was re-baselined — it predates this branch; this is the first full solid e2e since the #149-era goldens.

Result: all three frameworks pass byte-exact PPSSPP e2e, and the arena-pressure GC keeps every demo alive through its capture/bench window. What it does not do is make frequent re-rendering free on this engine revision: each Octane re-render still retains a residue the collector cannot reclaim (~80–115 KB/frame measured across the per-frame-state demos), so an always-animating screen has a session memory horizon — the stats bench window ends at 16.87 MiB of a 17.06 MiB arena, 196 KiB from the wall. The growing live set also inflates the engine's own auto-GC time, which dominates those demos' per-frame numbers in the benchmark below. The real cure is a quickjs-rs GC repair + repin, filed as follow-up; Solid, Vue Vapor, and Octane apps without per-frame state are unaffected.

Demos — all 8 Vue Vapor demos got app.octane.tsx twins (hero, cards, stats, library, settings, notifications, music, gallery), sim-verified text/pixel parity against the vapor variants, PPSSPP byte-exact goldens in tests/goldens/psp-octane/ (7 bench-table demos), plus new tests/goldens/psp-vue-vapor/ coverage.

Playground + docs — third framework toggle with in-browser octane compilation (pure-JS compiler, no WASM shim needed), pg/runtime-octane.js bundle, import-map entries; frameworks.md reworked for three frameworks, octane fences added to all 29 :::framework-code blocks, README/DESIGN/RUNTIMES/home/INTEGRATION updated.

Benchmarktools/bench-ppsspp.ts regained the PR #6 cross-engine axis: --frameworks=solid,vue-vapor,octane, per-(app, framework) grouping, geomean-vs-baseline ratios with optional --bootstrap CIs; tools/bench-chart.ts renders the matrix SVG. Full three-framework results below.

Benchmark (PPSSPP software renderer, per PR #6 methodology)

7 samples per (app, framework) cell across the 7 bench demos, deterministic PPSSPP headless (PPSSPP 676724ee5e, base 3c14b47), geomean-vs-Solid ratios with mulberry32-seeded 5000-resample bootstrap 95% CIs. Full report + chart in docs/bench/ (three-frameworks-ppsspp-2026-07-29.{md,json,svg,png}); raw samples reproducible via the specs table in tools/bench-ppsspp.ts, and the report is rebuildable from a raw dump with the new --from-raw=<raw.jsonl> mode.

three-framework chart

metric (geomean vs Solid, lower is better) Vue Vapor Octane
bundle eval 2.93× 2.95×
boot → first frame 2.30× 2.32×
avg frame work 1.24× 15.58×
host wall clock 1.35× [1.34, 1.36] 3.95× [3.93, 3.98]
bundle size 2.41× 2.96×

Average frame work by app:

app Solid Vue Vapor Octane
hero 4.35 ms 4.61 ms 387.82 ms
cards 5.21 ms 5.66 ms 9.66 ms
stats 8.63 ms 11.08 ms 279.91 ms
library 4.61 ms 6.05 ms 50.76 ms
settings 7.71 ms 8.78 ms 27.44 ms
notifications 5.55 ms 9.04 ms 271.21 ms
music 13.04 ms 16.12 ms 282.71 ms

Reading the 15.6×: it is not Octane's render cost — it is the engine pathology above compounding. In the demos with per-frame state (hero, stats, notifications, music, and to a lesser degree library/settings) each tick's re-render grows the unreclaimable live set, and the engine's auto-GC walks it every collection, so avg frame work is dominated by in-engine GC (stats: 254 ms of its 280 ms is avg_js, with another 22 ms in the arena-pressure GC bucket). cards — the one demo with no per-frame state — shows Octane's true steady-state cost: 9.66 ms, 1.86× Solid, still inside the 16.7 ms 60 fps budget. Boot/eval/bundle are engine-pathology-free and land essentially on top of Vue Vapor. These numbers are honest to the current pinned engine; they should collapse toward the cards ratio once the quickjs-rs GC repair lands.

React status: unchanged from PR #6 — original React still has no viable PSP path; Octane is the React programming model that does run, measured above.

Authoring notes (also in docs/frameworks.md)

  • Mixed static+dynamic text → one template literal (universal lowering trims the static segment's trailing space).
  • Frame-loop counters → functional setState (a same-frame handler write would be clobbered by a stale read).
  • Don't put natively-animated props in a style whose value changes across renders (unchanged values diff away and are safe).
  • Octane entries call mount(App) — JSX in a call-argument arrow is a fail-closed universal-target error.

Validation

  • bunx tsc --noEmit clean
  • bun run test green end-to-end (includes the new tests/octane-smoke.test.ts + hero --framework=octane build in the chain)
  • PPSSPP e2e byte-exact goldens for all three frameworks: tests/goldens/psp/, tests/goldens/psp-vue-vapor/ (new), tests/goldens/psp-octane/ (new); hero.boot re-baselined (single background pixel ±2, pre-dates this branch)
  • Three-framework benchmark: 147 samples (7 per cell × 7 apps × 3 frameworks), deterministic — repeated runs are byte-identical; report/chart/raw archived in docs/bench/
  • bun site/build.ts green — playground ships current pg/runtime.js / pg/runtime-vue-vapor.js / pg/runtime-octane.js
  • Real-hardware PSP spot-check (bench numbers above are PPSSPP software renderer; PR Document React failure and Vue/Vue Vapor PSP benchmarks #6 samples cover emulator↔hardware variance)

🤖 Generated with Claude Code

Octane (React's programming model, compiled) joins Solid and Vue Vapor:
universal-driver renderer over the native tree, full 15-component surface,
use-prefixed frame hooks, app.octane.tsx variants for all 8 demos, playground
in-browser compilation, byte-exact PPSSPP goldens, and a three-framework
PPSSPP benchmark (docs/bench/) per the PR #6 methodology. The PSP host gains
an arena-pressure GC; framework=octane builds stub octane/profiling to dodge
a WeakMap pinning pathology in the pinned QuickJS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant