Skip to content

feat: porffor backend - #76

Open
littledivy wants to merge 1 commit into
mainfrom
porffor-backend
Open

feat: porffor backend#76
littledivy wants to merge 1 commit into
mainfrom
porffor-backend

Conversation

@littledivy

Copy link
Copy Markdown
Owner

Adds src/porffor/, a fourth backend. Draft-quality scaffold, not a working engine — 75 symbols implemented, 745 still stubbed.

Why this one is different

JSC, QuickJS and Hermes are interpreters: hand them a source string at runtime and they run it. Porffor is an ahead-of-time compiler — it lowers JS to C and hands that to a C compiler, with nothing interpreted or JIT-compiled.

So v8__Script__Compile cannot mean "compile this string now". It can only resolve source already compiled into the binary. That makes the target flattened bundles (a celld Wrangler bundle, a deno compile graph) rather than arbitrary runtime input — and it is the reason this is worth trying at all: it tests whether the "generated rusty_v8 ABI is the portability boundary" claim holds against an engine with no runtime compiler.

Build shape

Follows the hermes spike:

  • --features porffor → pure-Rust stub backend, links with zero porffor dependency
  • --features porffor,link_porffor → build.rs runs porf c --lib over src/porffor/seed.js and links the emitted object

There is no engine library to link against. We compile the runtime itself out of porffor — its heap, collector and builtins — via a new --lib target on the porffor side. PORFFOR_DIR points at a porffor checkout (default ../porffor).

seed.js is the ABI contract. Porffor only emits builtins a compilation actually references, so a builtin absent from the seed is absent from the linked object. Every entry costs binary size in every consumer, which is the one axis porffor is unambiguously winning on (~500 KB object for a broad builtin surface, vs ~1 MB for QuickJS static and ~48 MB for JSC).

Handles

Same problem QuickJS has — a porffor jsval is { f64 val; i32 type; }, 16 bytes, not a pointer — so the same solution: handles are arena slots and the slot address is the v8 handle.

The discipline differs. QuickJS arena slots own exactly one refcount. Porffor has a tracing collector, so slots must be GC roots. The arena lives on the porffor side and registers with the collector in whole blocks rather than per handle, so entering and leaving a scope costs no per-handle root bookkeeping.

What works

cargo test --no-default-features --features porffor,link_porffor --test porffor_smoke

test primitives_round_trip ... ok
test strings_round_trip ... ok
test non_ascii_strings_round_trip ... ok
test objects_round_trip_through_the_heap ... ok
test values_survive_a_collection ... ok

Isolate, handle scope, context, primitives, strings (including ValueView, zero-copy into porffor's heap — safe because the collector is non-moving and the handle roots the string), objects, Global<T>, platform lifecycle. values_survive_a_collection churns 20k allocations then forces a full GC and reads the handle back, which is the test that actually exercises the root-range wiring.

Notes for review

  • One isolate per process. Porffor emits a single global heap per binary, so isolate liveness is a process-wide AtomicBool, not per-thread. A second live isolate panics rather than silently sharing the heap. The smoke test serializes on its own lock instead of relying on --test-threads=1.
  • ArrayBuffer::Allocator is reused from src/quickjs/allocator.rs via #[path] — it has zero engine coupling (plain calloc/free plus the Rust allocator vtable), so a second copy would only drift.
  • tools/gen_porffor_shims.sh differs from the hermes generator in one way: it emits cfg(not(feature = "link_porffor")) stubs for symbols implemented in link-gated files instead of dropping them outright. A symbol getting a real body for the first time therefore needs no hand-added gate. (The hermes misc.rs carries a hand-written block to compensate for the old behaviour; porffor's copy has that block removed, with a comment explaining why.)
  • Not wired into the CI matrix. No tests/status/baselines/porffor/ — adding a fourth column to the dashboard felt like a separate decision. Happy to add it.
  • Diff to existing files is purely additive and feature-gated: Cargo.toml (+13), build.rs (+51), src/lib.rs (+5). Nothing under src/jsc/ or src/quickjs/ is modified.

Depends on an unlanded porffor change

link_porffor needs a --lib target in porffor that suppresses main and emits a stable porf_embed_* C ABI. That is not upstream yet — the stub-only build (--features porffor) is unaffected and works against any checkout.

Next

v8__Function__New over porf_embed_function_new. Porffor's side is already built (a jsval whose call target is a C function pointer, dispatched through porf_call_dynamic), and it is what celld's 126 FunctionCallbackArguments sites bind to.

Adds src/porffor/, a fourth backend. The three existing engines are
interpreters; porffor is an ahead-of-time compiler, so this is the first
backend where "compile this source string at runtime" has no meaning.
v8__Script__Compile can only ever resolve source already compiled into
the binary, which is why the target is flattened bundles rather than
arbitrary input.

Build shape follows the hermes spike: `engine_porffor` alone is a
pure-Rust stub backend that links with zero porffor dependency;
`link_porffor` makes build.rs run `porf c --lib` over src/porffor/seed.js
and link the emitted object. There is no engine library to link against
-- we compile the runtime itself out of porffor.

Because porffor only emits builtins a compilation references, seed.js is
the ABI contract: what it mentions is what the backend can reach.

Handles use the QuickJS arena shape for the same reason (a porffor jsval
is {f64, i32}, not a pointer), but the discipline differs: QuickJS slots
own a refcount, porffor slots must be GC roots. The arena lives on the
porffor side and registers with the collector in whole blocks, so a
handle scope costs no per-handle bookkeeping.

75 symbols implemented (isolate, handle scope, context, primitives,
strings incl. ValueView, objects, Global<T>, platform lifecycle);
745 still stubbed. ArrayBuffer::Allocator is reused from
src/quickjs/allocator.rs via #[path] -- it has no engine coupling.

tools/gen_porffor_shims.sh differs from the hermes generator in one
way: it emits cfg(not(feature = "link_porffor")) stubs for symbols
implemented in link-gated files instead of dropping them, so a symbol
getting a real body for the first time needs no hand-added gate.

Note: porffor emits one global heap per binary, so isolate liveness is
process-wide, not per-thread. Creating a second live isolate panics
rather than silently sharing the heap.
@littledivy littledivy changed the title feat: porffor backend scaffold (AOT engine behind the v8 C ABI) feat: porffor backend Aug 22, 2026
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