Skip to content

[core] ppsc and ppsc-hybrid js rewriters with proxy elision - #138

Open
velzie wants to merge 19 commits into
developfrom
feat/ppsc-hybrid
Open

velzie wants to merge 19 commits into
developfrom
feat/ppsc-hybrid

Conversation

@velzie

@velzie velzie commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Adds two new JS rewriters next to the existing one, picked with a new jsRewriter flag. The default becomes ppsc-hybrid.

jsRewriter What it does
dpsc The current rewrite. Every member access whose key could name location/parent/top/eval goes through $sj_ accessors on Object.prototype.
ppsc Rewrites only references to the window and document, which the runtime answers with a Proxy. Member accesses are left alone.
ppsc-hybrid (default) ppsc, but a static read of an unsafe name (x.top, this.location) goes through dpsc's accessor instead, so the proxy is only needed where the object is used as a value (a computed key, or being passed on).

ppscWrapThis (off by default) also rebinds an unsafe this to a $t wrapped once per function.

Proxy elision (Rust, rewriter/js/src/ppsc/elide.rs)

An oxc_semantic pass proves which references are only used in ways the proxy would answer identically, and leaves those pointing at the real object:

  • Tracked values: globals, and locals holding them (var d = document, UMD factory params, IIFE params, var self = this). Each is classified by every use it has.
  • Unsafe locals: stay wrapped. Their safe uses are pointed back at the global when it provably still holds it, or else at a twin local (d$scramjet$r = $unwrap(d)) kept in step with every write.

On 29 real sites this took proxy traps from 1.04M to 549k (−47%) with ppsc-hybrid and $t off.

Runtime

  • Proxies: window and document proxies in client/global.ts.
  • Unproxy layer: client/shared/unproxy.ts, driven by tables generated from @webref/idl (./cv core:gen-unproxy), puts natives right when they're handed a proxy or return a real window/document.
    • Receiver fix: a proxy receiver is swapped for the real object once, in the slot dispatch (fixReceiver).
    • Bare patches: members that need only that are patched with no layer (Patch).
    • No reflection hooks: descriptor reads and defines go straight through, and the global proxy declares no descriptor traps. An extracted native location getter called on the proxy throws rather than leaks. The proper stand-ins come with the reflection work, and when that lands global.ts should go back to _Proxy.
  • $wrap: caches the values it compares against. It also hands back proxies for the pretend parent/top, which fixes a GTM while (w !== w.parent) hang on Discord.

Testing

  • cargo test -p native --release: rewrite_tests, google and the new elide_diff. elide_diff runs each case in a boa realm twice (as written against proxies, and rewritten against real objects) and fails on any difference or real-location leak. It passes on the hand-written cases and on 20k generated programs in every asserted configuration.
  • native verify: 0 parse failures over a 351 MB real-site corpus in all three modes. Rewrite cost is 9.4 / 19.5 / 19.1 ms/MB (dpsc / ppsc / ppsc-hybrid).
  • Leak probe: 50/50 checks pass on this branch through the harness, including descriptor reads and defines on the window and document, and postMessage source identity.
  • Location writes: 12 navigation cases stay inside the proxy under all three rewriters with stamp and lazystamp: var location, destructuring, loop heads, and plain location =. This includes a fix to $tryset, which since d9af2d0 navigated the real Location in every mode.
  • Runway, against a clean develop build (fast mode):
    • postmessage: 24 failures against develop's 25, with the same unexpected set.
    • location: 20 failures against develop's 27. This branch's unexpected set is a subset of develop's.
    • event: 19 failures, the same as develop.
    • function: 18 failures, the same as develop. One differs: functionctor-scope fails because new Function("return this")() returns the real window, which is expected with ppscWrapThis off. In exchange, rv23-destructure-location-var-in-function now passes.

Benchmarks (tools/BENCHMARKS.md)

The harness now takes ?flags={...}, so every tool compares chromium/dpsc/ppsc/ppsc-hybrid on one build: benchmark-octane.mjs, benchmark-ops.mjs, benchmark-sites.mjs, capture-script-corpus.mjs, plus SCRAMJET_FLAGS for benchmark-speedometer.mjs.

Octane in Chromium (median of 3):

chromium dpsc ppsc ppsc-hybrid
Score 106908 71918 85849 95934
Mandreel 103324 18405 101759 104577
zlib 151904 67918 110416 107616
Gameboy 195598 120565 198206 193182

Splay is bimodal in every scramjet mode and swings the total ±10%. See the README for the full tables.

Speedometer 3.1 (clean 18-suite subset) is within noise across modes: dpsc 17.3 / 17.0 / 16.1, ppsc 17.2, ppsc-hybrid 16.7 / 15.5 (±0.6–0.95). ppsc-hybrid came in slightly under dpsc in both paired runs.

Real sites, total script ms over 32: chromium 40103, dpsc 108950, ppsc-hybrid 101144. Three errors show up only under ppsc-hybrid:

  • Canva: MouseEvent rejects view: window, probably an inherited UIEventInit member missing from the tables.
  • Walmart: undefined is not iterable, with its script time roughly tripled.
  • Linear: getReader on undefined.

These are listed in tools/BENCHMARKS.md and still need fixing.

Known costs that ppsc adds, from benchmark-ops:

  • el.ownerDocument hands back the document proxy: 16 → 100ns, hit by jQuery-style el.ownerDocument === doc checks.
  • Native calls on the document pay the receiver fix: getElementById 27 → 47ns.
  • event.target (and currentTarget, relatedTarget, composedPath()) goes through an attribute trap so the window and document come back as proxies: 6.6 → 69ns per read.

Known open: a child frame reached by name (window.myframe, bare myframe, document.myframe) is handed out as its real window under ppsc. Keeping the proxy for every name a window might not have would give up most of the elision, so this is left as it is. window["0"] is handled.

tools/slot-proxy-to-function.md is a separate note on replacing the slot apply proxies with plain functions, the next step for that call overhead.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CuZEvxaYZC8a1Ezv4Jnvn9

velzie and others added 19 commits September 29, 2026 22:29
`dpsc` rewrites every member access whose key could name `location`,
`parent`, `top` or `eval`. `ppsc` rewrites only the references to the
window and the document, which the runtime answers with a Proxy, and
leaves member accesses alone.

Most of what a page does with the window never needs the proxy, so an
oxc_semantic pass (ppsc/elide.rs) proves which references are only ever
read through in ways the proxy would answer identically, and leaves those
pointing at the real object:

- a global, or a local holding one (`var d = document`, a UMD factory
  parameter, `var self = this`), is classified by every use it has; a
  computed member, an unsafe name or being passed on makes it unsafe
- an unsafe local stays wrapped, and its safe uses are pointed back at
  the global when it provably still holds it, or else at a twin local
  (`d$r = $unwrap(d)`) kept in step with every write
- with `ppsc_wrap_this`, a function's `this` gets the same treatment,
  rebound to a `$t` wrapped once in the function's prelude

`ppsc-hybrid` also renames a static read of an unsafe name (`x.top`,
`this.location`) to dpsc's `$sj_` accessor, so the proxy is only needed
where the object is used as a value: a computed key, or being passed on.
It is the default, with `ppsc_wrap_this` off.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuZEvxaYZC8a1Ezv4Jnvn9
elide_diff runs every case in elide-tests/ twice in a boa realm modelling
the page: as written with the globals bound to proxies, and rewritten with
them bound to the real objects. Elision must not change a result, and
must never hand a program the real `location`. elide-tests/generate.mjs
writes random programs for it (ELIDE_DIFF=<dir>).

`native verify <paths>` rewrites every script with each rewriter and
checks the output still parses, timing each.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuZEvxaYZC8a1Ezv4Jnvn9
tools/generate-unproxy-tables.mjs walks every IDL definition and writes
out each operation, constructor and attribute that takes or returns a
Window or a Document, for the ppsc runtime to patch. The output is
committed; regenerate with `./cv core:gen-unproxy`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuZEvxaYZC8a1Ezv4Jnvn9
`jsRewriter` picks the rewrite: "dpsc", "ppsc" or "ppsc-hybrid" (the
default), with `ppscWrapThis` off.

Under either ppsc mode the client makes the window and document proxies
(client/global.ts) and the unproxy layer (shared/unproxy.ts) puts natives
right: a proxy receiver is swapped for the real object once, in the slot
dispatch (`fixReceiver`), and members that take or return a window or a
document per the IDL tables get a layer that unwraps or wraps them.
Members that need only the receiver fixed are patched bare (`Patch`).

`$wrap` now hands back the proxies for the window, the document, and the
pretend parent and top, compares against values read once rather than
the accessors on every call, and returns early for non-objects. Added
`$unwrap`, `$rw` and `$rd` for the elision's twins and `$t` prelude.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuZEvxaYZC8a1Ezv4Jnvn9
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuZEvxaYZC8a1Ezv4Jnvn9
The runway harness takes `?flags={...}` and hands them to the frame it
creates, so every benchmark can compare chromium, dpsc, ppsc and
ppsc-hybrid on one build, round-robin with the order rotated each round.

- benchmark-octane.mjs: Octane 2.0, per test
- benchmark-ops.mjs: ns per operation for common real-code shapes,
  self-calibrating so the numbers survive large speed changes
- benchmark-sites.mjs: script time and new page errors on real sites
- capture-script-corpus.mjs: real-site scripts for `native verify`
- benchmark-speedometer.mjs: takes SCRAMJET_FLAGS

BENCHMARKS.md says which to run for what, with a baseline.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuZEvxaYZC8a1Ezv4Jnvn9
…tor traps

The global proxy's `defineProperty` trap made every define through the
window writable and configurable, and threw on an explicit
`configurable: false`. The `Object.defineProperty` hook only routed that
one entry point around it. With no trap, a define reaches the window with
the descriptor the page wrote, through every entry point.

The `Object.getOwnPropertyDescriptor` hook re-wrapped each accessor half
in a fresh slot on every call. That made getter identity unstable, cost a
slot per call on a hot builtin, and handed the real window to the native
`location` getter, leaking the real Location. Without it an extracted
native getter called on a proxy throws, as any unpatched native does. The
proper fix for descriptor reads is the reflection module's stand-ins.

The global proxy's `getOwnPropertyDescriptor` trap only forwarded, and a
declared trap is slower than none.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuZEvxaYZC8a1Ezv4Jnvn9
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuZEvxaYZC8a1Ezv4Jnvn9
From review of #138:

- A twin was declared right after its local, which in a
  `for (let h of xs)` head is a second binding the loop does not allow.
  Locals declared in a for-in/of head get no twin; the loop writes them
  without a reference the twin could follow anyway.
- The visitor had no loop handling, so `for (window in o)` became
  `for ($wrap(window) in o)`. It now walks for-in/of targets the way it
  walks destructuring targets.
- `location` written through a `var`, a destructuring pattern or a loop
  head assigned the real global and navigated unrewritten. Those now
  write `$temploc` and assign it through `$tryset`, as `location = x`
  already did; a module's `var` is left alone, since it is never the
  window's.
- stamp and lazystamp never stamped a call under ppsc, so `postMessage`
  saw the wrong incumbent. The stamping moves out of the dpsc visitor into
  `stamp.rs`, which both visitors call.

The elision's inserts at the end of an expression - a twin declaration,
the close of a twin write - are added before the visitor runs, and so
landed ahead of the expression's own closings at the same offset (a
stamped `var c = Date.now()` put the twin inside the call). They are now
`Trailer`s, which sort after every other insert at their offset, as does
`CleanVariableDeclaration`.

elide_diff also runs every case with `incumbency: stamp`, and
`native verify` takes `--incumbency`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuZEvxaYZC8a1Ezv4Jnvn9
…operator

d9af2d0 made `$tryset` recognise another frame's Location, but set
`lhs.href` on it: the real Location, so every bare `location = x`
navigated to `x` unrewritten, in every rewriter. It now sets the owning
client's location proxy. It also ignored the operator, so `location +=
"#a"` navigated to "#a" and `location ||= x` navigated at all.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuZEvxaYZC8a1Ezv4Jnvn9
The unproxy table generator did not resolve typedefs, so
`MessageEventSource` - `(WindowProxy or MessagePort or ServiceWorker)` -
never classified as a window, and neither did `GeometryNode`.
`MessageEvent.source` and its constructor and `initMessageEvent` are now
in the tables, along with the geometry methods.

The event layer answers `source` itself for scramjet's own messages, and
handed back the sender's real window; under ppsc it is the sender's
proxy, so `e.source === frames[0]` holds.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuZEvxaYZC8a1Ezv4Jnvn9
From review of #138: the tables knew Window, Document and Node but not
their supertype, so `new FocusEvent(t, {relatedTarget: document})` threw,
and `event.target`, `currentTarget`, `relatedTarget` and `composedPath()`
handed out the real window and document.

`EventTarget` classifies as "*", probed at runtime. A return or attribute
can now also be a list of a kind (`"*[]"`), wrapped element by element -
in place for a fresh list, as a cached frozen copy for a frozen one - or
a promise of one (`"Promise<w>"`, `requestWindow()`).

The ops benchmark gains two event cases: under ppsc a read of
`event.target` pays the attribute trap, 6.6ns to 69ns.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuZEvxaYZC8a1Ezv4Jnvn9
From review of #138: a string key was classified by name, and "0" is not
an unsafe one, so `window["0"]` was left on the real window and handed out
the child's real window. A string that is an array index is now unsafe on
a window, as a numeric key already was. Named frame access is left as it
is: keeping the proxy for every name a window might not have would give up
most of the elision.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuZEvxaYZC8a1Ezv4Jnvn9
…nstructor aliases

From review of #138:

- A dictionary argument had the members the IDL names unwrapped by
  writing them back into the page's object: a frozen or getter-only
  `{view: window}` threw, a mutable one was left holding the real window,
  and the getters ran in the wrong order and twice. The native now gets a
  view of the object that reads each member through when it asks, in its
  own order, unwrapping only the named ones.
- A proxy was only recognised by the client that made it, so
  `child.Document.prototype.querySelector.call(document, ...)` threw
  "Illegal invocation". The box now records every client's proxies, which
  the receiver fix consults once there is more than one client, and which
  argument unwrapping uses in place of reading a symbol off the value.
  Wrapping a returned window or document goes through the box's maps too.
- `RawProxy` on a constructor left `X.prototype.constructor` on the
  native: `MouseEvent.prototype.constructor !== MouseEvent`, and
  `new event.constructor(t, {view: window})` skipped the unwrapping. It
  now puts the prototype's `constructor` in the same slot, the way
  `Intercept` does, through a helper the two share.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuZEvxaYZC8a1Ezv4Jnvn9
A native dispatch calls a listener with the real current target as its
receiver, so under ppsc `this === window` was false in every listener on
the window or the document, and in their `on*` handlers. Both paths now
hand the listener what the page holds for it (`client.pageValue`).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuZEvxaYZC8a1Ezv4Jnvn9
…e `with`

From review of #138:

- An immediately invoked function was taken to be its own only call, so
  its parameters were substituted with the global it was called with. A
  named function expression can call itself - or hand itself out - with
  other arguments: `(function f(w, n) { ... f({title: "other"}, 0) ... })
  (document, 1)` read the document's title. It now only counts as the
  only call while nothing references its name.
- Inside a `with` body only the global substitution was ruled out, and the
  use fell through to its twin, picking a binding the object environment
  lookup might not: `var w = window; with ({w: {...}}) w.innerWidth`. A use
  inside `with` now gets no substitution at all.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuZEvxaYZC8a1Ezv4Jnvn9
…claration

From review of #138: a parameter was taken to be initialized everywhere,
so a use in an earlier parameter's default was substituted with the
global: `(function (a = w.title, w) { return a })(undefined, document)`
returned the title where it must throw. Parameters are initialized left
to right, so only a use past the parameter's own declaration - a later
default, or the body - is substituted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuZEvxaYZC8a1Ezv4Jnvn9
From review of #138: a local copied from `self` was substituted with
`self` at its later uses, but `self` and `frames` are [Replaceable] and
`globalThis` is writable - `var w = self; self = {...}; w.innerWidth`
read the replacement. Only `window` and `document`, which are
unforgeable, now stand in for a local, and only through a chain of those
two: `window.document`, but not `window.self` or `self.document`. A
destructured window is no longer substituted, since a pattern does not
say which key it read. Twins are unaffected: they hold the value the
local was given.

The differential test's window proxy answered `self` and the like with
itself whatever the window held; it now wraps what the window holds, as
client/global.ts does, which is what a replaced `window.self` needs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuZEvxaYZC8a1Ezv4Jnvn9
From review of #138: `x instanceof C` was safe when C was an unresolved
name starting with a capital, on the assumption that it named a platform
constructor. Any global can be replaced by an object whose
`Symbol.hasInstance` is handed the value - `window instanceof Custom`
went from true to false. The proxy answers a platform constructor's
prototype walk the same, so keeping it costs a trap on a rare check.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuZEvxaYZC8a1Ezv4Jnvn9

This branch has not been deployed

No deployments
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