From 0bb3fa8ff73d3b70395f193c30e1cd285d11c5d5 Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Thu, 3 Sep 2026 09:55:03 -0400 Subject: [PATCH 1/4] Spike: explicit WIT schema for the mutation channel, benchmarked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a second mutation channel expressing the same op vocabulary as an explicit WIT schema (`interface mutations`, `stream`, export `run-typed`) alongside the existing hand-rolled byte format on `run`, so the two encodings can be measured against each other. The byte channel stays the default and is untouched — it is the baseline. Both channels compile into the same component; `mountApp`'s `channel` option picks one, and both feed the identical `DomApplier`, so the DOM work is common and the delta is purely encode/transport/decode. `host/tests/typed_test.ts` asserts the two produce identical DOM for the same interaction sequence; a one-off instrumented run confirmed they also emit identical operation counts. Findings are written up in bench/README.md's "Channel A/B" section. In short: the typed channel costs 4-5.5 us per operation, which reproduces every row of the two-column table (~5x on create-10k, 12x on clear, invisible on the small ops). ~85% of that is polyengine's uncached canonical-ABI lift, not the component model or the schema — memoizing the layout functions alone takes create-10k from 5.07x to 1.74x. The floor is not 1.0x though: a typed channel must allocate JS objects per operation where the byte decoder allocates nothing (~190 vs ~45 ns/op in a pure-JS bound). The schema also costs something before any measurement: WIT forbids recursive types, so register-template's tree becomes an index arena, which admits malformed graphs the byte grammar cannot express and needs validation the byte decoder never needed. Also fixes bench/ops.ts's stale mountApp call (the bench had rotted against the current host API) and adds bench/ to `deno task check`, the gate that would have caught it. --- bench/README.md | 236 +++++++++- bench/bench.ts | 56 ++- bench/bench_worker.ts | 12 +- bench/ops.ts | 33 +- ...ts-2026-09-03-aarch64-unknown-linux-gnu.md | 15 + bench/run.sh | 4 +- deno.json | 2 +- fixtures/surface-probe/src/lib.rs | 13 + host/src/host.ts | 137 ++++-- host/src/typed.ts | 263 +++++++++++ host/tests/typed_test.ts | 428 ++++++++++++++++++ src/driver.rs | 215 ++++++++- src/lib.rs | 15 +- src/protocol.rs | 31 +- src/typed.rs | 322 +++++++++++++ wit/world.wit | 181 ++++++++ 16 files changed, 1867 insertions(+), 96 deletions(-) create mode 100644 bench/results-2026-09-03-aarch64-unknown-linux-gnu.md create mode 100644 host/src/typed.ts create mode 100644 host/tests/typed_test.ts create mode 100644 src/typed.rs diff --git a/bench/README.md b/bench/README.md index f873b20..e09e658 100644 --- a/bench/README.md +++ b/bench/README.md @@ -4,7 +4,10 @@ Tracks absolute row-operation throughput on the pinned polyengine (`justfile`'s `POLYENGINE_REV`), Deno + linkedom (host-side DOM), a real Dioxus component (`examples/bench-rows`), over the stream transport (the only transport — see "Transport A/B (historical)" below for why the call -transport was retired): +transport was retired). Since the typed-channel spike it runs **two +columns**: the stream transport's two mutation *channels*, the byte +protocol on `run` and the explicit WIT schema on `run-typed` (see +"Channel A/B" below). The operations are js-framework-benchmark-style row operations (create/append/update/swap/ remove/clear), so there's a baseline to track regressions against and to eventually compare with dioxus-web's published characteristics. @@ -128,7 +131,214 @@ run for the same reason. (or `FrameDecoder`) constructor hook, which is a `host/src` change — outside this track's territory. This is a gap for whoever owns `host/src` to close if the byte/batch columns are wanted later. This - bench therefore reports **wall-clock ms only**. + bench therefore reports **wall-clock ms only**. (The Channel A/B below + needed op *counts* and got them by patching each mounted applier's + `OpSink` methods in a throwaway script — enough for a one-off number, + still not a standing column.) + +## Channel A/B: byte protocol vs explicit WIT schema + +The mutation channel's wire format is a hand-rolled byte encoding +documented normatively in `wit/world.wit`'s `run` doc comment: opcodes, +operand widths and framing all live in prose, and the encoder +(`src/protocol.rs`) and decoder (`host/src/decoder.ts`) are two +independent hand-written implementations of it that can only be kept in +agreement by golden vectors and review. The obvious maintainability +alternative is to spell the vocabulary as WIT — records plus one +`operation` variant — and ship `stream` instead of +`stream`, letting bindgen own both sides. + +`wit/world.wit`'s `interface mutations` and the `run-typed` export are +that alternative, built so the two can be measured against each other. +Both channels are compiled into the same component (two exports); +`mountApp`'s `channel` option picks one, and both feed the identical +`DomApplier`, so the DOM work is common and the delta is purely +encode/transport/decode. `host/tests/typed_test.ts` asserts the two +produce identical DOM for the same interaction sequence — without that, +the numbers below would mean nothing. + +### What the schema costs before you measure anything + +**WIT forbids recursive type definitions.** `register-template`'s node +grammar is a tree, and + +```wit +record template-element { children: list } +variant template-node { element(template-element), ... } +``` + +is rejected outright: "type `template-node` depends on itself". The +typed schema therefore carries the template as an *arena* — a flat +`nodes` list plus `u32` indices in `roots` and `children` — which is a +strictly weaker encoding than the byte format's self-delimiting +recursive grammar: it admits out-of-range and cyclic index graphs that +the byte grammar cannot express, so `applyTyped` needs explicit +validation the byte decoder never needed (`host/src/typed.ts`'s +`rehydrateTemplateArena`). That is a real dent in the maintainability +case, independent of speed. + +The typed channel also gives up `readDirect`: polyengine's zero-copy +direct-read session is `stream` only (embedder-api amendment A21), +so the host reads with ordinary `read()` and pays a copy per batch. It +does **not** reintroduce the A15 host-retention hazard that killed the +call transport (see the historical section below): A15 licenses +quiescence on a retained end, a parked operation, *or* an unfinished +pump, and the host holds the lifted readable end for the instance's +lifetime, so the guest scheduler's persistent park is exactly as legal +here as on the byte channel. No deadlock trap fired in any run. + +Third, `use mutations.{operation}` in the world makes the interface an +import of every component built against it — including components that +only ever call `run`. The host supplies nothing (the interface has no +items), but the byte channel's own component type changed to add a +typed channel it does not use. + +### Measured + +Read the *ratio* column of the "Latest local numbers" table below, not +the absolute values. Op counts here are **instrumented**, not estimated: +a counting `OpSink` wrapped around each mounted applier during a one-off +run recorded the `OpSink` calls in each timed window. Both channels +produced identical counts for all seven operations, which is an +independent check on the equivalence test. + +| op | ops in the timed window | typed − bytes (median of 3 runs) | per op | +| --- | --- | --- | --- | +| create-10k | 90 006 | +437 ms | 4.9 µs | +| create-1k | 9 006 | +49 ms | 5.5 µs | +| append-1k | 9 002 | +47 ms | 5.2 µs | +| clear | 10 002 | +41 ms | 4.1 µs | +| update-every-10th | 101 | ~+1 ms | at the noise floor | +| swap-rows | 4 | — | at the noise floor | +| remove-row | 2 | — | at the noise floor | + +**The typed channel costs 4–5.5 µs per operation, and that single figure +reproduces the whole table.** The strongest evidence for the linearity +does not depend on the op counts at all: `create-1k` and `create-10k` +differ by 10x in rows and 8.9x in added milliseconds, and `append-1k` +— a different operation, same order of magnitude of ops — lands on +essentially the same delta as `create-1k`. + +The per-op cost is *not* flat to two digits, and the 1.3x spread is +predicted by the section's own mechanism rather than being noise: +`clear` is almost entirely `remove`, whose variant arm carries a bare +`u32`, while `create-*` is a mix dominated by multi-field records with +strings. A bigger type tree costs more to walk. + +The ratio column therefore measures how op-heavy each operation is, +nothing more. The three small ops sitting at ~1.0x is **not** evidence +the channels are comparable — with 2 to 101 operations in the window, +a per-op cost of any size is invisible there. + +### On the noise floor, and this file's own guardrail + +The "Interpretation guardrails" section below says a >2x run-to-run +delta on any operation is a bug lead, not a result. Comparing the +retained pre-spike run (`results-2026-08-31-*.md`) with the runs here, +the **bytes** column moved by 2.15x on `create-1k` and 2.47x on +`remove-row`. Discharging that rather than ignoring it: + +- The byte channel's code is unchanged by this spike. `Interner` was + refactored (`intern_raw` extracted) with `intern` kept as a wrapper, + and `mountApp` was restructured around a `channel` option with the + byte branch moved verbatim under an `else`. The golden byte vectors + (`cargo test --test vectors`) still match. +- The box was shared and busy across these runs; the byte column swung + by up to 1.7x *between two runs minutes apart with no code change at + all* (`create-1k`: 15.3 vs 8.8 ms). + +So: box noise, not a regression — but it also means **only the +op-heavy rows carry signal**, and no ratio here should be read past its +first digit. The three small ops are reported for completeness, not as +measurements of anything. + +### Where the ~5 µs goes — and how much of it is inherent + +Attributing the typed path with the polyengine runtime instrumented +(one-off local experiment; see the disclosure below): + +| stage | share | +| --- | --- | +| canonical-ABI per-element `load()` (`.deps/polyengine/runtime/src/cabi/load.ts`) | ~64% | +| embedder `toHost` value adaptation (`runtime/src/embedder/values.ts`) | ~20% | +| the rendezvous, guest-side lowering, promise plumbing | ~10% | +| `applyTyped`'s own dispatch | ~1–4% | + +Guest-side encoding is inside that third row and was not separated out. +It is not free: `src/typed.rs` allocates a `String` per dynamic operand +and a `Vec` per attribute/children/path list, where `src/protocol.rs` +appends into two reused buffers. A standalone measurement of *building* +the batch (no transport) actually favoured the typed writer slightly +(~36 vs ~56 ns/op) — pushing structs is cheaper than byte-serialising +them — so the allocation cost shows up in the lowering and the host +lift, not in construction. + +~85% is two generic interpreted walks of the type tree per element, +neither of which is inherent to the schema: + +- `alignment`/`elemSize`/`maxCaseAlignment`/`despecialize` are + recomputed from scratch for every element and every field. For a + 17-case variant of records that is a full type-tree walk per + operation, and `despecialize` *allocates* on every call for + `option`/`tuple`/`enum`/`result`. +- `toHost` calls `camelCase(label)` — a `split`/`map`/`join` — per field + per element, and finds the variant case by linear scan over 17 cases. + (Its `checkNoCollisions` is memoised per type and is *not* a per- + element cost.) + +Memoizing just the layout functions by type identity — a ~70-line +change to four functions in `runtime/src/cabi/{layout,types}.ts`, no +schema change and no compiled bindings — was measured as a sensitivity +run: + +| op | typed (stock) | typed (memoized) | ratio, stock → memoized | +| --- | --- | --- | --- | +| create-10k | 588 ms | 267 ms | 5.07x → 1.74x | +| create-1k | 62.3 ms | 23.8 ms | 4.08x → 2.71x | +| append-1k | 132 ms | 102 ms | 1.55x → 1.12x | +| clear | 49.3 ms | 7.6 ms | 12.18x → 2.08x | + +Given the noise floor above, trust the direction and the order of +magnitude of that column, not its third digit. + +A separate pure-JS experiment bounds the floor: materialising the +`{kind, value}` object graph the typed schema forces into existence, +with no runtime and no ABI lift at all, and walking it with the same +applier, costs ~190 ns/op against ~45 ns/op for decoding the byte frame. +That ~4x on the host decode step is the part no runtime work can remove +— a typed channel must allocate two or three JS objects per operation, +where the byte decoder passes operands straight to the sink as +arguments and allocates nothing. + +**Disclosure.** Three of the measurements above — the runtime-stage +attribution, the layout-memoization sensitivity run, and the pure-JS +floor — came from one-off local experiments that are not in this tree: +a scratch component exercising both encodings against a counting sink, +and a temporary patch to the gitignored `.deps/polyengine` checkout +(reverted; that checkout is pristine). They are described precisely +enough to redo — the memoization is a `WeakMap` cache keyed by type +identity on `alignment`, `elemSize`, `maxCaseAlignment` and +`despecialize` — but they are not re-derivable by running anything +committed here. The two-column table below, and the instrumented op +counts, are. + +### Read + +The delta is not small today: ~5x on op-heavy operations (12x on +`clear`, which is nearly pure per-op cost with no DOM work to dilute +it). It is the polyengine runtime's uncached lift, not the component +model or the schema, that makes it that large. Most of it is +recoverable — the layout memoization alone takes create-10k from 5.07x +to 1.74x, and a per-type compiled lift would go further. But the floor +is not 1.0x, and `create-10k` at 90 000 operations per render is +exactly where a per-op cost is least forgivable. + +The maintainability argument is also weaker than it looks: the arena +workaround for WIT's recursion ban moves the template grammar's +correctness burden from a hand-written decoder into hand-written index +validation, so the schema does not actually retire the +"two implementations must agree" problem for the one op where that +problem is hardest. ## Transport A/B (historical) @@ -268,21 +478,21 @@ see the numbers-are-box-relative guardrail below.) -# bench-rows results — 2026-08-31 +# bench-rows results — 2026-09-03 - Deno: 2.9.5 (aarch64-unknown-linux-gnu) -- git rev: ccc3c50 +- git rev: 9dc9810-dirty - Box note: numbers are box-relative — compare columns within this run, not across machines. See bench/README.md. -| op | ms (median of 5) | -| --- | --- | -| create-1k | 7.10 | -| create-10k | 79.41 | -| append-1k | 79.05 | -| update-every-10th | 5.37 | -| swap-rows | 4.99 | -| remove-row | 2.09 | -| clear | 3.07 | +| op | bytes (ms, median of 5) | typed (ms, median of 5) | typed / bytes | +| --- | --- | --- | --- | +| create-1k | 14.53 | 72.53 | 4.99x | +| create-10k | 91.03 | 528.18 | 5.80x | +| append-1k | 83.45 | 133.05 | 1.59x | +| update-every-10th | 4.73 | 5.81 | 1.23x | +| swap-rows | 2.92 | 5.34 | 1.83x | +| remove-row | 4.83 | 5.01 | 1.04x | +| clear | 3.69 | 44.31 | 12.02x | diff --git a/bench/bench.ts b/bench/bench.ts index a25dd30..22ead41 100644 --- a/bench/bench.ts +++ b/bench/bench.ts @@ -6,18 +6,20 @@ // retries — see bench/ops.ts's runOp doc); that renders as "N/A" in the // table rather than aborting the whole run or fabricating a number. // -// Methodology and interpretation guardrails: bench/README.md. This bench -// tracks stream-transport throughput only — the call transport was -// retired after the historical A/B recorded in bench/README.md's -// "Transport A/B (historical)" section. +// This bench A/Bs the two mutation channels (`run`'s hand-rolled byte +// format vs `run-typed`'s explicit WIT schema, wit/world.wit) against each +// other, over the same component build — restoring the two-column shape +// documented in bench/README.md's "Transport A/B" section (the earlier A/B +// there compared the byte channel against the since-retired "call" +// transport; this one compares it against the typed channel instead). import { ops, TRANSPORTS } from "./ops.ts"; import type { TransportName } from "./ops.ts"; interface OpResult { op: string; - medianMs: number | null; - error?: string; + medianMs: Record; + error: Record; } async function runWorker(opName: string, transport: TransportName): Promise<{ medianMs: number | null; error?: string }> { @@ -44,9 +46,20 @@ async function runWorker(opName: string, transport: TransportName): Promise<{ me async function benchAll(): Promise { const results: OpResult[] = []; - const transport = TRANSPORTS[0]; - for (const op of ops) { - const { medianMs, error } = await runWorker(op.name, transport); + for (const [i, op] of ops.entries()) { + const medianMs = {} as Record; + const error = {} as Record; + // Alternate which transport runs first per op (rather than always + // "bytes" then "typed") so any drift over the run (thermal, background + // GC, whatever) does not land preferentially on one channel. Each op + // still runs in its own process per transport, so there is no shared + // state to worry about — this only removes a fixed ordering bias. + const order = i % 2 === 0 ? TRANSPORTS : [...TRANSPORTS].reverse(); + for (const transport of order) { + const r = await runWorker(op.name, transport); + medianMs[transport] = r.medianMs; + error[transport] = r.error; + } results.push({ op: op.name, medianMs, error }); } return results; @@ -56,19 +69,34 @@ function fmtMs(ms: number | null): string { return ms === null ? "N/A" : ms.toFixed(2); } +function fmtRatio(bytesMs: number | null, typedMs: number | null): string { + if (bytesMs === null || typedMs === null || bytesMs === 0) return "N/A"; + return `${(typedMs / bytesMs).toFixed(2)}x`; +} + function renderTable(results: OpResult[]): string { - const header = "| op | ms (median of 5) |"; - const sep = "| --- | --- |"; - const rows = results.map((r) => `| ${r.op} | ${fmtMs(r.medianMs)} |`); + const header = "| op | bytes (ms, median of 5) | typed (ms, median of 5) | typed / bytes |"; + const sep = "| --- | --- | --- | --- |"; + const rows = results.map((r) => + `| ${r.op} | ${fmtMs(r.medianMs.bytes)} | ${fmtMs(r.medianMs.typed)} | ${ + fmtRatio(r.medianMs.bytes, r.medianMs.typed) + } |` + ); const table = [header, sep, ...rows].join("\n"); - const failureNotes = results.filter((r) => r.error).map((r) => `- **${r.op}**: ${r.error}`); + const failureNotes = results.flatMap((r) => + TRANSPORTS.filter((t) => r.error[t]).map((t) => `- **${r.op}** (${t}): ${r.error[t]}`) + ); if (failureNotes.length === 0) return table; return table + "\n\n### N/A explanations\n\n" + failureNotes.join("\n"); } async function gitRev(): Promise { + // `git describe --always --dirty` rather than `rev-parse --short HEAD`: + // this whole spike is uncommitted, so a bare HEAD rev would claim a + // pre-spike commit that has no `run-typed` in it at all — the `--dirty` + // suffix makes that visible instead of silently misleading. try { - const cmd = new Deno.Command("git", { args: ["rev-parse", "--short", "HEAD"], stdout: "piped" }); + const cmd = new Deno.Command("git", { args: ["describe", "--always", "--dirty"], stdout: "piped" }); const { stdout } = await cmd.output(); return new TextDecoder().decode(stdout).trim() || "unknown"; } catch { diff --git a/bench/bench_worker.ts b/bench/bench_worker.ts index b783d49..bfa63e6 100644 --- a/bench/bench_worker.ts +++ b/bench/bench_worker.ts @@ -8,10 +8,10 @@ // whole bench run; bench.ts renders that op as "N/A" and the failure is // reported plainly, not hidden. // -// Usage: deno run --allow-read=. --allow-env --allow-run bench/bench_worker.ts +// Usage: deno run --allow-read=. --allow-env --allow-run bench/bench_worker.ts import { defaultTranslator } from "@deltic/translator"; -import { loadComponentBytes, ops, runOp } from "./ops.ts"; +import { loadComponentBytes, ops, runOp, TRANSPORTS } from "./ops.ts"; import type { TransportName } from "./ops.ts"; async function main() { @@ -20,13 +20,13 @@ async function main() { if (!op) { throw new Error(`unknown op "${opName}"; known ops: ${ops.map((o) => o.name).join(", ")}`); } - if (transportArg !== "stream") { - throw new Error(`transport must be "stream", got "${transportArg}"`); + if (!(TRANSPORTS as string[]).includes(transportArg)) { + throw new Error(`transport must be one of ${TRANSPORTS.join(", ")}, got "${transportArg}"`); } - const transport: TransportName = transportArg; + const transport = transportArg as TransportName; const translator = await defaultTranslator(); - const bytes = await loadComponentBytes(transport); + const bytes = await loadComponentBytes(); try { const medianMs = await runOp(transport, bytes, translator, op); console.log(JSON.stringify({ op: opName, transport, medianMs })); diff --git a/bench/ops.ts b/bench/ops.ts index a3fd44c..df28fb4 100644 --- a/bench/ops.ts +++ b/bench/ops.ts @@ -25,21 +25,29 @@ import { parseHTML } from "linkedom"; import { mountApp } from "../host/src/host.ts"; import type { Mounted } from "../host/src/host.ts"; +import { defaultTranslator } from "@deltic/translator"; + +type Translator = Awaited>; export const RUNS = 5; // Below this, for an op touching >=1000 rows, the number is not credible // (dispatch: "an unexplainable number is a bug lead, not a result"). export const SANITY_FLOOR_MS = 0.5; -export type TransportName = "stream"; -export const TRANSPORTS: TransportName[] = ["stream"]; +// The two mutation channels (wit/world.wit `run` vs `run-typed`) A/B'd +// against each other — see "Transport A/B" in bench/README.md. Both use the +// SAME component build (one `.wasm`, two exports): `mountApp`'s `channel` +// option picks which export/decode path is used, so there is only one +// componentPath. +export type TransportName = "bytes" | "typed"; +export const TRANSPORTS: TransportName[] = ["bytes", "typed"]; -export function componentPath(t: TransportName): string { - return new URL(`./build/bench-rows-${t}.component.wasm`, import.meta.url).pathname; +export function componentPath(): string { + return new URL(`./build/bench-rows.component.wasm`, import.meta.url).pathname; } -export async function loadComponentBytes(t: TransportName): Promise { - const path = componentPath(t); +export async function loadComponentBytes(): Promise { + const path = componentPath(); try { return await Deno.readFile(path); } catch (e) { @@ -199,11 +207,16 @@ export function median(xs: number[]): number { async function freshMount( t: TransportName, componentBytes: Uint8Array, - translator: unknown, + translator: Translator, ): Promise<{ root: Element; mounted: Mounted; errors: unknown[] }> { const root = makeRoot(); const errors: unknown[] = []; - const mounted = await mountApp({ componentBytes, translator, root, onError: (err) => errors.push(err) }); + const mounted = await mountApp({ + source: { componentBytes, translator }, + root, + channel: t, + onError: (err) => errors.push(err), + }); await waitFor(() => root.querySelector("#row-count") !== null, `${t}: initial mount`); if (errors.length > 0) { throw new Error(`${t}: onError fired during mount: ${Deno.inspect(errors)}`); @@ -377,7 +390,7 @@ const MAX_ATTEMPTS_PER_OP = 3; export async function runOp( t: TransportName, componentBytes: Uint8Array, - translator: unknown, + translator: Translator, op: OpDef, ): Promise { let lastErr: unknown; @@ -405,7 +418,7 @@ export async function runOp( async function runOpOnce( t: TransportName, componentBytes: Uint8Array, - translator: unknown, + translator: Translator, op: OpDef, ): Promise { const { root, mounted } = await freshMount(t, componentBytes, translator); diff --git a/bench/results-2026-09-03-aarch64-unknown-linux-gnu.md b/bench/results-2026-09-03-aarch64-unknown-linux-gnu.md new file mode 100644 index 0000000..f10d83e --- /dev/null +++ b/bench/results-2026-09-03-aarch64-unknown-linux-gnu.md @@ -0,0 +1,15 @@ +# bench-rows results — 2026-09-03 + +- Deno: 2.9.5 (aarch64-unknown-linux-gnu) +- git rev: 9dc9810-dirty +- Box note: numbers are box-relative — compare columns within this run, not across machines. See bench/README.md. + +| op | bytes (ms, median of 5) | typed (ms, median of 5) | typed / bytes | +| --- | --- | --- | --- | +| create-1k | 14.53 | 72.53 | 4.99x | +| create-10k | 91.03 | 528.18 | 5.80x | +| append-1k | 83.45 | 133.05 | 1.59x | +| update-every-10th | 4.73 | 5.81 | 1.23x | +| swap-rows | 2.92 | 5.34 | 1.83x | +| remove-row | 4.83 | 5.01 | 1.04x | +| clear | 3.69 | 44.31 | 12.02x | diff --git a/bench/run.sh b/bench/run.sh index 22e7afe..d6531ce 100755 --- a/bench/run.sh +++ b/bench/run.sh @@ -13,8 +13,8 @@ mkdir -p bench/build echo "== building bench-rows ==" cargo build -p bench-rows-example --target wasm32-wasip2 --release cp target/wasm32-wasip2/release/bench_rows_example.wasm \ - bench/build/bench-rows-stream.component.wasm -wasm-tools validate --features component-model,cm-async bench/build/bench-rows-stream.component.wasm + bench/build/bench-rows.component.wasm +wasm-tools validate --features component-model,cm-async bench/build/bench-rows.component.wasm echo "== running bench.ts ==" deno run --allow-read=. --allow-write=bench --allow-env --allow-run bench/bench.ts "$@" diff --git a/deno.json b/deno.json index 8379998..9058c7a 100644 --- a/deno.json +++ b/deno.json @@ -11,7 +11,7 @@ "lib": ["deno.ns", "dom", "dom.iterable", "esnext"] }, "tasks": { - "check": "deno check host/src host/tests", + "check": "deno check host/src host/tests bench", "test": "deno test --allow-read=. host/tests/", "bench": "bash bench/run.sh" } diff --git a/fixtures/surface-probe/src/lib.rs b/fixtures/surface-probe/src/lib.rs index 86cf1b3..cafc1e5 100644 --- a/fixtures/surface-probe/src/lib.rs +++ b/fixtures/surface-probe/src/lib.rs @@ -432,6 +432,19 @@ impl Guest for Component { reader } + /// This fixture exercises the byte channel only. `run-typed` is the + /// other half of the spike's A/B (wit/world.wit: exactly one of + /// `run`/`run-typed` is called per instance), and nothing here mounts + /// through it — so this arm exists to satisfy the trait and closes the + /// stream immediately by dropping the write end. + async fn run_typed() -> wit_bindgen::rt::async_support::StreamReader< + polymorph::dioxus::mutations::Operation, + > { + let (writer, reader) = wit_stream::new::(); + drop(writer); + reader + } + async fn handle_event(_target: u32, name: u16, payload: Payload, ev: &HostDomEvent) { let (ops, strings, prevent) = build_event_batch(name, &payload); diff --git a/host/src/host.ts b/host/src/host.ts index e1fec97..18a2b2a 100644 --- a/host/src/host.ts +++ b/host/src/host.ts @@ -16,6 +16,8 @@ import { DispatchGate } from "./dispatch.ts"; import { FrameDecoder } from "./decoder.ts"; import { EventDispatcher, serializePayload } from "./events.ts"; import type { NativeEventLike } from "./events.ts"; +import { applyTyped } from "./typed.ts"; +import type { OperationLifted } from "./typed.ts"; export interface MountOptions { /** Component artifacts in either form `instantiate` accepts: a @@ -27,6 +29,12 @@ export interface MountOptions { * verbatim to `instantiate`. */ source: InstantiateSource; root: Element; + /** Which mutation channel to mount on. Defaults to "bytes". "typed" uses + * the explicit-WIT-schema channel (`run-typed` / `stream`, + * wit/world.wit `interface mutations`) instead of the hand-rolled byte + * format on `run`. Added to A/B the two encodings (bench/README.md); the + * byte channel remains the baseline and is unaffected by this option. */ + channel?: "bytes" | "typed"; /** Asynchronous failure after a successful mount: the mutation stream's * parked direct-read session rejecting (guest trap — `PeerTrappedError` — * or teardown), or a `handle-event` call rejecting. A failure during mount @@ -401,45 +409,98 @@ export async function mountApp(opts: MountOptions): Promise { handleEventExport = instance.exports.handleEvent as (...a: unknown[]) => unknown; - // `run` starts the app and returns the mutation channel's read end; its - // promise settles as soon as the guest hands the reader back (the app's - // scheduler keeps running as a spawned guest task). A trap before the - // return rejects here and propagates out of `mountApp`. - const ops = await (instance.exports.run as () => Promise>)(); - - // Park a direct-read session for the instance's lifetime. We always consume - // the FULL view per rendezvous — whole frames decoded+applied, any partial - // tail staged in the FrameDecoder — so `readDirect`'s "never acknowledge - // zero bytes" hazard (embedder-api.md amendment A21) never arises: - // `markRead` always receives `view.length`, never 0. - const consume = (src: DirectSource): "more" | "done" => { - const view = src.remaining(); - // The callback runs DOM application only (decodeBatch via the - // FrameDecoder) — no direct guest call. But DOM application can fire - // synchronous NATIVE events (detaching a focused element fires - // `focusout`), whose listeners would dispatch into the guest from - // inside this rendezvous. The gate enforces the A21 rule ("calls that - // can run guest code ... are forbidden" inside a direct-read callback) - // transitively: dispatches raised in this window are queued and drained - // by a microtask, once the rendezvous' guest turn has unwound. - gate.beginApply(); - try { - const n = frameDecoder.feed(view); - if (n < view.length) { - frameDecoder.stashRest(view, n); + // `run`/`run-typed` start the app and return the mutation channel's read + // end; the promise settles as soon as the guest hands the reader back + // (the app's scheduler keeps running as a spawned guest task). A trap + // before the return rejects here and propagates out of `mountApp`. + const channel = opts.channel ?? "bytes"; + const ops = channel === "typed" + ? await (instance.exports.runTyped as () => Promise>)() + : await (instance.exports.run as () => Promise>)(); + + if (channel === "typed") { + // Unlike `stream`, a typed stream has no zero-copy direct-read path + // (embedder-api.md amendment A21's `readDirect` is `stream` only — + // wit/world.wit's `run-typed` doc). Read with an ordinary `read()` + // instead. + // + // The guest scheduler's persistent park between renders needs SOME + // host-side reason the store's deadlock verdict stays suppressed + // (embedder-api.md amendment A15, "Deadlock-verdict suppression tracks + // host retention": suppression holds "while the host retains a way to + // act on a stream/future — a retained end, a parked host operation, or + // an unfinished producer pump"). Here that's the first disjunct, not the + // second: `mountApp` holds the lifted readable end (`ops`/`typedStream`) + // for the instance's whole lifetime — never lowered back into the guest + // — which is retention-by-itself, independent of whether a `read()` + // happens to be in flight at any given instant. (Runtime-side, this is + // `HostActivity` arming on the retained end and disarming only on a + // lower-back-to-guest — .deps/polyengine/runtime/src/exec/ + // host_streams.ts ~lines 295-318.) So, unlike the byte channel's parked + // `readDirect` session — which genuinely IS "a parked host operation" + // for as long as it runs — there being a brief gap here with no read + // outstanding (the await resumption between one `read()`'s chunk + // landing and `applyTyped` finishing, before the next `read()` is + // issued) is not itself a hazard: retention already covers it. + // + // The next read is still issued immediately after applying a chunk, + // with no unnecessary work in between — good practice for latency, not + // a correctness requirement. + // + // MAX_TYPED_READ must be large enough that a whole batch (up to ~40k + // operations for the 10k-row bench case) arrives in one chunk. + const MAX_TYPED_READ = 1 << 22; + const typedStream = ops as Stream; + (async () => { + while (!disposed) { + const chunk = await typedStream.read(MAX_TYPED_READ); + if (chunk.length === 0) break; // end of stream + gate.beginApply(); + try { + applyTyped(chunk, applier); + } finally { + gate.endApply(); + } } - } finally { - gate.endApply(); - } - src.markRead(view.length); - return "more"; - }; - // The session only settles on stream end/drop/fault, which for a healthy - // long-lived app never happens in normal operation. Route a rejection (peer - // trap, teardown) to onError rather than letting it become unhandled. - ops.readDirect(consume).catch((err: unknown) => { - if (!disposed) onError(err); - }); + })().catch((err: unknown) => { + if (!disposed) onError(err); + }); + } else { + const byteStream = ops as Stream; + // Park a direct-read session for the instance's lifetime. We always consume + // the FULL view per rendezvous — whole frames decoded+applied, any partial + // tail staged in the FrameDecoder — so `readDirect`'s "never acknowledge + // zero bytes" hazard (embedder-api.md amendment A21) never arises: + // `markRead` always receives `view.length`, never 0. + const consume = (src: DirectSource): "more" | "done" => { + const view = src.remaining(); + // The callback runs DOM application only (decodeBatch via the + // FrameDecoder) — no direct guest call. But DOM application can fire + // synchronous NATIVE events (detaching a focused element fires + // `focusout`), whose listeners would dispatch into the guest from + // inside this rendezvous. The gate enforces the A21 rule ("calls that + // can run guest code ... are forbidden" inside a direct-read callback) + // transitively: dispatches raised in this window are queued and drained + // by a microtask, once the rendezvous' guest turn has unwound. + gate.beginApply(); + try { + const n = frameDecoder.feed(view); + if (n < view.length) { + frameDecoder.stashRest(view, n); + } + } finally { + gate.endApply(); + } + src.markRead(view.length); + return "more"; + }; + // The session only settles on stream end/drop/fault, which for a healthy + // long-lived app never happens in normal operation. Route a rejection (peer + // trap, teardown) to onError rather than letting it become unhandled. + byteStream.readDirect(consume).catch((err: unknown) => { + if (!disposed) onError(err); + }); + } const mounted: Mounted = { applier, diff --git a/host/src/typed.ts b/host/src/typed.ts new file mode 100644 index 0000000..1559bf6 --- /dev/null +++ b/host/src/typed.ts @@ -0,0 +1,263 @@ +// Typed-channel applier for the polymorph:dioxus explicit WIT mutation +// schema (`interface mutations` / `stream`, wit/world.wit). +// +// This is the typed counterpart of decoder.ts's `decodeBatch`: instead of +// decoding a byte format, it walks already-lifted `operation` values (as +// polyengine's embedder lifts them per .deps/polyengine/contracts/ +// embedder-api.md "Value mapping") and drives the same `OpSink`. + +import type { OpSink, StrRef, TemplateNodeDesc } from "./decoder.ts"; + +// -- lifted-value shapes ---------------------------------------------------- +// +// contract:"Value mapping" — variant -> { kind, value? }, record -> plain +// object with camelCased fields, option as a record field -> absent when +// none / bare T when some, list -> Uint8Array, list -> number[], +// s64 -> bigint, f64/u32/u16 -> number, bool -> boolean, string -> string. + +interface TemplateAttrLifted { + name: StrRef; + ns?: StrRef; + value: string; +} + +interface TemplateElementLifted { + tag: StrRef; + ns?: StrRef; + attrs: TemplateAttrLifted[]; + children: number[]; +} + +type TemplateNodeLifted = + | { kind: "element"; value: TemplateElementLifted } + | { kind: "text"; value: string } + | { kind: "dynamic" }; + +interface RegisterTemplateLifted { + id: number; + nodes: TemplateNodeLifted[]; + roots: number[]; +} + +interface StackOpLifted { + id: number; + m: number; +} +interface PathOpLifted { + path: Uint8Array; + m: number; +} +interface AssignIdLifted { + path: Uint8Array; + id: number; +} +interface CreateTextNodeLifted { + id: number; + text: string; +} +interface LoadTemplateLifted { + id: number; + tmpl: number; + root: number; +} +interface SetTextLifted { + id: number; + text: string; +} + +type AttrValueLifted = + | { kind: "text"; value: string } + | { kind: "float"; value: number } + | { kind: "int"; value: bigint } + | { kind: "boolean"; value: boolean } + | { kind: "none" }; + +interface SetAttributeLifted { + id: number; + name: StrRef; + ns?: StrRef; + value: AttrValueLifted; +} + +interface EventListenerLifted { + id: number; + name: StrRef; + bubbles: boolean; +} + +interface CacheStringLifted { + id: number; + str: string; +} + +/** One lifted `operation` variant value. */ +export type OperationLifted = + | { kind: "cache-string"; value: CacheStringLifted } + | { kind: "register-template"; value: RegisterTemplateLifted } + | { kind: "append-children"; value: StackOpLifted } + | { kind: "assign-id"; value: AssignIdLifted } + | { kind: "create-placeholder"; value: number } + | { kind: "create-text-node"; value: CreateTextNodeLifted } + | { kind: "load-template"; value: LoadTemplateLifted } + | { kind: "replace-with"; value: StackOpLifted } + | { kind: "replace-placeholder"; value: PathOpLifted } + | { kind: "insert-after"; value: StackOpLifted } + | { kind: "insert-before"; value: StackOpLifted } + | { kind: "set-attribute"; value: SetAttributeLifted } + | { kind: "set-text"; value: SetTextLifted } + | { kind: "new-event-listener"; value: EventListenerLifted } + | { kind: "remove-event-listener"; value: EventListenerLifted } + | { kind: "remove"; value: number } + | { kind: "push-root"; value: number }; + +/** + * Rehydrate a `register-template` arena (`nodes` flat pre-order list, + * `roots`/`template-element.children` are `u32` indices into it — WIT + * forbids the natural recursive shape, see wit/world.wit's `mutations` + * interface doc) into the recursive `TemplateNodeDesc` tree `OpSink. + * registerTemplate` wants. + * + * The arena admits index graphs the byte grammar cannot express. Two are + * rejected outright, with a thrown Error rather than a hang or a silently + * wrong tree: an out-of-range index, and a cycle (a node that (transitively) + * indexes itself as a child — `state[idx] === 1` below, "on the current + * build path"). A THIRD shape is accepted rather than rejected: a DAG, where + * one node index is reachable as a child from two different parents. That + * is not a cycle (the build terminates), and the `built` memo below makes + * it safe — `build(idx)` is memoized once `state[idx] === 2`, so a + * DAG-shared node is built once and the resulting `TemplateNodeDesc` object + * is aliased into both parents' `children` arrays, rather than double-built + * or refused. `DomApplier.registerTemplate` only reads the desc tree to + * construct fresh DOM nodes per visit (never mutates or identity-compares + * the desc objects), so an aliased subtree is indistinguishable from two + * independently-built ones to every consumer. + */ +function rehydrateTemplateArena(nodes: TemplateNodeLifted[], roots: number[]): TemplateNodeDesc[] { + // 0 = unvisited, 1 = on the current path (cycle detection), 2 = done. + const state = new Uint8Array(nodes.length); + const built: (TemplateNodeDesc | undefined)[] = new Array(nodes.length); + + function build(idx: number): TemplateNodeDesc { + if (idx < 0 || idx >= nodes.length) { + throw new Error(`applyTyped: register-template arena index ${idx} out of range (${nodes.length} nodes)`); + } + if (state[idx] === 1) { + throw new Error(`applyTyped: register-template arena has a cycle at index ${idx}`); + } + if (state[idx] === 2) { + return built[idx]!; + } + state[idx] = 1; + const n = nodes[idx]; + let desc: TemplateNodeDesc; + switch (n.kind) { + case "element": { + const el = n.value; + desc = { + kind: "element", + tag: el.tag, + ns: el.ns ?? null, + attrs: el.attrs.map((a) => ({ name: a.name, ns: a.ns ?? null, value: a.value })), + children: el.children.map(build), + }; + break; + } + case "text": + desc = { kind: "text", value: n.value }; + break; + case "dynamic": + desc = { kind: "dynamic" }; + break; + } + state[idx] = 2; + built[idx] = desc; + return desc; + } + + return roots.map(build); +} + +/** Apply a batch of lifted `operation` values to `sink` — the typed + * counterpart of `decodeBatch`. */ +export function applyTyped(ops: OperationLifted[], sink: OpSink): void { + for (const op of ops) { + switch (op.kind) { + case "cache-string": + sink.cacheString(op.value.id, op.value.str); + break; + case "register-template": { + const { id, nodes, roots } = op.value; + sink.registerTemplate(id, rehydrateTemplateArena(nodes, roots)); + break; + } + case "append-children": + sink.appendChildren(op.value.id, op.value.m); + break; + case "assign-id": + sink.assignId(op.value.path, op.value.id); + break; + case "create-placeholder": + sink.createPlaceholder(op.value); + break; + case "create-text-node": + sink.createTextNode(op.value.id, op.value.text); + break; + case "load-template": + sink.loadTemplate(op.value.tmpl, op.value.root, op.value.id); + break; + case "replace-with": + sink.replaceWith(op.value.id, op.value.m); + break; + case "replace-placeholder": + sink.replacePlaceholder(op.value.path, op.value.m); + break; + case "insert-after": + sink.insertAfter(op.value.id, op.value.m); + break; + case "insert-before": + sink.insertBefore(op.value.id, op.value.m); + break; + case "set-attribute": { + const { id, name, ns, value } = op.value; + const nsResolved = ns ?? null; + switch (value.kind) { + case "text": + sink.setAttributeText(id, name, nsResolved, value.value); + break; + case "float": + sink.setAttributeFloat(id, name, nsResolved, value.value); + break; + case "int": + sink.setAttributeInt(id, name, nsResolved, value.value); + break; + case "boolean": + sink.setAttributeBool(id, name, nsResolved, value.value); + break; + case "none": + sink.setAttributeNone(id, name, nsResolved); + break; + } + break; + } + case "set-text": + sink.setText(op.value.id, op.value.text); + break; + case "new-event-listener": + sink.newEventListener(op.value.id, op.value.name, op.value.bubbles); + break; + case "remove-event-listener": + sink.removeEventListener(op.value.id, op.value.name, op.value.bubbles); + break; + case "remove": + sink.remove(op.value); + break; + case "push-root": + sink.pushRoot(op.value); + break; + default: { + const _exhaustive: never = op; + throw new Error(`applyTyped: unknown operation "${(_exhaustive as { kind: string }).kind}"`); + } + } + } +} diff --git a/host/tests/typed_test.ts b/host/tests/typed_test.ts new file mode 100644 index 0000000..26483cc --- /dev/null +++ b/host/tests/typed_test.ts @@ -0,0 +1,428 @@ +// Equivalence test: the byte channel (`run`/decodeBatch) and the typed +// channel (`run-typed`/applyTyped) must produce identical DOM output for +// the same interaction sequence against the same component. A benchmark of +// a wrong implementation is worthless, so this is the load-bearing test for +// the typed track. +// +// Uses the counter example (host/tests/counter_test.ts's mount/dispatch/ +// poll pattern) — it exercises templates (including nested children), +// dynamic text, several attribute value kinds, event listeners, and keyed +// list insert/remove. + +import { assertEquals, assertNotEquals } from "jsr:@std/assert@1"; +import { parseHTML } from "linkedom"; +import { defaultTranslator } from "@deltic/translator"; +import { mountApp } from "../src/host.ts"; +import type { Mounted } from "../src/host.ts"; +import { applyTyped } from "../src/typed.ts"; +import type { OpSink, TemplateNodeDesc } from "../src/decoder.ts"; + +const COMPONENT_PATH = "../../examples/build/counter.component.wasm"; + +async function waitFor(cond: () => boolean, what: string, maxIters = 2000): Promise { + for (let i = 0; i < maxIters; i++) { + if (cond()) return; + await new Promise((r) => setTimeout(r, 0)); + } + throw new Error(`waitFor timed out: ${what}`); +} + +function makeRoot() { + const { document } = parseHTML("
"); + return document.getElementById("root")!; +} + +async function loadComponentBytes(): Promise { + const url = new URL(COMPONENT_PATH, import.meta.url); + try { + return await Deno.readFile(url); + } catch (e) { + if (e instanceof Deno.errors.NotFound) { + throw new Error( + `component not found at ${url}. Run \`just example counter\` first ` + + `(builds examples/build/counter.component.wasm).`, + ); + } + throw e; + } +} + +function byId(root: Element, id: string): Element { + const el = root.querySelector(`#${id}`); + if (!el) throw new Error(`no element with id=${id} in ${root.innerHTML}`); + return el; +} + +interface TrackedEvent { + type: string; + clientX: number; + clientY: number; + button: number; + buttons: number; + preventDefault(): void; + stopPropagation(): void; +} + +function click(): TrackedEvent { + return { + type: "click", + clientX: 0, + clientY: 0, + button: 0, + buttons: 0, + preventDefault: () => {}, + stopPropagation: () => {}, + }; +} + +async function mountOn(channel: "bytes" | "typed"): Promise<{ root: Element; mounted: Mounted; errors: unknown[] }> { + const root = makeRoot(); + const componentBytes = await loadComponentBytes(); + const translator = await defaultTranslator(); + const errors: unknown[] = []; + const mounted = await mountApp({ + source: { componentBytes, translator }, + root, + channel, + onError: (err) => errors.push(err), + }); + await waitFor(() => root.querySelector("#count") !== null, `${channel}: initial mount`); + return { root, mounted, errors }; +} + +Deno.test("typed channel matches byte channel: counter example, full interaction sequence", async () => { + const bytes = await mountOn("bytes"); + const typed = await mountOn("typed"); + + function assertSame(step: string) { + assertEquals(typed.root.innerHTML, bytes.root.innerHTML, `DOM mismatch after ${step}`); + } + + assertSame("initial mount"); + assertEquals(bytes.errors, []); + assertEquals(typed.errors, []); + + // +/- buttons: set-text + set-attribute (class toggling even/odd). + let expectedCount = 0; + for (const step of ["inc", "inc", "dec", "inc"]) { + expectedCount += step === "inc" ? 1 : -1; + const want = String(expectedCount); + bytes.mounted.dispatch(byId(bytes.root, step), "click", click()); + typed.mounted.dispatch(byId(typed.root, step), "click", click()); + await waitFor(() => byId(bytes.root, "count").textContent === want, `${step} (bytes)`); + await waitFor(() => byId(typed.root, "count").textContent === want, `${step} (typed)`); + assertSame(`click ${step}`); + } + + // Typed text input: attribute of "text" kind (value) plus dynamic text. + bytes.mounted.dispatch(byId(bytes.root, "draft"), "input", { type: "input", value: "hello" }); + typed.mounted.dispatch(byId(typed.root, "draft"), "input", { type: "input", value: "hello" }); + await waitFor(() => byId(bytes.root, "echo").textContent === "hello", "input (bytes)"); + await waitFor(() => byId(typed.root, "echo").textContent === "hello", "input (typed)"); + assertSame("typed input"); + + // List add/add/remove: keyed diff exercises load-template (nested + // template children: li > text) / assign-id / replace-placeholder / + // remove. + for (const step of ["add", "add", "remove"]) { + bytes.mounted.dispatch(byId(bytes.root, step), "click", click()); + typed.mounted.dispatch(byId(typed.root, step), "click", click()); + } + await waitFor( + () => bytes.root.querySelectorAll("#items li").length === 3, + "list settle (bytes)", + ); + await waitFor( + () => typed.root.querySelectorAll("#items li").length === 3, + "list settle (typed)", + ); + assertSame("list add/add/remove"); + + // Form submit: onsubmit calls prevent_default(); assert the DOM (submitted + // counter text) stays identical across channels. + bytes.mounted.dispatch(byId(bytes.root, "form"), "submit", { ...click(), type: "submit" }); + typed.mounted.dispatch(byId(typed.root, "form"), "submit", { ...click(), type: "submit" }); + await waitFor(() => byId(bytes.root, "submitted").textContent === "submitted 1 time(s)", "submit (bytes)"); + await waitFor(() => byId(typed.root, "submitted").textContent === "submitted 1 time(s)", "submit (typed)"); + assertSame("form submit"); + + assertEquals(bytes.errors, [], "no onError on the byte channel"); + assertEquals(typed.errors, [], "no onError on the typed channel"); + + bytes.mounted.dispose(); + typed.mounted.dispose(); +}); + +// -- typed-path risk areas: unit tests directly against applyTyped -------- +// +// The counter example above never happens to emit a template with more +// than one level of nesting under `register-template` per templates +// batch, nor a non-text `attr-value` case (dioxus's own attribute encoding +// only uses `text` for string-interpolated attrs, which is everything +// counter has) — so those two typed-path-specific risks (the arena +// rehydration walk; the four non-text attr-value cases) are exercised +// here directly against a recording OpSink, independent of any guest. + +function recordingSink(ops: unknown[]): OpSink { + return { + cacheString(id, s) { + ops.push({ op: "cache-string", id, s }); + }, + registerTemplate(tmpl, roots) { + ops.push({ op: "register-template", tmpl, roots }); + }, + appendChildren(id, m) { + ops.push({ op: "append-children", id, m }); + }, + assignId(path, id) { + ops.push({ op: "assign-id", path: Array.from(path), id }); + }, + createPlaceholder(id) { + ops.push({ op: "create-placeholder", id }); + }, + createTextNode(id, text) { + ops.push({ op: "create-text-node", id, text }); + }, + loadTemplate(tmpl, root, id) { + ops.push({ op: "load-template", tmpl, root, id }); + }, + replaceWith(id, m) { + ops.push({ op: "replace-with", id, m }); + }, + replacePlaceholder(path, m) { + ops.push({ op: "replace-placeholder", path: Array.from(path), m }); + }, + insertAfter(id, m) { + ops.push({ op: "insert-after", id, m }); + }, + insertBefore(id, m) { + ops.push({ op: "insert-before", id, m }); + }, + setAttributeText(id, name, ns, value) { + ops.push({ op: "set-attribute-text", id, name, ns, value }); + }, + setAttributeFloat(id, name, ns, value) { + ops.push({ op: "set-attribute-float", id, name, ns, value }); + }, + setAttributeInt(id, name, ns, value) { + ops.push({ op: "set-attribute-int", id, name, ns, value }); + }, + setAttributeBool(id, name, ns, value) { + ops.push({ op: "set-attribute-bool", id, name, ns, value }); + }, + setAttributeNone(id, name, ns) { + ops.push({ op: "set-attribute-none", id, name, ns }); + }, + setText(id, text) { + ops.push({ op: "set-text", id, text }); + }, + newEventListener(id, name, bubbles) { + ops.push({ op: "new-event-listener", id, name, bubbles }); + }, + removeEventListener(id, name, bubbles) { + ops.push({ op: "remove-event-listener", id, name, bubbles }); + }, + remove(id) { + ops.push({ op: "remove", id }); + }, + pushRoot(id) { + ops.push({ op: "push-root", id }); + }, + }; +} + +Deno.test("applyTyped: register-template arena rehydrates nested children into a tree", () => { + const ops: unknown[] = []; + const sink = recordingSink(ops); + + // Arena for:
text0text1
+ // nodes[0] = div element, children = [1, 2, 3] + // nodes[1] = text "text0" + // nodes[2] = span element, children = [4] + // nodes[3] = dynamic + // nodes[4] = text "text1" + const nodes = [ + { kind: "element", value: { tag: 0, attrs: [], children: [1, 2, 3] } }, + { kind: "text", value: "text0" }, + { kind: "element", value: { tag: 1, attrs: [], children: [4] } }, + { kind: "dynamic" }, + { kind: "text", value: "text1" }, + ]; + applyTyped([{ kind: "register-template", value: { id: 7, nodes, roots: [0] } }] as never, sink); + + assertEquals(ops.length, 1); + const roots = (ops[0] as { roots: TemplateNodeDesc[] }).roots; + assertEquals(roots, [ + { + kind: "element", + tag: 0, + ns: null, + attrs: [], + children: [ + { kind: "text", value: "text0" }, + { + kind: "element", + tag: 1, + ns: null, + attrs: [], + children: [{ kind: "text", value: "text1" }], + }, + { kind: "dynamic" }, + ], + }, + ]); +}); + +Deno.test("applyTyped: register-template rejects an out-of-range arena index", () => { + const nodes = [{ kind: "element", value: { tag: 0, attrs: [], children: [99] } }]; + let threw = false; + try { + applyTyped( + [{ kind: "register-template", value: { id: 0, nodes, roots: [0] } }] as never, + recordingSink([]), + ); + } catch (e) { + threw = true; + assertEquals(e instanceof Error, true); + } + assertEquals(threw, true, "expected a thrown Error, not a silently wrong tree"); +}); + +Deno.test("applyTyped: register-template rejects a cyclic arena", () => { + // nodes[0].children includes 0 itself. + const nodes = [{ kind: "element", value: { tag: 0, attrs: [], children: [0] } }]; + let threw = false; + try { + applyTyped( + [{ kind: "register-template", value: { id: 0, nodes, roots: [0] } }] as never, + recordingSink([]), + ); + } catch (e) { + threw = true; + assertEquals(e instanceof Error, true); + } + assertEquals(threw, true, "expected a thrown Error, not a hang"); +}); + +Deno.test("applyTyped: set-attribute's non-text attr-value cases survive to the sink", () => { + const ops: unknown[] = []; + const sink = recordingSink(ops); + + applyTyped( + [ + { kind: "set-attribute", value: { id: 1, name: 2, value: { kind: "float", value: 1.5 } } }, + { kind: "set-attribute", value: { id: 1, name: 3, value: { kind: "int", value: 42n } } }, + { kind: "set-attribute", value: { id: 1, name: 4, value: { kind: "boolean", value: true } } }, + { kind: "set-attribute", value: { id: 1, name: 5, value: { kind: "none" } } }, + ] as never, + sink, + ); + + assertEquals(ops, [ + { op: "set-attribute-float", id: 1, name: 2, ns: null, value: 1.5 }, + { op: "set-attribute-int", id: 1, name: 3, ns: null, value: 42n }, + { op: "set-attribute-bool", id: 1, name: 4, ns: null, value: true }, + { op: "set-attribute-none", id: 1, name: 5, ns: null }, + ]); + // int arrives as bigint, matching OpSink.setAttributeInt's declared type. + assertEquals(typeof (ops[1] as { value: unknown }).value, "bigint"); +}); + +Deno.test("applyTyped: option ns absent lifts to null, not {kind:'none'}", () => { + const ops: unknown[] = []; + const sink = recordingSink(ops); + applyTyped( + [{ kind: "set-attribute", value: { id: 1, name: 2, value: { kind: "text", value: "v" } } }] as never, + sink, + ); + assertEquals((ops[0] as { ns: unknown }).ns, null); + assertNotEquals(ops[0], { op: "set-attribute-text", id: 1, name: 2, ns: { kind: "none" }, value: "v" }); +}); + +// -- F12 coverage gaps ------------------------------------------------------ +// +// The counter example's full-stack equivalence test above never exercises +// these shapes (its listeners are all bubbling, its templates are +// single-root with no namespaced attrs, and its list never empties to a +// placeholder) — cheap unit cases against the recording sink instead of new +// integration mounts. + +Deno.test("applyTyped: new-event-listener/remove-event-listener carry bubbles verbatim, both values", () => { + // This matters more than most: an inverted `bubbles` bit would NOT show + // up in an innerHTML diff at all (it only changes the host's listener + // delegation strategy — root-delegated vs per-element — not the + // markup), so this has to be asserted directly against the sink. + const ops: unknown[] = []; + const sink = recordingSink(ops); + applyTyped( + [ + { kind: "new-event-listener", value: { id: 1, name: 10, bubbles: true } }, + { kind: "new-event-listener", value: { id: 2, name: 11, bubbles: false } }, + { kind: "remove-event-listener", value: { id: 1, name: 10, bubbles: true } }, + { kind: "remove-event-listener", value: { id: 2, name: 11, bubbles: false } }, + ] as never, + sink, + ); + assertEquals(ops, [ + { op: "new-event-listener", id: 1, name: 10, bubbles: true }, + { op: "new-event-listener", id: 2, name: 11, bubbles: false }, + { op: "remove-event-listener", id: 1, name: 10, bubbles: true }, + { op: "remove-event-listener", id: 2, name: 11, bubbles: false }, + ]); +}); + +Deno.test("applyTyped: option ns PRESENT lifts to the bare id, on set-attribute and template attrs", () => { + const ops: unknown[] = []; + const sink = recordingSink(ops); + + // set-attribute path. + applyTyped( + [{ kind: "set-attribute", value: { id: 1, name: 2, ns: 9, value: { kind: "text", value: "v" } } }] as never, + sink, + ); + assertEquals(ops, [{ op: "set-attribute-text", id: 1, name: 2, ns: 9, value: "v" }]); + + // template-attr path (attrs: [] in every other arena test means this is + // otherwise uncovered): a static template attribute carrying a namespace. + ops.length = 0; + const nodes = [ + { kind: "element", value: { tag: 0, ns: 9, attrs: [{ name: 3, ns: 9, value: "v" }], children: [] } }, + ]; + applyTyped([{ kind: "register-template", value: { id: 0, nodes, roots: [0] } }] as never, sink); + const roots = (ops[0] as { roots: TemplateNodeDesc[] }).roots; + assertEquals(roots, [{ kind: "element", tag: 0, ns: 9, attrs: [{ name: 3, ns: 9, value: "v" }], children: [] }]); +}); + +Deno.test("applyTyped: register-template with multiple roots indexes each root correctly", () => { + // Arena for two roots: root0 = text "a", root1 = element with a child. + // nodes[0] = text "a" (root 0) + // nodes[1] = element, children = [2] (root 1) + // nodes[2] = text "b" + const ops: unknown[] = []; + const sink = recordingSink(ops); + const nodes = [ + { kind: "text", value: "a" }, + { kind: "element", value: { tag: 0, attrs: [], children: [2] } }, + { kind: "text", value: "b" }, + ]; + applyTyped([{ kind: "register-template", value: { id: 0, nodes, roots: [0, 1] } }] as never, sink); + + const roots = (ops[0] as { roots: TemplateNodeDesc[] }).roots; + assertEquals(roots, [ + { kind: "text", value: "a" }, + { + kind: "element", + tag: 0, + ns: null, + attrs: [], + children: [{ kind: "text", value: "b" }], + }, + ]); +}); + +Deno.test("applyTyped: create-placeholder reaches the sink with the bare element id", () => { + const ops: unknown[] = []; + const sink = recordingSink(ops); + applyTyped([{ kind: "create-placeholder", value: 4 }] as never, sink); + assertEquals(ops, [{ op: "create-placeholder", id: 4 }]); +}); diff --git a/src/driver.rs b/src/driver.rs index aa7cb43..d7517d4 100644 --- a/src/driver.rs +++ b/src/driver.rs @@ -53,6 +53,27 @@ //! as a rejection of the host's parked direct-read session //! (`PeerTrappedError`) — the channel the host actually watches. A trap //! during `run`'s own body rejects the host's `await exports.run()`. +//! +//! # The second, typed channel +//! +//! `run-typed` is the same driver against `wit/world.wit`'s `mutations` +//! interface: `stream` instead of `stream`, filled by +//! [`crate::typed::TypedWriter`] instead of [`MutationWriter`]. It exists so +//! the two encodings can be benchmarked against each other. +//! +//! **Exactly one of `run` / `run-typed` is used per instance** — the host +//! picks the channel at mount, per the WIT. The two have separate renderer +//! thread-locals ([`RENDERER`] / [`TYPED_RENDERER`]) and exactly one of them +//! is ever installed; `handle_event` dispatches on which one it finds. That +//! is one check per event, not per op. +//! +//! The typed half is written as a deliberate duplicate of the byte half +//! rather than a generic `Renderer`: the byte path is the measurement +//! baseline for this spike, and not perturbing it is worth more than the +//! shared code. Everything the two do is structurally identical — the same +//! in-flight-write staging, the same reader-gone handling, the same +//! no-borrow-across-an-await discipline — so the two `flush`es and the two +//! `render`s should be read as a pair and kept in step. use std::cell::RefCell; use std::future::Future; @@ -66,9 +87,11 @@ use dioxus_html::PlatformEventData; use wit_bindgen::rt::async_support::{spawn_local, StreamReader, StreamWriter}; use crate::bindings::polymorph::dioxus::events::Payload; +use crate::bindings::polymorph::dioxus::mutations::Operation; use crate::bindings::{wit_stream, DomEvent}; use crate::events::{WitEventConverter, WitEventData}; use crate::protocol::Interner; +use crate::typed::TypedWriter; use crate::writer::MutationWriter; /// The read end of the mutation channel: what `run` hands back to the host. @@ -76,6 +99,10 @@ use crate::writer::MutationWriter; /// without the app crate naming wit-bindgen's runtime module. pub type MutationStream = StreamReader; +/// The read end of the typed mutation channel: what `run_typed` hands back. +/// Same role as [`MutationStream`], for the `stream` twin. +pub type TypedMutationStream = StreamReader; + /// Everything the flush path needs, shared by the `run` loop and by /// `handle-event`. struct Renderer { @@ -99,9 +126,30 @@ struct Renderer { dead: bool, } +/// The typed channel's analogue of [`Renderer`]. Field-for-field the same +/// except that the unit of flow is an `Operation` rather than a byte, so +/// there is no frame scratch buffer: a batch is the writer's `Vec` itself, +/// taken whole. +struct TypedRenderer { + writer: TypedWriter, + /// Taken for the duration of a write, exactly as [`Renderer::stream`]. + stream: Option>, + /// Operations staged while another task owns `stream`, drained by the + /// in-flight flusher before it hands the writer back — keeping batches + /// in order. (Batch boundaries are not preserved across staging, just as + /// the byte channel's staged frames are concatenated; the host applies + /// operations in order and does not depend on where a write ends.) + pending: Vec, + /// Set once the host has dropped the read end. See [`Renderer::dead`]. + dead: bool, +} + thread_local! { static VDOM: RefCell> = const { RefCell::new(None) }; static RENDERER: RefCell> = const { RefCell::new(None) }; + /// The typed channel's renderer. Exactly one of this and [`RENDERER`] is + /// ever installed (`run` xor `run-typed`; see the module doc). + static TYPED_RENDERER: RefCell> = const { RefCell::new(None) }; /// Kept separately from `VDOM` so event dispatch never has to borrow the /// VirtualDom itself (`Runtime::handle_event` only needs the runtime). static RUNTIME: RefCell>> = const { RefCell::new(None) }; @@ -193,6 +241,97 @@ async fn flush() { } } +/// The typed channel's [`flush`]: push the current batch of operations to +/// the host as one `write_all`. +/// +/// One batch = one stream write of the whole `Vec` — that is the +/// property being benchmarked, so it is not chunked here. Staging, +/// ordering, and reader-gone handling are identical to [`flush`]; read the +/// two together. +async fn flush_typed() { + enum Action { + Nothing, + Stream(StreamWriter, Vec), + /// Another task owns the stream writer; our operations were staged + /// and will be drained by that task in order. + Staged, + } + + let action = TYPED_RENDERER.with_borrow_mut(|r| { + let r = r.as_mut().expect("driver: typed renderer not initialized"); + if r.writer.batch.is_empty() { + return Action::Nothing; + } + if r.dead { + // The reader is gone; discard rather than growing `pending` + // unboundedly across every future flush (see `flush`). + r.writer.batch.clear(); + return Action::Nothing; + } + // `mem::take` rather than `clear`: the operations are moved into the + // write, so the batch leaves with them. Its capacity comes back + // through `write_all`'s return value and is recycled below. + let batch = std::mem::take(&mut r.writer.batch); + match r.stream.take() { + Some(w) => Action::Stream(w, batch), + None => { + r.pending.extend(batch); + Action::Staged + } + } + }); + + match action { + Action::Nothing | Action::Staged => {} + Action::Stream(mut w, mut ops) => { + loop { + // `write_all` loops internally over partial writes and gives + // back whatever it could not deliver; a non-empty remainder + // means the read end is gone, not a short write to retry. + let leftover = w.write_all(ops).await; + if !leftover.is_empty() { + TYPED_RENDERER.with_borrow_mut(|r| { + let r = r.as_mut().unwrap(); + r.dead = true; + r.pending.clear(); + }); + return; + } + // Anything another task staged while we were awaiting goes + // out now, before the writer becomes available again — + // otherwise operations would leave the guest out of order. + let staged = TYPED_RENDERER.with_borrow_mut(|r| { + let r = r.as_mut().unwrap(); + // `leftover` is the drained batch: an empty `Vec` that + // kept its capacity. Hand that capacity back to the + // writer so steady-state flushing does not regrow the + // batch from zero — the same recycling the byte + // channel's `flush` does with its scratch buffer, and + // required for the A/B to be fair (a batch is tens of + // thousands of operations at bench sizes, so dropping + // the capacity would charge the typed column a dozen + // reallocations per batch that the byte column does not + // pay). + // + // Only when the writer has not already started filling + // the next batch: another task may have rendered into it + // while we were awaiting, and that batch must not be + // clobbered. + if r.writer.batch.is_empty() { + r.writer.batch = leftover; + } + std::mem::take(&mut r.pending) + }); + if staged.is_empty() { + TYPED_RENDERER.with_borrow_mut(|r| r.as_mut().unwrap().stream = Some(w)); + return; + } + ops = staged; + } + } + } +} + /// Await the next scheduler wakeup without holding a borrow of the VirtualDom /// across the await point. See the module doc for why this is sound. fn wait_for_work() -> impl Future { @@ -267,6 +406,65 @@ fn render(step: impl FnOnce(&mut VirtualDom, &mut MutationWriter)) { }) } +/// The typed channel's [`render`]: one render step with both thread-locals +/// borrowed and nothing awaited in between (same invariant, same reason). +fn render_typed(step: impl FnOnce(&mut VirtualDom, &mut TypedWriter)) { + VDOM.with_borrow_mut(|dom| { + TYPED_RENDERER.with_borrow_mut(|r| { + let dom = dom.as_mut().expect("driver: vdom not initialized"); + let r = r.as_mut().expect("driver: typed renderer not initialized"); + step(dom, &mut r.writer); + }) + }) +} + +/// Implementation of the world's `run-typed` export: the typed twin of +/// [`run`]. +/// +/// Structurally identical to [`run`] — install the converter, build the +/// VirtualDom, install RUNTIME/INTERNER/VDOM and the typed renderer, create +/// the stream, spawn the mount-and-serve task, return the reader — against +/// `stream` instead of `stream`. Everything the module doc +/// says about `run`'s lifecycle (why the scheduler is a spawned task, why +/// nothing may be written before the reader is returned, why the park is +/// legal, how failure surfaces) applies here unchanged. Exactly one of the +/// two is called per instance. +pub async fn run_typed(root: fn() -> Element) -> TypedMutationStream { + // dioxus-html's converter slot is global and write-once per process; a + // component instance is a fresh process image, so this runs exactly once. + dioxus_html::set_event_converter(Box::new(WitEventConverter)); + + let dom = VirtualDom::new(root); + let interner = Rc::new(RefCell::new(Interner::new())); + RUNTIME.set(Some(dom.runtime())); + INTERNER.set(Some(interner.clone())); + VDOM.set(Some(dom)); + + let (writer, reader) = wit_stream::new(); + + // Installed before returning, so a `handle-event` racing the host's very + // first read finds initialized state rather than tripping an `expect`. + TYPED_RENDERER.set(Some(TypedRenderer { + writer: TypedWriter::new(interner), + stream: Some(writer), + pending: Vec::new(), + dead: false, + })); + + spawn_local(async move { + render_typed(|dom, w| dom.rebuild(w)); + flush_typed().await; + + loop { + wait_for_work().await; + render_typed(|dom, w| dom.render_immediate(w)); + flush_typed().await; + } + }); + + reader +} + /// Implementation of the world's `handle-event` export. /// /// Dispatch is synchronous (Dioxus's synthetic bubbling included). Afterwards @@ -303,8 +501,17 @@ pub async fn handle_event(target: u32, name: u16, payload: Payload, ev: &DomEven ev.prevent_default(); } - render(|dom, w| dom.render_immediate(w)); - flush().await; + // Flush on whichever channel this instance mounted. Exactly one of the + // two renderers is installed (`run` xor `run-typed`), and this is the + // only place that has to ask: one check per event, and the byte path + // takes the first branch without touching typed state. + if RENDERER.with_borrow(|r| r.is_some()) { + render(|dom, w| dom.render_immediate(w)); + flush().await; + } else { + render_typed(|dom, w| dom.render_immediate(w)); + flush_typed().await; + } } /// Wire an app crate's root component into the `polymorph:dioxus/app` world. @@ -326,6 +533,10 @@ macro_rules! launch { $crate::driver::run($root).await } + async fn run_typed() -> $crate::driver::TypedMutationStream { + $crate::driver::run_typed($root).await + } + async fn handle_event( target: u32, name: u16, diff --git a/src/lib.rs b/src/lib.rs index bf46452..8e6f478 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,11 +4,14 @@ //! and dynamic strings) matching the wire format documented in //! `wit/world.wit`. //! - [`writer`]: the `dioxus_core::WriteMutations` sink that fills a batch. -//! - `bindings` / `driver` / `events` (wasm32 only): the generated WIT -//! bindings, the `run`/`handle-event` implementation, and the -//! `HtmlEventConverter` over the WIT payload types. These are gated on -//! `target_arch = "wasm32"` so `cargo test` can exercise the encoder and -//! writer natively. +//! - `bindings` / `driver` / `events` / `typed` (wasm32 only): the generated +//! WIT bindings, the `run`/`run-typed`/`handle-event` implementation, the +//! `HtmlEventConverter` over the WIT payload types, and the +//! `WriteMutations` sink for the typed channel's `stream`. +//! These are gated on `target_arch = "wasm32"` so `cargo test` can +//! exercise the encoder and writer natively. `typed` names the generated +//! bindings and so is not covered by `cargo test`; it is kept obviously +//! parallel to [`writer`] and checked host-side for equivalence. //! //! An application crate wires itself up with [`launch!`]. @@ -21,3 +24,5 @@ pub mod bindings; pub mod driver; #[cfg(target_arch = "wasm32")] pub mod events; +#[cfg(target_arch = "wasm32")] +pub mod typed; diff --git a/src/protocol.rs b/src/protocol.rs index 75806bc..c753426 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -392,16 +392,25 @@ impl Interner { Interner { ids: FxHashMap::default(), names: Vec::new() } } - /// Return the interned id for `s`, emitting `cache-string` into `batch` - /// on first sight of this pointer identity. + /// Return the interned id for `s`, plus whether *this* call is the one + /// that defined it (i.e. whether the caller owes the wire a + /// `cache-string` for it). + /// + /// This is the emission-agnostic core of interning: the id space, the + /// pointer-identity keying and the reverse map live here, and each + /// encoder decides how to emit the definition. [`Self::intern`] is the + /// byte encoder's wrapper; `typed::TypedWriter` pushes an + /// `Operation::CacheString` instead. Only one encoder is live per + /// instance (`run` xor `run-typed`, per `wit/world.wit`), so an id is + /// never defined on one channel and referenced on the other. /// /// Panics if more than `u16::MAX - 1` distinct strings are interned /// (id `0xffff` is reserved as the `strref` "none" sentinel, so it must /// never be assigned). - pub fn intern(&mut self, batch: &mut Batch, s: &'static str) -> u16 { + pub fn intern_raw(&mut self, s: &'static str) -> (u16, bool) { let key = (s.as_ptr() as usize, s.len()); if let Some(&id) = self.ids.get(&key) { - return id; + return (id, false); } let next = self.names.len(); assert!( @@ -413,7 +422,19 @@ impl Interner { let id = next as u16; self.ids.insert(key, id); self.names.push(s); - batch.cache_string(id, s); + (id, true) + } + + /// Return the interned id for `s`, emitting `cache-string` into `batch` + /// on first sight of this pointer identity. + /// + /// Panics if more than `u16::MAX - 1` distinct strings are interned + /// (see [`Self::intern_raw`]). + pub fn intern(&mut self, batch: &mut Batch, s: &'static str) -> u16 { + let (id, is_new) = self.intern_raw(s); + if is_new { + batch.cache_string(id, s); + } id } diff --git a/src/typed.rs b/src/typed.rs new file mode 100644 index 0000000..030c763 --- /dev/null +++ b/src/typed.rs @@ -0,0 +1,322 @@ +//! [`TypedWriter`]: the `run-typed` channel's `dioxus_core::WriteMutations` +//! sink, pushing `mutations::Operation` values into a `Vec` instead of +//! encoding bytes. +//! +//! This is [`crate::writer::MutationWriter`] op-for-op, against the explicit +//! WIT schema in `wit/world.wit`'s `mutations` interface rather than the +//! byte format documented on `run`. The two are deliberately parallel and +//! deliberately not factored together: the byte channel is the benchmark +//! baseline for this spike, so nothing here may perturb it. Read the two +//! side by side — a divergence between them is a bug in this file, and the +//! host-side equivalence test is what catches it (this module names the +//! generated bindings, so it is `wasm32`-only and `cargo test` never builds +//! it). +//! +//! # Interning invariant +//! +//! `writer.rs`'s module doc states the discipline for the byte encoder: a +//! `cache-string` op must never land in the middle of another op's operands, +//! so every name a composite op needs is interned before that op's first +//! byte is written. Here the *mechanics* of the hazard are gone — an +//! `Operation` is a value, and pushing a `CacheString` cannot split one — +//! but the *ordering* requirement is identical and just as binding: the host +//! resolves a `str-ref` against definitions it has already seen, so every +//! `CacheString` must precede the operation referencing it in the batch. +//! This module therefore keeps the same two-pass template walk +//! (`intern_template_node` then `flatten_template_node`) and the same +//! intern-then-build order in `set_attribute` and the listener ops. +//! +//! # Why template registration flattens +//! +//! WIT forbids recursive type definitions, so `register-template` carries an +//! arena (`nodes`, plus `u32` indices in `roots` and each element's +//! `children`) where the byte grammar carries a self-delimiting recursive +//! tree. See the `mutations` interface doc in `wit/world.wit`. + +use std::cell::RefCell; +use std::rc::Rc; + +use dioxus_core::{AttributeValue, ElementId, Template, TemplateAttribute, TemplateNode, WriteMutations}; +use rustc_hash::FxHashMap; + +use crate::bindings::polymorph::dioxus::mutations as m; +use crate::protocol::Interner; + +/// Encodes dioxus mutations as `mutations::Operation` values. +/// +/// The interner is shared (`Rc>`) with the event-dispatch path, +/// which needs the reverse `u16 -> &'static str` lookup to turn a +/// `handle-event` name id back into a dioxus event name — exactly as +/// [`crate::writer::MutationWriter`] does. +pub struct TypedWriter { + /// The batch being filled. The driver drains it with `std::mem::take` + /// once per flush and writes the whole `Vec` in one `write_all`. The + /// operations are moved into the write, but the `Vec` itself comes back + /// from `write_all` emptied with its capacity intact, and the driver + /// hands that capacity back here — so a steady-state batch reuses one + /// allocation, exactly as the byte channel reuses its frame scratch + /// buffer. See `driver::flush_typed`. + pub batch: Vec, + interner: Rc>, + /// Guest-assigned template ids, keyed exactly as + /// [`crate::writer::MutationWriter`]'s are: the pointer identity of + /// `template`'s `roots`/`node_paths`/`attr_paths` slices. See that + /// field's doc for the tradeoff (duplicate registration of structurally + /// identical templates in unmerged-statics builds). + templates: FxHashMap<(usize, usize, usize), u16>, +} + +impl TypedWriter { + /// Create a writer sharing `interner` with the event-dispatch path. + pub fn new(interner: Rc>) -> Self { + TypedWriter { batch: Vec::new(), interner, templates: FxHashMap::default() } + } + + /// Intern `s`, pushing `Operation::CacheString` on first sight of this + /// pointer identity — the same points at which `MutationWriter` emits + /// the byte format's `cache-string` op. + fn intern(&mut self, s: &'static str) -> u16 { + let (id, is_new) = self.interner.borrow_mut().intern_raw(s); + if is_new { + self.batch.push(m::Operation::CacheString(m::CacheString { id, str: s.to_string() })); + } + id + } + + fn intern_opt(&mut self, s: Option<&'static str>) -> Option { + s.map(|s| self.intern(s)) + } + + /// Pass 1 of template registration: intern every `&'static str` the + /// template references, so all their `CacheString` operations precede + /// the `RegisterTemplate` that references their ids. + fn intern_template_node(&mut self, node: &'static TemplateNode) { + if let TemplateNode::Element { tag, namespace, attrs, children } = node { + self.intern(tag); + self.intern_opt(*namespace); + for attr in *attrs { + // Dynamic template attributes are realized later through + // `set_attribute`; only static ones are part of the template. + if let TemplateAttribute::Static { name, namespace, .. } = attr { + self.intern(name); + self.intern_opt(*namespace); + } + } + for child in *children { + self.intern_template_node(child); + } + } + } + + /// Pass 2: append `node` and its subtree to `nodes` in pre-order, + /// returning `node`'s own index. + /// + /// The node is reserved in `nodes` *before* its children are walked (a + /// `Dynamic` placeholder stands in), so that the parent's index is fixed + /// while the children — which occupy later slots — are appended. The + /// reserved slot is then overwritten with the real element carrying the + /// child indices just collected. Must run after + /// [`Self::intern_template_node`]. + fn flatten_template_node( + &mut self, + node: &'static TemplateNode, + nodes: &mut Vec, + ) -> u32 { + match node { + TemplateNode::Element { tag, namespace, attrs, children } => { + let tag_id = self.intern(tag); + let ns_id = self.intern_opt(*namespace); + // Dynamic template attributes are realized later through + // `set_attribute`; only static ones are part of the template. + let mut wit_attrs = Vec::new(); + for attr in *attrs { + if let TemplateAttribute::Static { name, value, namespace } = attr { + let name_id = self.intern(name); + let ns_id = self.intern_opt(*namespace); + wit_attrs.push(m::TemplateAttr { + name: name_id, + ns: ns_id, + value: value.to_string(), + }); + } + } + let index = nodes.len() as u32; + nodes.push(m::TemplateNode::Dynamic); // reserved; overwritten below + let child_indices = children + .iter() + .map(|child| self.flatten_template_node(child, nodes)) + .collect(); + nodes[index as usize] = m::TemplateNode::Element(m::TemplateElement { + tag: tag_id, + ns: ns_id, + attrs: wit_attrs, + children: child_indices, + }); + index + } + TemplateNode::Text { text } => { + let index = nodes.len() as u32; + nodes.push(m::TemplateNode::Text(text.to_string())); + index + } + // A runtime-supplied node slot; the host materializes a + // placeholder that later ops (assign-id / replace-placeholder) + // address by path. + TemplateNode::Dynamic { .. } => { + let index = nodes.len() as u32; + nodes.push(m::TemplateNode::Dynamic); + index + } + } + } + + /// Return the id for `template`, registering it on first encounter. + /// + /// Panics if more than `u16::MAX` distinct templates are registered + /// (mirrors `MutationWriter::template_id` and [`Interner::intern_raw`]'s + /// id-space guard). + fn template_id(&mut self, template: Template) -> u16 { + let key = ( + template.roots.as_ptr() as usize, + template.node_paths.as_ptr() as usize, + template.attr_paths.as_ptr() as usize, + ); + if let Some(&id) = self.templates.get(&key) { + return id; + } + let next = self.templates.len(); + assert!( + next < u16::MAX as usize, + "typed: registered more than {} distinct templates; template id \ + space (u16) exhausted", + u16::MAX + ); + let id = next as u16; + self.templates.insert(key, id); + + // Pass 1 first, so every CacheString precedes the RegisterTemplate + // referencing its id (see the module doc). + for root in template.roots.iter() { + self.intern_template_node(root); + } + let mut nodes = Vec::new(); + let roots = template + .roots + .iter() + .map(|root| self.flatten_template_node(root, &mut nodes)) + .collect(); + self.batch.push(m::Operation::RegisterTemplate(m::RegisterTemplate { id, nodes, roots })); + id + } +} + +impl WriteMutations for TypedWriter { + fn append_children(&mut self, id: ElementId, m: usize) { + self.batch.push(m::Operation::AppendChildren(m::StackOp { id: id.0 as u32, m: m as u32 })); + } + + fn assign_node_id(&mut self, path: &'static [u8], id: ElementId) { + self.batch + .push(m::Operation::AssignId(m::AssignId { path: path.to_vec(), id: id.0 as u32 })); + } + + fn create_placeholder(&mut self, id: ElementId) { + self.batch.push(m::Operation::CreatePlaceholder(id.0 as u32)); + } + + fn create_text_node(&mut self, value: &str, id: ElementId) { + self.batch.push(m::Operation::CreateTextNode(m::CreateTextNode { + id: id.0 as u32, + text: value.to_string(), + })); + } + + fn load_template(&mut self, template: Template, index: usize, id: ElementId) { + let tmpl = self.template_id(template); + self.batch.push(m::Operation::LoadTemplate(m::LoadTemplate { + id: id.0 as u32, + tmpl, + root: index as u16, + })); + } + + fn replace_node_with(&mut self, id: ElementId, m: usize) { + self.batch.push(m::Operation::ReplaceWith(m::StackOp { id: id.0 as u32, m: m as u32 })); + } + + fn replace_placeholder_with_nodes(&mut self, path: &'static [u8], m: usize) { + self.batch + .push(m::Operation::ReplacePlaceholder(m::PathOp { path: path.to_vec(), m: m as u32 })); + } + + fn insert_nodes_after(&mut self, id: ElementId, m: usize) { + self.batch.push(m::Operation::InsertAfter(m::StackOp { id: id.0 as u32, m: m as u32 })); + } + + fn insert_nodes_before(&mut self, id: ElementId, m: usize) { + self.batch.push(m::Operation::InsertBefore(m::StackOp { id: id.0 as u32, m: m as u32 })); + } + + fn set_attribute( + &mut self, + name: &'static str, + ns: Option<&'static str>, + value: &AttributeValue, + id: ElementId, + ) { + // Intern first: the CacheStrings must precede the SetAttribute that + // names their ids. + let name_id = self.intern(name); + let ns_id = self.intern_opt(ns); + let value = match value { + AttributeValue::Text(s) => m::AttrValue::Text(s.clone()), + AttributeValue::Float(f) => m::AttrValue::Float(*f), + AttributeValue::Int(n) => m::AttrValue::Int(*n), + AttributeValue::Bool(b) => m::AttrValue::Boolean(*b), + AttributeValue::None => m::AttrValue::None, + // Listener: reaches the renderer through `create_event_listener` + // instead (dioxus never asks a renderer to serialize a callback). + // Any: a renderer-opaque payload for custom (non-HTML) + // renderers; there is nothing to put on the wire. Same as + // `MutationWriter` — no operation at all. + AttributeValue::Listener(_) | AttributeValue::Any(_) => return, + }; + self.batch.push(m::Operation::SetAttribute(m::SetAttribute { + id: id.0 as u32, + name: name_id, + ns: ns_id, + value, + })); + } + + fn set_node_text(&mut self, value: &str, id: ElementId) { + self.batch + .push(m::Operation::SetText(m::SetText { id: id.0 as u32, text: value.to_string() })); + } + + fn create_event_listener(&mut self, name: &'static str, id: ElementId) { + let name_id = self.intern(name); + self.batch.push(m::Operation::NewEventListener(m::EventListener { + id: id.0 as u32, + name: name_id, + bubbles: dioxus_core_types::event_bubbles(name), + })); + } + + fn remove_event_listener(&mut self, name: &'static str, id: ElementId) { + let name_id = self.intern(name); + self.batch.push(m::Operation::RemoveEventListener(m::EventListener { + id: id.0 as u32, + name: name_id, + bubbles: dioxus_core_types::event_bubbles(name), + })); + } + + fn remove_node(&mut self, id: ElementId) { + self.batch.push(m::Operation::Remove(id.0 as u32)); + } + + fn push_root(&mut self, id: ElementId) { + self.batch.push(m::Operation::PushRoot(id.0 as u32)); + } +} diff --git a/wit/world.wit b/wit/world.wit index 3712ada..e8133cd 100644 --- a/wit/world.wit +++ b/wit/world.wit @@ -296,6 +296,161 @@ interface events { } } +/// The explicit-schema alternative to the byte format documented on `run`: +/// the same op vocabulary, spelled as WIT records and one `operation` +/// variant, delivered as `stream` by `run-typed`. Exactly one of +/// the two channels is used per instance; the host picks at mount. This +/// interface exists so the two encodings can be benchmarked against each +/// other — the byte format on `run` remains the measurement baseline and is +/// unchanged. +/// +/// Semantics are inherited wholesale from `run`'s doc comment: element ids +/// are Dioxus ElementIds (slab indices, id 0 = mount root), `m` is the +/// "top m nodes of the stack" count, `path` is a list of child indices, +/// stack semantics are Dioxus's mutation-stack semantics, and `bubbles` on +/// the listener ops is dioxus-html's `event_bubbles` verdict (the host keys +/// its root-delegated vs per-element strategy off it). Interning is +/// unchanged too: a `str-ref` is an id defined by a prior `cache-string` +/// operation, in this batch or any earlier one, and the guest emits each +/// definition exactly once per instance. +/// +/// Three representational differences from the byte format, all forced: +/// +/// - **`option` replaces the `0xffff` sentinel** at the type level: +/// a namespace operand's optionality is expressed by the type rather than +/// by a reserved id value. Note this does NOT free the id: both channels +/// assign ids from one `Interner`, whose guard still refuses to hand out +/// `0xffff` (src/protocol.rs `intern_raw`), so the typed channel inherits +/// the byte format's 65535-string ceiling without getting anything for it. +/// - **`register-template` carries an arena, not a tree.** The natural +/// shape is recursive — `record template-element { children: +/// list }` — and WIT forbids it: recursive type +/// definitions are rejected outright (`wasm-tools`: "type `template-node` +/// depends on itself"). So `nodes` is a flat pre-order list, `roots` and +/// every element's `children` are `u32` indices into it, and the host +/// rebuilds the tree by indexing. This is a real expressiveness limit of +/// the typed schema and a cost the byte format does not pay: its `node` +/// grammar is self-delimiting and recursive at no charge. It also means +/// the typed form admits malformed arenas (out-of-range or cyclic +/// indices) that the byte grammar cannot express. +/// - **The world's `use mutations.{operation}` makes this interface an +/// import of every component built against the world**, whether or not it +/// mounts the typed channel: `wasm-tools component wit` on a guest shows +/// `import polymorph:dioxus/mutations@0.4.0` alongside `events` and `dom`. +/// Nothing needs supplying (the interface has no items — the import +/// exists only to name the types), but it is a change to the *byte* +/// channel's own component type: a host that only ever calls `run` still +/// sees a new entry in the instantiation surface. Adding a typed channel +/// is therefore not free for guests that never use it. +/// +/// (Version note: this interface is added to `polymorph:dioxus@0.4.0` +/// without a package bump, deliberately, and that is a real shortcut rather +/// than a formality. `app` gained a *required* export, so `0.4.0` now +/// denotes two mutually incompatible worlds: any guest already built +/// against the old 0.4.0 no longer satisfies it, and the mismatch is not +/// detectable by version. `fixtures/surface-probe` needing a stub +/// `run-typed` arm to keep compiling is that incompatibility showing up in +/// the small. The shortcut is taken because this is a spike branch +/// evaluating the typed channel and a bump rewrites every interface id in +/// the host wiring, swamping the change under measurement — but it must not +/// be carried forward: whichever way the spike lands, the result ships with +/// a version bump.) + +interface mutations { + /// An interned string id, defined by a prior `cache-string` operation. + type str-ref = u16; + /// A Dioxus ElementId (slab index; dense). 0 is the mount root. + type element-id = u32; + + /// Define (or overwrite) interned slot `id`. Ids are guest-managed and + /// assigned monotonically from 0. + record cache-string { id: str-ref, str: string } + + /// One *static* template attribute. Dynamic template attributes are not + /// part of the template; they arrive later as `set-attribute`. + record template-attr { name: str-ref, ns: option, value: string } + + /// `children` are indices into the enclosing `register-template`'s + /// `nodes` arena — see the interface doc for why this is not a tree. + record template-element { + tag: str-ref, + ns: option, + attrs: list, + children: list, + } + + variant template-node { + element(template-element), + text(string), + /// A runtime-supplied node slot; the host materializes a placeholder + /// that later ops (`assign-id` / `replace-placeholder`) address by path. + dynamic, + } + + /// Register a static template, which the host materializes once + /// (detached) so `load-template` can clone root subtrees from it. Each + /// distinct template is sent once, before its first use. + /// + /// `nodes` is the arena; `roots` indexes into it. + record register-template { + id: u16, + nodes: list, + roots: list, + } + + /// An element id plus a stack count ("top m nodes"). + record stack-op { id: element-id, m: u32 } + /// A template path plus a stack count. + record path-op { path: list, m: u32 } + record assign-id { path: list, id: element-id } + record create-text-node { id: element-id, text: string } + record load-template { id: element-id, tmpl: u16, root: u16 } + record set-text { id: element-id, text: string } + + /// The byte format's `attrval` kinds. `none` removes the attribute + /// unconditionally (it is not an empty text value); the host applies the + /// same property-reset rules `run`'s doc comment spells out. + variant attr-value { + text(string), + float(f64), + int(s64), + boolean(bool), + none, + } + + record set-attribute { + id: element-id, + name: str-ref, + ns: option, + value: attr-value, + } + + record event-listener { id: element-id, name: str-ref, bubbles: bool } + + /// One mutation. The arms are the byte format's opcodes in opcode order + /// (0x01 cache-string through 0x11 push-root); arms whose only operand is + /// an element id carry it bare. + variant operation { + cache-string(cache-string), + register-template(register-template), + append-children(stack-op), + assign-id(assign-id), + create-placeholder(element-id), + create-text-node(create-text-node), + load-template(load-template), + replace-with(stack-op), + replace-placeholder(path-op), + insert-after(stack-op), + insert-before(stack-op), + set-attribute(set-attribute), + set-text(set-text), + new-event-listener(event-listener), + remove-event-listener(event-listener), + remove(element-id), + push-root(element-id), + } +} + /// Operations on live elements the guest cannot express declaratively. /// /// Everything else in this world flows one way: the guest describes DOM @@ -379,6 +534,7 @@ interface dom { /// dispatches DOM events via `handle-event`. world app { use events.{payload, dom-event}; + use mutations.{operation}; import events; import dom; @@ -525,6 +681,31 @@ world app { /// see bench/README.md for the retirement record.) export run: async func() -> stream; + /// The typed twin of `run`: the same channel with the explicit schema of + /// the `mutations` interface instead of the hand-rolled byte format. + /// + /// Lifecycle is identical in every respect — it returns the read end and + /// spawns the scheduler as a separate task (see `run`'s doc for why the + /// scheduler cannot live in the export's own body, and why nothing may be + /// written before the reader is returned), initial mount is one batch, and + /// each subsequent render flushes one batch. One batch is one stream + /// write of the whole `list`: writes-per-batch is the thing + /// being measured, so it stays at one on both channels. + /// + /// **Exactly one of `run` / `run-typed` is called per instance** — the + /// host picks the channel at mount. Calling both is not supported: they + /// would share one VirtualDom and one interner, and each would see half + /// the mutations. + /// + /// Unlike `stream`, a typed stream has no zero-copy direct-read path + /// (polyengine amendment A21's `readDirect` is `stream` only, since it + /// hands the callback a view aliasing guest linear memory and only a byte + /// stream *has* a memory representation to alias). The host therefore + /// reads this one with an ordinary `read()`, paying lift/lower for every + /// record — which is precisely the cost the benchmark is here to + /// quantify. + export run-typed: async func() -> stream; + /// Dispatch one DOM event to the listener registered on `target` (the /// ElementId carried by new-event-listener) for the interned event name /// `name`. From 9f26b55ec4b6d1b9db312317b47f426b5dbe7c51 Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Thu, 3 Sep 2026 18:22:53 -0400 Subject: [PATCH 2/4] =?UTF-8?q?bench:=20correct=20the=20'irreducible=20~4x?= =?UTF-8?q?'=20claim=20=E2=80=94=20it=20was=20~17x=20of=20interpreter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The section claimed the residual gap after fixing polyengine's uncached lift was a ~4x floor no runtime work could remove, because a typed channel must allocate JS objects per element. That held polyengine's value-mapping contract fixed, which is the thing worth changing, and it was wrong in the direction that flattered the conclusion. Measured against the same guest memory at the same rendezvous, interleaved so no strategy sees different JIT state, and asserted element-by-element against the interpreter and against the shipped applyTyped: interpreted load() + toHost (today) ~4080 ns/element compiled lift (identical JS values) ~232 ns/element 17x compiled visitor (operands as args) ~127 ns/element 32x the byte decoder, same sink ~14 ns/element Closure specialization needs no eval, no emitted modules and no contract change. The visitor shape is where the supposedly irreducible allocation goes. Extrapolated, create-10k lands at ~1.9x rather than 5.8x, and the residual becomes guest-side lowering rather than lift. Reported upstream as polymorph-components/polyengine#261. --- bench/README.md | 99 ++++++++++++++++++++++++++++++++++++------------- 1 file changed, 73 insertions(+), 26 deletions(-) diff --git a/bench/README.md b/bench/README.md index e09e658..460680a 100644 --- a/bench/README.md +++ b/bench/README.md @@ -301,37 +301,84 @@ run: Given the noise floor above, trust the direction and the order of magnitude of that column, not its third digit. -A separate pure-JS experiment bounds the floor: materialising the -`{kind, value}` object graph the typed schema forces into existence, -with no runtime and no ABI lift at all, and walking it with the same -applier, costs ~190 ns/op against ~45 ns/op for decoding the byte frame. -That ~4x on the host decode step is the part no runtime work can remove -— a typed channel must allocate two or three JS objects per operation, -where the byte decoder passes operands straight to the sink as -arguments and allocates nothing. - -**Disclosure.** Three of the measurements above — the runtime-stage -attribution, the layout-memoization sensitivity run, and the pure-JS -floor — came from one-off local experiments that are not in this tree: -a scratch component exercising both encodings against a counting sink, -and a temporary patch to the gitignored `.deps/polyengine` checkout -(reverted; that checkout is pristine). They are described precisely -enough to redo — the memoization is a `WeakMap` cache keyed by type -identity on `alignment`, `elemSize`, `maxCaseAlignment` and -`despecialize` — but they are not re-derivable by running anything -committed here. The two-column table below, and the instrumented op -counts, are. +### How far the runtime could actually take it + +An earlier revision of this section claimed the remaining gap was a +~4x floor that "no runtime work can remove", on the grounds that a +typed channel must allocate two or three JS objects per operation. +**That was wrong, and it was wrong in the direction that flattered the +conclusion.** It held polyengine's value-mapping contract fixed, which +is exactly the thing worth changing. + +Measured properly: several lift strategies run against the same guest +memory at the same rendezvous, interleaved in one timed loop (so no +strategy is measured under different JIT state), each asserted +element-by-element to produce the same values as the interpreter and +the same sink calls as `host/src/typed.ts`. + +| strategy | ns/element | vs byte protocol | +| --- | --- | --- | +| interpreted `load()` + `toHost` — what runs today | ~4080 | ~300x | +| compiled lift — closure tree per type, identical JS values | ~232 | ~17x | +| compiled visitor — operands passed as arguments | ~127 | ~9x | +| the byte decoder here, same sink | ~14 | 1.0x | + +The "compiled lift" is just the interpreter specialised: walk the type +descriptor once, build a tree of closures, and every field offset, +`camelCase` name and variant case index becomes a constant. No `eval`, +no emitted modules, no CSP question, no contract change — and 17x. The +"compiled visitor" adds a contract change, handing operands to a +per-case callback instead of materialising a `{kind, value}` wrapper +and a payload record per element; that is where the allegedly +irreducible allocation cost goes. + +Extrapolating to this bench: the ~4.9 µs/op the typed channel costs +today is ~4.08 µs of lift plus ~0.8 µs of everything else. A compiled +visitor would leave ~0.9 µs/op, taking `create-10k` from **5.8x to +roughly 1.9x**. And the residual would no longer be lift — it is +guest-side lowering (`src/typed.rs` allocates a `String` per dynamic +operand and a `Vec` per list) plus the rendezvous copy plus the absence +of `readDirect` for typed streams. Nobody has measured the lowering +side; the next person should, before assuming the lift was the whole +story. + +What genuinely does not go away is the layout. The lowered +`list` is 24 bytes/element fixed-stride with strings, paths +and node lists all out of line, against a packed variable-length frame +decoded with one `TextDecoder` pass over one contiguous string segment. +The typed path chases pointers and calls `TextDecoder` per string. That +is the canonical ABI's memory layout, not the runtime's implementation +of it. + +Reported upstream as +[polyengine#261](https://github.com/polymorph-components/polyengine/issues/261). + +**Disclosure.** Four of the measurements above — the runtime-stage +attribution, the layout-memoization sensitivity run, the compiled +lift/visitor table, and the pure-JS floor — came from one-off local +experiments that are not in this tree: a scratch component exercising +both encodings against a counting sink, and temporary patches to the +gitignored `.deps/polyengine` checkout (reverted; that checkout is +pristine). The upstream issue carries the detail. They are not +re-derivable by running anything committed here; the two-column table +below, and the instrumented op counts, are. ### Read The delta is not small today: ~5x on op-heavy operations (12x on `clear`, which is nearly pure per-op cost with no DOM work to dilute -it). It is the polyengine runtime's uncached lift, not the component -model or the schema, that makes it that large. Most of it is -recoverable — the layout memoization alone takes create-10k from 5.07x -to 1.74x, and a per-type compiled lift would go further. But the floor -is not 1.0x, and `create-10k` at 90 000 operations per render is -exactly where a per-op cost is least forgivable. +it). But essentially all of it is the polyengine runtime's interpreted +lift, not the component model and not the schema — a compiled lift +plus a visitor-shaped read would put `create-10k` at ~1.9x, and the +bottleneck would then be guest-side lowering rather than anything the +host does. + +So the decision is really about sequencing, not about the schema. On +today's runtime the typed channel costs too much to adopt for a +consumer that emits 90 000 operations per render. That is a statement +about a fixable implementation, and the fix is upstream work that +would benefit every polyengine consumer rather than anything this repo +can do. The maintainability argument is also weaker than it looks: the arena workaround for WIT's recursion ban moves the template grammar's From 813b31c910d3460b5aafc21d41fd8d1669946114 Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Thu, 3 Sep 2026 22:49:19 -0400 Subject: [PATCH 3/4] Advance polyengine 9e17dc9 -> 22b5d3d and re-run the Channel A/B MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pinned runtime now carries the four optimization PRs that came out of polyengine#261 (#263 layout-node cache, #264 embedder adapter tables, #265 flatten-count memoization, #270 variant kind/value). The typed mutation channel's cost was almost entirely that lift path, so the A/B was only meaningful once they landed. Per-operation cost of the typed channel, medians of three full runs: op ops before after ratio then -> now create-10k 90 006 4.9 us 0.78 us 5.80x -> ~1.8x create-1k 9 006 5.5 us 0.70 us 4.99x -> ~1.6x append-1k 9 002 5.2 us 0.77 us 1.59x -> ~1.1x clear 10 002 4.1 us 0.15 us 12.02x -> ~1.4x 6-27x less per operation, from four upstream commits and no change to this repo's encoders on either side. The before block is kept as a dated table rather than overwritten; the before/after is what makes either column mean anything. Also retracts a claim from the previous revision: it said the residual after fixing the lift would be guest-side lowering rather than lift. That was a subtraction between numbers measured under different conditions. With the lift fixed, the end-to-end delta (~0.78 us/op) and the measured lift cost (~0.7-0.96 us/element) are the same size, so lift still accounts for essentially all of it — and a compiled lift plus a visitor-shaped read are still worth ~3x and ~1.8x on top upstream. The verdict changes with the numbers: at 1.1-1.8x the typed channel is a real option rather than a non-starter, and the case for or against it is now mostly about the schema. That case still has the WIT recursion ban in it, which no runtime work fixes. deno.lock moves 0.5.2 -> 0.6.3 as a consequence of the bump. --- bench/README.md | 314 ++++++++---------- ...ts-2026-09-04-aarch64-unknown-linux-gnu.md | 15 + deno.lock | 10 +- justfile | 7 +- 4 files changed, 165 insertions(+), 181 deletions(-) create mode 100644 bench/results-2026-09-04-aarch64-unknown-linux-gnu.md diff --git a/bench/README.md b/bench/README.md index 460680a..7c8ea3f 100644 --- a/bench/README.md +++ b/bench/README.md @@ -195,197 +195,161 @@ typed channel it does not use. ### Measured -Read the *ratio* column of the "Latest local numbers" table below, not -the absolute values. Op counts here are **instrumented**, not estimated: -a counting `OpSink` wrapped around each mounted applier during a one-off -run recorded the `OpSink` calls in each timed window. Both channels -produced identical counts for all seven operations, which is an -independent check on the equivalence test. - -| op | ops in the timed window | typed − bytes (median of 3 runs) | per op | +Read the *ratio* column, not the absolute values. Op counts are +**instrumented**, not estimated: a counting `OpSink` wrapped around each +mounted applier during a one-off run recorded the `OpSink` calls in each +timed window. Both channels produced identical counts for all seven +operations, which is an independent check on the equivalence test. + +These numbers are against **polyengine 22b5d3d**, which carries the four +optimization PRs that came out of the finding below +([#263](https://github.com/polymorph-components/polyengine/pull/263) +layout-node cache, +[#264](https://github.com/polymorph-components/polyengine/pull/264) +embedder adapter tables, +[#265](https://github.com/polymorph-components/polyengine/pull/265) +flatten-count memoization, +[#270](https://github.com/polymorph-components/polyengine/pull/270) +variant kind/value). The "before" block further down is against +9e17dc9, which does not. The bump is in the justfile's +`POLYENGINE_REV`, and it is the single biggest input to this table — +do not compare rows across it. + +Deltas below are medians of three full runs. + +| op | ops in the timed window | typed − bytes | per op | | --- | --- | --- | --- | -| create-10k | 90 006 | +437 ms | 4.9 µs | -| create-1k | 9 006 | +49 ms | 5.5 µs | -| append-1k | 9 002 | +47 ms | 5.2 µs | -| clear | 10 002 | +41 ms | 4.1 µs | -| update-every-10th | 101 | ~+1 ms | at the noise floor | +| create-10k | 90 006 | +70 ms | 0.78 µs | +| append-1k | 9 002 | +7.0 ms | 0.77 µs | +| create-1k | 9 006 | +6.3 ms | 0.70 µs | +| clear | 10 002 | +1.5 ms | 0.15 µs | +| update-every-10th | 101 | — | at the noise floor | | swap-rows | 4 | — | at the noise floor | | remove-row | 2 | — | at the noise floor | -**The typed channel costs 4–5.5 µs per operation, and that single figure -reproduces the whole table.** The strongest evidence for the linearity -does not depend on the op counts at all: `create-1k` and `create-10k` -differ by 10x in rows and 8.9x in added milliseconds, and `append-1k` -— a different operation, same order of magnitude of ops — lands on -essentially the same delta as `create-1k`. +**The typed channel costs ~0.7-0.8 µs per operation** on the ops whose +mix is dominated by multi-field records with strings, and ~0.15 µs/op on +`clear`, which is almost entirely `remove` — a variant arm carrying a +bare `u32`. As before, one per-op figure reproduces the whole table, and +the ratio column is really measuring how op-heavy each operation is. -The per-op cost is *not* flat to two digits, and the 1.3x spread is -predicted by the section's own mechanism rather than being noise: -`clear` is almost entirely `remove`, whose variant arm carries a bare -`u32`, while `create-*` is a mix dominated by multi-field records with -strings. A bigger type tree costs more to walk. +The three small ops (2 to 101 operations in the window) are reported for +completeness only. They swing by more than the effect being measured — +`create-1k` read 0.74x in one of the three runs, i.e. the typed channel +apparently *faster*, which is the bytes column bouncing between 9.5 and +24.3 ms across runs, not a real result. Nothing below the op-heavy four +rows should be read at all. -The ratio column therefore measures how op-heavy each operation is, -nothing more. The three small ops sitting at ~1.0x is **not** evidence -the channels are comparable — with 2 to 101 operations in the window, -a per-op cost of any size is invisible there. +### Before the runtime fix (polyengine 9e17dc9) + +| op | ops | typed − bytes | per op | ratio then | ratio now | +| --- | --- | --- | --- | --- | --- | +| create-10k | 90 006 | +437 ms | 4.9 µs | 5.80x | ~1.8x | +| create-1k | 9 006 | +49 ms | 5.5 µs | 4.99x | ~1.6x | +| append-1k | 9 002 | +47 ms | 5.2 µs | 1.59x | ~1.1x | +| clear | 10 002 | +41 ms | 4.1 µs | 12.02x | ~1.4x | + +**Four upstream commits took the typed channel's per-operation cost down +by 6-27x.** `clear` moved most because it was the purest measure of the +per-op cost — nearly no DOM work to dilute it, and the cheapest possible +variant arm paying the full type-tree walk anyway. + +Kept as a dated block rather than overwritten: the before/after is what +makes either column mean anything. ### On the noise floor, and this file's own guardrail The "Interpretation guardrails" section below says a >2x run-to-run -delta on any operation is a bug lead, not a result. Comparing the -retained pre-spike run (`results-2026-08-31-*.md`) with the runs here, -the **bytes** column moved by 2.15x on `create-1k` and 2.47x on -`remove-row`. Discharging that rather than ignoring it: +delta on any operation is a bug lead, not a result. This table trips it +repeatedly on the small ops and once on `create-1k`'s bytes column +(9.51 / 12.90 / 24.32 ms across three runs, no code change). Discharging +rather than ignoring it: - The byte channel's code is unchanged by this spike. `Interner` was refactored (`intern_raw` extracted) with `intern` kept as a wrapper, and `mountApp` was restructured around a `channel` option with the byte branch moved verbatim under an `else`. The golden byte vectors (`cargo test --test vectors`) still match. -- The box was shared and busy across these runs; the byte column swung - by up to 1.7x *between two runs minutes apart with no code change at - all* (`create-1k`: 15.3 vs 8.8 ms). - -So: box noise, not a regression — but it also means **only the -op-heavy rows carry signal**, and no ratio here should be read past its -first digit. The three small ops are reported for completeness, not as -measurements of anything. +- The box is shared and busy; the swing is present with and without any + change under test. -### Where the ~5 µs goes — and how much of it is inherent +So: box noise. It also means **only the op-heavy four rows carry +signal**, and no ratio here should be read past its first digit. -Attributing the typed path with the polyengine runtime instrumented -(one-off local experiment; see the disclosure below): - -| stage | share | -| --- | --- | -| canonical-ABI per-element `load()` (`.deps/polyengine/runtime/src/cabi/load.ts`) | ~64% | -| embedder `toHost` value adaptation (`runtime/src/embedder/values.ts`) | ~20% | -| the rendezvous, guest-side lowering, promise plumbing | ~10% | -| `applyTyped`'s own dispatch | ~1–4% | - -Guest-side encoding is inside that third row and was not separated out. -It is not free: `src/typed.rs` allocates a `String` per dynamic operand -and a `Vec` per attribute/children/path list, where `src/protocol.rs` -appends into two reused buffers. A standalone measurement of *building* -the batch (no transport) actually favoured the typed writer slightly -(~36 vs ~56 ns/op) — pushing structs is cheaper than byte-serialising -them — so the allocation cost shows up in the lowering and the host -lift, not in construction. - -~85% is two generic interpreted walks of the type tree per element, -neither of which is inherent to the schema: - -- `alignment`/`elemSize`/`maxCaseAlignment`/`despecialize` are - recomputed from scratch for every element and every field. For a - 17-case variant of records that is a full type-tree walk per - operation, and `despecialize` *allocates* on every call for - `option`/`tuple`/`enum`/`result`. -- `toHost` calls `camelCase(label)` — a `split`/`map`/`join` — per field - per element, and finds the variant case by linear scan over 17 cases. - (Its `checkNoCollisions` is memoised per type and is *not* a per- - element cost.) - -Memoizing just the layout functions by type identity — a ~70-line -change to four functions in `runtime/src/cabi/{layout,types}.ts`, no -schema change and no compiled bindings — was measured as a sensitivity -run: - -| op | typed (stock) | typed (memoized) | ratio, stock → memoized | -| --- | --- | --- | --- | -| create-10k | 588 ms | 267 ms | 5.07x → 1.74x | -| create-1k | 62.3 ms | 23.8 ms | 4.08x → 2.71x | -| append-1k | 132 ms | 102 ms | 1.55x → 1.12x | -| clear | 49.3 ms | 7.6 ms | 12.18x → 2.08x | - -Given the noise floor above, trust the direction and the order of -magnitude of that column, not its third digit. - -### How far the runtime could actually take it - -An earlier revision of this section claimed the remaining gap was a -~4x floor that "no runtime work can remove", on the grounds that a -typed channel must allocate two or three JS objects per operation. -**That was wrong, and it was wrong in the direction that flattered the -conclusion.** It held polyengine's value-mapping contract fixed, which -is exactly the thing worth changing. - -Measured properly: several lift strategies run against the same guest -memory at the same rendezvous, interleaved in one timed loop (so no -strategy is measured under different JIT state), each asserted -element-by-element to produce the same values as the interpreter and -the same sink calls as `host/src/typed.ts`. - -| strategy | ns/element | vs byte protocol | -| --- | --- | --- | -| interpreted `load()` + `toHost` — what runs today | ~4080 | ~300x | -| compiled lift — closure tree per type, identical JS values | ~232 | ~17x | -| compiled visitor — operands passed as arguments | ~127 | ~9x | -| the byte decoder here, same sink | ~14 | 1.0x | - -The "compiled lift" is just the interpreter specialised: walk the type -descriptor once, build a tree of closures, and every field offset, -`camelCase` name and variant case index becomes a constant. No `eval`, -no emitted modules, no CSP question, no contract change — and 17x. The -"compiled visitor" adds a contract change, handing operands to a -per-case callback instead of materialising a `{kind, value}` wrapper -and a payload record per element; that is where the allegedly -irreducible allocation cost goes. - -Extrapolating to this bench: the ~4.9 µs/op the typed channel costs -today is ~4.08 µs of lift plus ~0.8 µs of everything else. A compiled -visitor would leave ~0.9 µs/op, taking `create-10k` from **5.8x to -roughly 1.9x**. And the residual would no longer be lift — it is -guest-side lowering (`src/typed.rs` allocates a `String` per dynamic -operand and a `Vec` per list) plus the rendezvous copy plus the absence -of `readDirect` for typed streams. Nobody has measured the lowering -side; the next person should, before assuming the lift was the whole -story. - -What genuinely does not go away is the layout. The lowered -`list` is 24 bytes/element fixed-stride with strings, paths -and node lists all out of line, against a packed variable-length frame -decoded with one `TextDecoder` pass over one contiguous string segment. -The typed path chases pointers and calls `TextDecoder` per string. That -is the canonical ABI's memory layout, not the runtime's implementation -of it. +### Where the remaining cost is -Reported upstream as +The finding that produced the upstream fix, and what is left after it. +Reported as [polyengine#261](https://github.com/polymorph-components/polyengine/issues/261). -**Disclosure.** Four of the measurements above — the runtime-stage -attribution, the layout-memoization sensitivity run, the compiled -lift/visitor table, and the pure-JS floor — came from one-off local -experiments that are not in this tree: a scratch component exercising -both encodings against a counting sink, and temporary patches to the -gitignored `.deps/polyengine` checkout (reverted; that checkout is -pristine). The upstream issue carries the detail. They are not -re-derivable by running anything committed here; the two-column table -below, and the instrumented op counts, are. +Several lift strategies run against the same guest memory at the same +rendezvous, interleaved in one timed loop so none is measured under a +different JIT state, each asserted element-by-element to produce the +same values as the interpreter and the same sink calls as +`host/src/typed.ts`: + +| strategy | ns/element, 9e17dc9 | ns/element, 22b5d3d | +| --- | --- | --- | +| the runtime's own `load()` + `toHost` | ~4080 | ~675-963 | +| compiled lift — closure tree per type, identical JS values | ~232 | ~231-267 | +| compiled visitor — operands passed as arguments | ~127 | ~132-181 | +| the byte decoder here, same sink | ~14 | ~22-31 | + +Upstream's caching closed most of the gap. What remains is that caching +a walk is not the same as not walking: a **compiled lift** — walk the +type descriptor once, build a tree of closures, and every field offset, +`camelCase` name and variant case index becomes a constant — is still +~3x faster than the cached interpreter, needs no `eval`, no emitted +modules and no contract change. A **visitor-shaped read**, handing +operands to a per-case callback instead of materialising a +`{kind, value}` wrapper plus a payload record per element, is worth a +further ~1.8x and would need a contract addition. + +Retracting a claim from an earlier revision of this section: it said the +~0.8 µs/op that would remain after fixing the lift was guest-side +lowering and the rendezvous, "not lift". That was a subtraction between +two numbers measured under different conditions. Now that the lift is +fixed, the end-to-end delta (~0.78 µs/op) and the measured lift cost +(~0.7-0.96 µs/element) are the same size — so lift still accounts for +essentially all of it, and the further ~3x and ~1.8x above are still on +the table. Nobody has isolated the lowering side; that measurement has +not been done. + +What does not go away is the layout: the lowered `list` is +24 bytes/element fixed-stride with strings, paths and node lists all out +of line, against a packed variable-length frame decoded with one +`TextDecoder` pass over one contiguous string segment. + +**Disclosure.** The strategy table, the runtime-stage attribution and +the pure-JS floor came from one-off local experiments not in this tree: +a scratch component exercising both encodings against a counting sink, +and a temporary hook in the gitignored `.deps/polyengine` checkout +(reverted; that checkout is pristine). The upstream issue carries the +detail. They are not re-derivable by running anything committed here; +the two-column table and the instrumented op counts are. ### Read -The delta is not small today: ~5x on op-heavy operations (12x on -`clear`, which is nearly pure per-op cost with no DOM work to dilute -it). But essentially all of it is the polyengine runtime's interpreted -lift, not the component model and not the schema — a compiled lift -plus a visitor-shaped read would put `create-10k` at ~1.9x, and the -bottleneck would then be guest-side lowering rather than anything the -host does. - -So the decision is really about sequencing, not about the schema. On -today's runtime the typed channel costs too much to adopt for a -consumer that emits 90 000 operations per render. That is a statement -about a fixable implementation, and the fix is upstream work that -would benefit every polyengine consumer rather than anything this repo -can do. - -The maintainability argument is also weaker than it looks: the arena -workaround for WIT's recursion ban moves the template grammar's -correctness burden from a hand-written decoder into hand-written index -validation, so the schema does not actually retire the +At ~1.1x on `append-1k` and ~1.8x on `create-10k`, the typed channel is +no longer obviously unaffordable — which is a different answer than this +section gave a day ago, and the thing that changed was the runtime, not +this repo. The cost was never the component model or the schema; it was +one interpreter's uncached lift, and four upstream commits removed most +of it. + +If the typed channel is wanted, the case for it is now mostly about the +schema, not the speed. That case is weaker than it looks in one specific +place: WIT forbids recursive types, so `register-template`'s tree +becomes an index arena, which admits malformed index graphs the byte +grammar cannot express and needs validation in `applyTyped` that the +byte decoder never needed. The schema does not retire the "two implementations must agree" problem for the one op where that -problem is hardest. +problem is hardest — it relocates it. + +Everywhere else, it does retire it, and 1.1-1.8x on a channel whose cost +is already a small fraction of the DOM work is a real option rather than +a non-starter. There is also another ~3x of headroom upstream if +anyone wants it (above), which would take `create-10k` under ~1.3x. ## Transport A/B (historical) @@ -525,21 +489,21 @@ see the numbers-are-box-relative guardrail below.) -# bench-rows results — 2026-09-03 +# bench-rows results — 2026-09-04 - Deno: 2.9.5 (aarch64-unknown-linux-gnu) -- git rev: 9dc9810-dirty +- git rev: 9f26b55-dirty - Box note: numbers are box-relative — compare columns within this run, not across machines. See bench/README.md. | op | bytes (ms, median of 5) | typed (ms, median of 5) | typed / bytes | | --- | --- | --- | --- | -| create-1k | 14.53 | 72.53 | 4.99x | -| create-10k | 91.03 | 528.18 | 5.80x | -| append-1k | 83.45 | 133.05 | 1.59x | -| update-every-10th | 4.73 | 5.81 | 1.23x | -| swap-rows | 2.92 | 5.34 | 1.83x | -| remove-row | 4.83 | 5.01 | 1.04x | -| clear | 3.69 | 44.31 | 12.02x | +| create-1k | 9.51 | 15.97 | 1.68x | +| create-10k | 76.21 | 151.89 | 1.99x | +| append-1k | 85.77 | 90.89 | 1.06x | +| update-every-10th | 3.77 | 4.84 | 1.28x | +| swap-rows | 4.54 | 4.99 | 1.10x | +| remove-row | 3.05 | 5.56 | 1.82x | +| clear | 4.23 | 5.01 | 1.18x | diff --git a/bench/results-2026-09-04-aarch64-unknown-linux-gnu.md b/bench/results-2026-09-04-aarch64-unknown-linux-gnu.md new file mode 100644 index 0000000..c31dd44 --- /dev/null +++ b/bench/results-2026-09-04-aarch64-unknown-linux-gnu.md @@ -0,0 +1,15 @@ +# bench-rows results — 2026-09-04 + +- Deno: 2.9.5 (aarch64-unknown-linux-gnu) +- git rev: 9f26b55-dirty +- Box note: numbers are box-relative — compare columns within this run, not across machines. See bench/README.md. + +| op | bytes (ms, median of 5) | typed (ms, median of 5) | typed / bytes | +| --- | --- | --- | --- | +| create-1k | 9.51 | 15.97 | 1.68x | +| create-10k | 76.21 | 151.89 | 1.99x | +| append-1k | 85.77 | 90.89 | 1.06x | +| update-every-10th | 3.77 | 4.84 | 1.28x | +| swap-rows | 4.54 | 4.99 | 1.10x | +| remove-row | 3.05 | 5.56 | 1.82x | +| clear | 4.23 | 5.01 | 1.18x | diff --git a/deno.lock b/deno.lock index ec3ffd1..975e502 100644 --- a/deno.lock +++ b/deno.lock @@ -132,11 +132,11 @@ "npm:linkedom@0.18.12" ], "links": { - "jsr:@polyengine/ct-runner@0.5.2": {}, - "jsr:@polyengine/protocol@0.2.3": {}, - "jsr:@polyengine/runtime@0.5.2": {}, - "jsr:@polyengine/translator@0.5.2": {}, - "jsr:@polyengine/wasi@0.5.2": {} + "jsr:@polyengine/ct-runner@0.6.3": {}, + "jsr:@polyengine/protocol@0.3.1": {}, + "jsr:@polyengine/runtime@0.6.3": {}, + "jsr:@polyengine/translator@0.6.3": {}, + "jsr:@polyengine/wasi@0.6.3": {} } } } diff --git a/justfile b/justfile index aa5aa86..273d011 100644 --- a/justfile +++ b/justfile @@ -6,7 +6,12 @@ # A21, so we build against a pinned upstream rev instead. POLYENGINE_REPO := "https://github.com/polymorph-components/polyengine.git" -POLYENGINE_REV := "9e17dc97dd3e" +# Advanced 9e17dc97dd3e -> 22b5d3d for polyengine#261's optimization PRs +# (#263 layout-node cache, #264 embedder adapter tables, #265 flatten-count +# memoization, #270 variant kind/value): the typed mutation channel's cost is +# almost entirely that lift path, so the Channel A/B in bench/README.md is +# only meaningful against a runtime that has them. +POLYENGINE_REV := "22b5d3d" TAILWIND_VERSION := "v4.3.3" default: check test From 63dfb41295ec4aaa3ecaf5a0553863d8d9b00912 Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Thu, 3 Sep 2026 23:57:15 -0400 Subject: [PATCH 4/4] Adopt the WIT mutation schema; retire the byte protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spike measured the two channels, upstream fixed the lift that made the typed one expensive (polyengine#261, pinned at 22b5d3d), and at 1.1-1.8x the maintainability win is worth paying for. So the explicit WIT schema is now the mutation protocol and the hand-rolled byte format is gone. polymorph:dioxus@0.5.0. One export: export run: async func() -> stream What that deletes, which is the point of the change: - the normative wire-format prose in wit/world.wit — the opcode table, the framing grammar, the primitive operand encodings. `interface mutations` is the schema now, and bindgen owns both sides of it. - src/protocol.rs's Batch: the opcode constants, utf16_len, the dynstr escape, the path/strref encoders, take_frame. - host/src/decoder.ts: the cursor, decodeBatch's 18-arm switch, FrameDecoder's partial-frame staging. - tests/vectors.rs and vectors/*: golden byte vectors existed to keep two hand-written implementations of one undocumented-by-construction format in agreement. There is one implementation of each side now, generated. - the surface-probe fixture's inlined 150-line encoder. Net -3189 lines. Two things the schema buys beyond deletion: `option` replaces the 0xffff sentinel, which frees the reserved id (the interner's guard is now the plain u16 ceiling), and every operand's type is checked at the boundary instead of by a decoder's cursor arithmetic. One thing it costs, recorded in wit/world.wit and bench/README.md rather than glossed: WIT forbids recursive types, so register-template carries an index arena instead of the natural tree, and the arena admits malformed index graphs a recursive grammar could not express. host/src/operations.ts validates them. Also corrects a citation that was wrong in both halves of the tree: the guest scheduler's persistent park was justified by the host's parked readDirect session, which the typed channel does not have. The park is legal by a different clause of the same rule — polyengine#162's retention rule is disjunctive, and a retained end alone suffices, which mountApp has for the instance's lifetime. ("A15" is also no longer a real label; upstream pruned amendment labels, so this now cites the contract section and HostActivity.) host/tests/operations_test.ts loses its differential reference when the byte channel goes, so it is now an absolute assertion: literal innerHTML at each step of the counter interaction sequence, captured while the differential test was still green and both channels were proven identical. Gates: cargo check/clippy/test, deno task check, deno task test (90), all five examples build and validate, and the Playwright lane (8 tests, real Chromium) which had not been run in this branch before. --- Cargo.lock | 1 - Cargo.toml | 1 - bench/README.md | 118 ++-- bench/bench.ts | 65 +-- bench/bench_worker.ts | 19 +- bench/ops.ts | 55 +- ...ts-2026-09-04-aarch64-unknown-linux-gnu.md | 20 +- e2e/tests/counter.spec.ts | 11 +- fixtures/surface-probe/src/lib.rs | 368 ++++-------- harness/entry.ts | 5 +- host/src/applier.ts | 57 +- host/src/decoder.ts | 444 -------------- host/src/dispatch.ts | 41 +- host/src/events.ts | 7 +- host/src/host.ts | 207 +++---- host/src/mod.ts | 8 +- host/src/{typed.ts => operations.ts} | 32 +- host/tests/applier_test.ts | 2 +- host/tests/counter_test.ts | 13 +- host/tests/decoder_test.ts | 352 ----------- host/tests/fullstack_test.ts | 7 - .../{typed_test.ts => operations_test.ts} | 194 ++++--- justfile | 6 - src/driver.rs | 333 +++-------- src/interner.rs | 93 +++ src/lib.rs | 24 +- src/protocol.rs | 546 ------------------ src/typed.rs | 322 ----------- src/writer.rs | 418 +++++--------- tests/vectors.rs | 472 --------------- vectors/basic.bin | Bin 273 -> 0 bytes vectors/basic.expected.json | 169 ------ vectors/frames.bin | Bin 281 -> 0 bytes vectors/frames.expected.json | 171 ------ vectors/template.bin | Bin 171 -> 0 bytes vectors/template.expected.json | 100 ---- vectors/unicode.bin | Bin 88098 -> 0 bytes vectors/unicode.expected.json | 36 -- wit/world.wit | 316 +++------- 39 files changed, 922 insertions(+), 4111 deletions(-) delete mode 100644 host/src/decoder.ts rename host/src/{typed.ts => operations.ts} (86%) delete mode 100644 host/tests/decoder_test.ts rename host/tests/{typed_test.ts => operations_test.ts} (61%) create mode 100644 src/interner.rs delete mode 100644 src/protocol.rs delete mode 100644 src/typed.rs delete mode 100644 tests/vectors.rs delete mode 100644 vectors/basic.bin delete mode 100644 vectors/basic.expected.json delete mode 100644 vectors/frames.bin delete mode 100644 vectors/frames.expected.json delete mode 100644 vectors/template.bin delete mode 100644 vectors/template.expected.json delete mode 100644 vectors/unicode.bin delete mode 100644 vectors/unicode.expected.json diff --git a/Cargo.lock b/Cargo.lock index 0ae02a0..41b8c47 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1538,7 +1538,6 @@ dependencies = [ "dioxus-core-types", "dioxus-html", "rustc-hash 2.1.3", - "serde_json", "wit-bindgen 0.60.0", ] diff --git a/Cargo.toml b/Cargo.toml index 39bd61b..34e0a17 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,6 @@ dioxus-html = { version = "=0.7.10", default-features = false } rustc-hash = "2" [dev-dependencies] -serde_json = "1" # Native-only: `rsx!` for driving a real VirtualDom through the writer in unit # tests. Dev-dependencies do not enter the wasm32 component build. dioxus = { version = "=0.7.10", default-features = false, features = [ diff --git a/bench/README.md b/bench/README.md index 7c8ea3f..e56eab3 100644 --- a/bench/README.md +++ b/bench/README.md @@ -2,12 +2,10 @@ Tracks absolute row-operation throughput on the pinned polyengine (`justfile`'s `POLYENGINE_REV`), Deno + linkedom (host-side DOM), a real -Dioxus component (`examples/bench-rows`), over the stream transport (the +Dioxus component (`examples/bench-rows`), over the mutation stream (the only transport — see "Transport A/B (historical)" below for why the call -transport was retired). Since the typed-channel spike it runs **two -columns**: the stream transport's two mutation *channels*, the byte -protocol on `run` and the explicit WIT schema on `run-typed` (see -"Channel A/B" below). The operations are +transport was retired, and "Channel A/B (historical)" below for why an +earlier hand-rolled byte protocol was retired too). The operations are js-framework-benchmark-style row operations (create/append/update/swap/ remove/clear), so there's a baseline to track regressions against and to eventually compare with dioxus-web's published characteristics. @@ -136,26 +134,37 @@ run for the same reason. `OpSink` methods in a throwaway script — enough for a one-off number, still not a standing column.) -## Channel A/B: byte protocol vs explicit WIT schema - -The mutation channel's wire format is a hand-rolled byte encoding -documented normatively in `wit/world.wit`'s `run` doc comment: opcodes, -operand widths and framing all live in prose, and the encoder -(`src/protocol.rs`) and decoder (`host/src/decoder.ts`) are two -independent hand-written implementations of it that can only be kept in -agreement by golden vectors and review. The obvious maintainability -alternative is to spell the vocabulary as WIT — records plus one -`operation` variant — and ship `stream` instead of -`stream`, letting bindgen own both sides. - -`wit/world.wit`'s `interface mutations` and the `run-typed` export are -that alternative, built so the two can be measured against each other. -Both channels are compiled into the same component (two exports); -`mountApp`'s `channel` option picks one, and both feed the identical -`DomApplier`, so the DOM work is common and the delta is purely -encode/transport/decode. `host/tests/typed_test.ts` asserts the two -produce identical DOM for the same interaction sequence — without that, -the numbers below would mean nothing. +## Channel A/B (historical): byte protocol vs explicit WIT schema + +**Retired.** `polymorph:dioxus` used to ship two mutation channels on the +same stream transport: a hand-rolled byte encoding on `run` and an explicit +WIT schema (`interface mutations` / `stream`) on `run-typed`. +The A/B below is what closed that question: the owner decided to keep the +typed channel and delete the byte one (recorded in `wit/world.wit` — `run` +now returns `stream` directly, and there is only one export). +This section preserves the measurement and reasoning that closed the +question, for the record; the "byte" column below no longer exists in this +tree. + +The byte format was documented normatively in `wit/world.wit`'s `run` doc +comment: opcodes, operand widths and framing all live in prose, and the +encoder (formerly `src/protocol.rs`) and decoder (formerly +`host/src/decoder.ts`) were two independent hand-written implementations of +it that could only be kept in agreement by golden vectors and review. The +obvious maintainability alternative was to spell the vocabulary as WIT — +records plus one `operation` variant — and ship `stream` +instead of `stream`, letting bindgen own both sides. + +`wit/world.wit`'s `interface mutations` and the (then-separate) `run-typed` +export were that alternative, built so the two could be measured against +each other. Both channels were compiled into the same component (two +exports); `mountApp`'s `channel` option picked one, and both fed the +identical `DomApplier`, so the DOM work was common and the delta was purely +encode/transport/decode. `host/tests/typed_test.ts` (since replaced by an +absolute-assertion test, its differential reference implementation having +been retired) asserted the two produced identical DOM for the same +interaction sequence — without that, the numbers below would have meant +nothing. ### What the schema costs before you measure anything @@ -172,26 +181,29 @@ typed schema therefore carries the template as an *arena* — a flat `nodes` list plus `u32` indices in `roots` and `children` — which is a strictly weaker encoding than the byte format's self-delimiting recursive grammar: it admits out-of-range and cyclic index graphs that -the byte grammar cannot express, so `applyTyped` needs explicit -validation the byte decoder never needed (`host/src/typed.ts`'s -`rehydrateTemplateArena`). That is a real dent in the maintainability -case, independent of speed. - -The typed channel also gives up `readDirect`: polyengine's zero-copy -direct-read session is `stream` only (embedder-api amendment A21), -so the host reads with ordinary `read()` and pays a copy per batch. It -does **not** reintroduce the A15 host-retention hazard that killed the -call transport (see the historical section below): A15 licenses -quiescence on a retained end, a parked operation, *or* an unfinished -pump, and the host holds the lifted readable end for the instance's -lifetime, so the guest scheduler's persistent park is exactly as legal -here as on the byte channel. No deadlock trap fired in any run. +the byte grammar could not express, so `applyOperations` (formerly +`applyTyped`) needs explicit validation the byte decoder never needed +(`host/src/operations.ts`'s `rehydrateTemplateArena`). That is a real dent +in the maintainability case, independent of speed, and it did not go away +when the byte channel was retired — it is the cost of the schema that +remains. + +The typed channel also gave up `readDirect`: polyengine's zero-copy +direct-read session is `stream` only, so the host read with ordinary +`read()` and paid a copy per batch. It did **not** reintroduce the +host-retention hazard that killed the call transport (see the historical +section below): that rule licenses quiescence on a retained end, a parked +operation, *or* an unfinished pump (.deps/polyengine/contracts/ +embedder-api.md §"Streams and futures", issue #162), and the host holds the +lifted readable end for the instance's lifetime, so the guest scheduler's +persistent park was exactly as legal here as on the byte channel. No +deadlock trap fired in any run. Third, `use mutations.{operation}` in the world makes the interface an import of every component built against it — including components that -only ever call `run`. The host supplies nothing (the interface has no -items), but the byte channel's own component type changed to add a -typed channel it does not use. +only ever called `run`. The host supplies nothing (the interface has no +items), but the byte channel's own component type changed to add a typed +channel it did not use — moot now that there is only one channel. ### Measured @@ -286,7 +298,7 @@ Several lift strategies run against the same guest memory at the same rendezvous, interleaved in one timed loop so none is measured under a different JIT state, each asserted element-by-element to produce the same values as the interpreter and the same sink calls as -`host/src/typed.ts`: +`host/src/operations.ts` (then `host/src/typed.ts`): | strategy | ns/element, 9e17dc9 | ns/element, 22b5d3d | | --- | --- | --- | @@ -492,18 +504,18 @@ see the numbers-are-box-relative guardrail below.) # bench-rows results — 2026-09-04 - Deno: 2.9.5 (aarch64-unknown-linux-gnu) -- git rev: 9f26b55-dirty +- git rev: 813b31c-dirty - Box note: numbers are box-relative — compare columns within this run, not across machines. See bench/README.md. -| op | bytes (ms, median of 5) | typed (ms, median of 5) | typed / bytes | -| --- | --- | --- | --- | -| create-1k | 9.51 | 15.97 | 1.68x | -| create-10k | 76.21 | 151.89 | 1.99x | -| append-1k | 85.77 | 90.89 | 1.06x | -| update-every-10th | 3.77 | 4.84 | 1.28x | -| swap-rows | 4.54 | 4.99 | 1.10x | -| remove-row | 3.05 | 5.56 | 1.82x | -| clear | 4.23 | 5.01 | 1.18x | +| op | ms (median of 5) | +| --- | --- | +| create-1k | 22.15 | +| create-10k | 192.22 | +| append-1k | 92.77 | +| update-every-10th | 5.39 | +| swap-rows | 3.47 | +| remove-row | 4.04 | +| clear | 7.44 | diff --git a/bench/bench.ts b/bench/bench.ts index 22ead41..e562ee6 100644 --- a/bench/bench.ts +++ b/bench/bench.ts @@ -5,27 +5,19 @@ // A worker may report a null median (host-runtime trap exhausted its // retries — see bench/ops.ts's runOp doc); that renders as "N/A" in the // table rather than aborting the whole run or fabricating a number. -// -// This bench A/Bs the two mutation channels (`run`'s hand-rolled byte -// format vs `run-typed`'s explicit WIT schema, wit/world.wit) against each -// other, over the same component build — restoring the two-column shape -// documented in bench/README.md's "Transport A/B" section (the earlier A/B -// there compared the byte channel against the since-retired "call" -// transport; this one compares it against the typed channel instead). -import { ops, TRANSPORTS } from "./ops.ts"; -import type { TransportName } from "./ops.ts"; +import { ops } from "./ops.ts"; interface OpResult { op: string; - medianMs: Record; - error: Record; + medianMs: number | null; + error: string | undefined; } -async function runWorker(opName: string, transport: TransportName): Promise<{ medianMs: number | null; error?: string }> { +async function runWorker(opName: string): Promise<{ medianMs: number | null; error?: string }> { const workerPath = new URL("./bench_worker.ts", import.meta.url).pathname; const cmd = new Deno.Command(Deno.execPath(), { - args: ["run", "--allow-read=.", "--allow-env", "--allow-run", workerPath, opName, transport], + args: ["run", "--allow-read=.", "--allow-env", "--allow-run", workerPath, opName], cwd: new URL("..", import.meta.url).pathname, stdout: "piped", stderr: "piped", @@ -34,33 +26,21 @@ async function runWorker(opName: string, transport: TransportName): Promise<{ me const stdoutText = new TextDecoder().decode(stdout); if (code !== 0) { const stderrText = new TextDecoder().decode(stderr); - throw new Error(`bench_worker failed for op=${opName} transport=${transport} (exit ${code}):\n${stderrText}`); + throw new Error(`bench_worker failed for op=${opName} (exit ${code}):\n${stderrText}`); } // The worker's only stdout line is its JSON result; be tolerant of any // stray output by taking the last non-empty line. const lines = stdoutText.trim().split("\n").filter((l) => l.length > 0); const last = lines[lines.length - 1]; - const parsed = JSON.parse(last) as { op: string; transport: string; medianMs: number | null; error?: string }; + const parsed = JSON.parse(last) as { op: string; medianMs: number | null; error?: string }; return { medianMs: parsed.medianMs, error: parsed.error }; } async function benchAll(): Promise { const results: OpResult[] = []; - for (const [i, op] of ops.entries()) { - const medianMs = {} as Record; - const error = {} as Record; - // Alternate which transport runs first per op (rather than always - // "bytes" then "typed") so any drift over the run (thermal, background - // GC, whatever) does not land preferentially on one channel. Each op - // still runs in its own process per transport, so there is no shared - // state to worry about — this only removes a fixed ordering bias. - const order = i % 2 === 0 ? TRANSPORTS : [...TRANSPORTS].reverse(); - for (const transport of order) { - const r = await runWorker(op.name, transport); - medianMs[transport] = r.medianMs; - error[transport] = r.error; - } - results.push({ op: op.name, medianMs, error }); + for (const op of ops) { + const r = await runWorker(op.name); + results.push({ op: op.name, medianMs: r.medianMs, error: r.error }); } return results; } @@ -69,32 +49,21 @@ function fmtMs(ms: number | null): string { return ms === null ? "N/A" : ms.toFixed(2); } -function fmtRatio(bytesMs: number | null, typedMs: number | null): string { - if (bytesMs === null || typedMs === null || bytesMs === 0) return "N/A"; - return `${(typedMs / bytesMs).toFixed(2)}x`; -} - function renderTable(results: OpResult[]): string { - const header = "| op | bytes (ms, median of 5) | typed (ms, median of 5) | typed / bytes |"; - const sep = "| --- | --- | --- | --- |"; - const rows = results.map((r) => - `| ${r.op} | ${fmtMs(r.medianMs.bytes)} | ${fmtMs(r.medianMs.typed)} | ${ - fmtRatio(r.medianMs.bytes, r.medianMs.typed) - } |` - ); + const header = "| op | ms (median of 5) |"; + const sep = "| --- | --- |"; + const rows = results.map((r) => `| ${r.op} | ${fmtMs(r.medianMs)} |`); const table = [header, sep, ...rows].join("\n"); - const failureNotes = results.flatMap((r) => - TRANSPORTS.filter((t) => r.error[t]).map((t) => `- **${r.op}** (${t}): ${r.error[t]}`) - ); + const failureNotes = results.filter((r) => r.error).map((r) => `- **${r.op}**: ${r.error}`); if (failureNotes.length === 0) return table; return table + "\n\n### N/A explanations\n\n" + failureNotes.join("\n"); } async function gitRev(): Promise { // `git describe --always --dirty` rather than `rev-parse --short HEAD`: - // this whole spike is uncommitted, so a bare HEAD rev would claim a - // pre-spike commit that has no `run-typed` in it at all — the `--dirty` - // suffix makes that visible instead of silently misleading. + // this tree may be uncommitted, so a bare HEAD rev could claim a stale + // commit — the `--dirty` suffix makes that visible instead of silently + // misleading. try { const cmd = new Deno.Command("git", { args: ["describe", "--always", "--dirty"], stdout: "piped" }); const { stdout } = await cmd.output(); diff --git a/bench/bench_worker.ts b/bench/bench_worker.ts index bfa63e6..8227999 100644 --- a/bench/bench_worker.ts +++ b/bench/bench_worker.ts @@ -8,32 +8,27 @@ // whole bench run; bench.ts renders that op as "N/A" and the failure is // reported plainly, not hidden. // -// Usage: deno run --allow-read=. --allow-env --allow-run bench/bench_worker.ts +// Usage: deno run --allow-read=. --allow-env --allow-run bench/bench_worker.ts import { defaultTranslator } from "@deltic/translator"; -import { loadComponentBytes, ops, runOp, TRANSPORTS } from "./ops.ts"; -import type { TransportName } from "./ops.ts"; +import { loadComponentBytes, ops, runOp } from "./ops.ts"; async function main() { - const [opName, transportArg] = Deno.args; + const [opName] = Deno.args; const op = ops.find((o) => o.name === opName); if (!op) { throw new Error(`unknown op "${opName}"; known ops: ${ops.map((o) => o.name).join(", ")}`); } - if (!(TRANSPORTS as string[]).includes(transportArg)) { - throw new Error(`transport must be one of ${TRANSPORTS.join(", ")}, got "${transportArg}"`); - } - const transport = transportArg as TransportName; const translator = await defaultTranslator(); const bytes = await loadComponentBytes(); try { - const medianMs = await runOp(transport, bytes, translator, op); - console.log(JSON.stringify({ op: opName, transport, medianMs })); + const medianMs = await runOp(bytes, translator, op); + console.log(JSON.stringify({ op: opName, medianMs })); } catch (e) { const message = e instanceof Error ? e.message : String(e); - console.error(`bench_worker: op=${opName} transport=${transport} failed after retries: ${message}`); - console.log(JSON.stringify({ op: opName, transport, medianMs: null, error: message })); + console.error(`bench_worker: op=${opName} failed after retries: ${message}`); + console.log(JSON.stringify({ op: opName, medianMs: null, error: message })); } } diff --git a/bench/ops.ts b/bench/ops.ts index df28fb4..eee4c46 100644 --- a/bench/ops.ts +++ b/bench/ops.ts @@ -34,14 +34,6 @@ export const RUNS = 5; // (dispatch: "an unexplainable number is a bug lead, not a result"). export const SANITY_FLOOR_MS = 0.5; -// The two mutation channels (wit/world.wit `run` vs `run-typed`) A/B'd -// against each other — see "Transport A/B" in bench/README.md. Both use the -// SAME component build (one `.wasm`, two exports): `mountApp`'s `channel` -// option picks which export/decode path is used, so there is only one -// componentPath. -export type TransportName = "bytes" | "typed"; -export const TRANSPORTS: TransportName[] = ["bytes", "typed"]; - export function componentPath(): string { return new URL(`./build/bench-rows.component.wasm`, import.meta.url).pathname; } @@ -148,11 +140,12 @@ function posDataId(root: Element, pos: number): string | undefined { * after a small number of click/render round trips in one mount — this * bench was the first thing to drive repeated interactions through that * transport. Root-caused and fixed at the driver level, NOT here or in - * polyengine (`src/driver.rs`'s `run()`; amendment A15 host retention — - * see its module doc for the full explanation: the stream transport's - * parked `readDirect` session is host-retained state that makes a - * quiescent park legal, the call transport retains nothing host-side - * once flush returns, so the same park was correctly indistinguishable + * polyengine (`src/driver.rs`'s `run()`; see its module doc for the full + * explanation, citing .deps/polyengine/contracts/embedder-api.md + * §"Streams and futures", issue #162 host retention: the stream + * transport's host-retained mutation-stream read end is what makes a + * quiescent park legal, the call transport retained nothing host-side + * once flush returned, so the same park was correctly indistinguishable * from deadlock and traps). This function stays written with minimal * microtask depth between dispatch and poll (tail-returning `waitFor`'s * promise rather than `await`ing it in an `async function`) since that @@ -205,7 +198,6 @@ export function median(xs: number[]): number { } async function freshMount( - t: TransportName, componentBytes: Uint8Array, translator: Translator, ): Promise<{ root: Element; mounted: Mounted; errors: unknown[] }> { @@ -214,12 +206,11 @@ async function freshMount( const mounted = await mountApp({ source: { componentBytes, translator }, root, - channel: t, onError: (err) => errors.push(err), }); - await waitFor(() => root.querySelector("#row-count") !== null, `${t}: initial mount`); + await waitFor(() => root.querySelector("#row-count") !== null, "initial mount"); if (errors.length > 0) { - throw new Error(`${t}: onError fired during mount: ${Deno.inspect(errors)}`); + throw new Error(`onError fired during mount: ${Deno.inspect(errors)}`); } return { root, mounted, errors }; } @@ -371,24 +362,23 @@ export const ops: OpDef[] = [ /** * Run one op's warmup + N=5 timed reps against a *fresh* mount, and return - * the median. Each op/transport pair is run in its own Deno process (see - * bench_worker.ts / bench.ts) — cheap process isolation with no known - * cost now, kept as a structural safety margin. + * the median. Each op is run in its own Deno process (see bench_worker.ts / + * bench.ts) — cheap process isolation with no known cost now, kept as a + * structural safety margin. * * Retries the whole attempt (fresh mount) up to `MAX_ATTEMPTS_PER_OP` * times as a defensive net, not because a specific failure mode is * expected: an earlier revision of this bench was the first thing to - * drive repeated interactions through the CALL transport and hit a real - * `run`-task deadlock trap doing so — root-caused and fixed at the - * driver level (`src/driver.rs`'s `run()`, amendment A15 host retention; - * see its module doc for the full explanation) rather than here. Kept as - * a low-cost fallback in case of unrelated future flakiness, not because - * this bug is expected to recur. + * drive repeated interactions through the CALL transport (since retired — + * bench/README.md's "Transport A/B (historical)") and hit a real + * `run`-task deadlock trap doing so — root-caused and fixed at the driver + * level (`src/driver.rs`'s `run()`; see its module doc for the full + * explanation) rather than here. Kept as a low-cost fallback in case of + * unrelated future flakiness, not because this bug is expected to recur. */ const MAX_ATTEMPTS_PER_OP = 3; export async function runOp( - t: TransportName, componentBytes: Uint8Array, translator: Translator, op: OpDef, @@ -396,7 +386,7 @@ export async function runOp( let lastErr: unknown; for (let attempt = 1; attempt <= MAX_ATTEMPTS_PER_OP; attempt++) { try { - return await runOpOnce(t, componentBytes, translator, op); + return await runOpOnce(componentBytes, translator, op); } catch (e) { lastErr = e; // A "FATAL bench bug" is a real defect in this harness (bad @@ -404,24 +394,23 @@ export async function runOp( // so surface it immediately rather than masking it behind retries. if (e instanceof Error && e.message.startsWith("FATAL bench bug")) throw e; console.error( - `bench/ops.ts: "${op.name}" (${t}) attempt ${attempt}/${MAX_ATTEMPTS_PER_OP} failed ` + + `bench/ops.ts: "${op.name}" attempt ${attempt}/${MAX_ATTEMPTS_PER_OP} failed ` + `(${e instanceof Error ? e.message : String(e)}); retrying with a fresh mount if attempts remain.`, ); } } throw new Error( - `"${op.name}" (${t}) failed all ${MAX_ATTEMPTS_PER_OP} attempts. Last error: ` + + `"${op.name}" failed all ${MAX_ATTEMPTS_PER_OP} attempts. Last error: ` + `${lastErr instanceof Error ? lastErr.message : String(lastErr)}`, ); } async function runOpOnce( - t: TransportName, componentBytes: Uint8Array, translator: Translator, op: OpDef, ): Promise { - const { root, mounted } = await freshMount(t, componentBytes, translator); + const { root, mounted } = await freshMount(componentBytes, translator); // Warmup: exercises JIT/allocator warm paths and any one-time template // registration (dioxus registers a template on first encounter — see // src/writer.rs's module doc — so the very first create/update op pays a @@ -442,7 +431,7 @@ async function runOpOnce( const m = median(timings); if (op.rowsInvolved >= 1000 && m < SANITY_FLOOR_MS) { throw new Error( - `FATAL bench bug: "${op.name}" (${t} transport) touches ${op.rowsInvolved} rows but median is ` + + `FATAL bench bug: "${op.name}" touches ${op.rowsInvolved} rows but median is ` + `${m.toFixed(3)}ms, under the ${SANITY_FLOOR_MS}ms sanity floor for that row count. This is not a ` + `credible number for real DOM work at that scale — investigate before reporting (per-rep timings: ` + `${timings.map((x) => x.toFixed(3)).join(", ")}ms).`, diff --git a/bench/results-2026-09-04-aarch64-unknown-linux-gnu.md b/bench/results-2026-09-04-aarch64-unknown-linux-gnu.md index c31dd44..5a1869c 100644 --- a/bench/results-2026-09-04-aarch64-unknown-linux-gnu.md +++ b/bench/results-2026-09-04-aarch64-unknown-linux-gnu.md @@ -1,15 +1,15 @@ # bench-rows results — 2026-09-04 - Deno: 2.9.5 (aarch64-unknown-linux-gnu) -- git rev: 9f26b55-dirty +- git rev: 813b31c-dirty - Box note: numbers are box-relative — compare columns within this run, not across machines. See bench/README.md. -| op | bytes (ms, median of 5) | typed (ms, median of 5) | typed / bytes | -| --- | --- | --- | --- | -| create-1k | 9.51 | 15.97 | 1.68x | -| create-10k | 76.21 | 151.89 | 1.99x | -| append-1k | 85.77 | 90.89 | 1.06x | -| update-every-10th | 3.77 | 4.84 | 1.28x | -| swap-rows | 4.54 | 4.99 | 1.10x | -| remove-row | 3.05 | 5.56 | 1.82x | -| clear | 4.23 | 5.01 | 1.18x | +| op | ms (median of 5) | +| --- | --- | +| create-1k | 22.15 | +| create-10k | 192.22 | +| append-1k | 92.77 | +| update-every-10th | 5.39 | +| swap-rows | 3.47 | +| remove-row | 4.04 | +| clear | 7.44 | diff --git a/e2e/tests/counter.spec.ts b/e2e/tests/counter.spec.ts index d4ec300..cb46f2d 100644 --- a/e2e/tests/counter.spec.ts +++ b/e2e/tests/counter.spec.ts @@ -122,16 +122,7 @@ test("counter example: real click/type/submit through Chromium", async ({ page } const marker = await page.evaluate(() => (globalThis as unknown as { __preNavMarker?: string }).__preNavMarker); expect(marker, "a real navigation/reload would have wiped this in-page marker").toBe("still-here"); - // 6) STREAM transport smoke assert: every delivered byte has been - // decoded into whole frames (host/src/host.ts's frameDecoder — "lets a - // test confirm the zero-copy direct-read path actually engaged"). - const pending = await page.evaluate(() => - (globalThis as unknown as { __mountedHandle: { frameDecoder: { pending(): number } } }).__mountedHandle - .frameDecoder.pending() - ); - expect(pending, "frameDecoder must have no partial frame staged after settling").toBe(0); - - // 7) Zero collected page errors / console errors throughout. + // 6) Zero collected page errors / console errors throughout. const collectedErrors = await page.evaluate(() => (globalThis as unknown as { __e2eErrors: unknown[] }).__e2eErrors ); diff --git a/fixtures/surface-probe/src/lib.rs b/fixtures/surface-probe/src/lib.rs index cafc1e5..fdd7071 100644 --- a/fixtures/surface-probe/src/lib.rs +++ b/fixtures/surface-probe/src/lib.rs @@ -1,7 +1,8 @@ -//! Host-transport test fixture: no-dioxus guest exercising the stream -//! mutation transport and the round-trip event path. +//! Host test fixture: no-dioxus guest exercising the mutation channel and +//! the round-trip event path. //! -//! Governing doc: wit/world.wit (normative wire format + world `app`). +//! Governing doc: wit/world.wit (the `mutations` interface — the normative +//! operation schema — and the world `app`). //! Op-sequence/behavior spec: the polyengine-dioxus host-runtime dispatch //! (fixtures/surface-probe territory) — see its "Behavior" section for the //! exact template/listener layout this file builds; cited inline as @@ -32,7 +33,7 @@ //! //! Reused across BOTH roles where the text is identical (e.g. the string //! "input" serves as both the second template's tag name and the third -//! listener's event name — same interned slot, no wire-format rule forbids +//! listener's event name — same interned slot, nothing in the schema forbids //! it): 0=`section` 1=`class` 2=`click` 3=`keydown` 4=`input` 5=`title` //! 6=`disabled` 7=`touchstart`. `handle-event`'s `name: u16` therefore //! arrives as one of {2, 3, 4, 7} for our own listeners; @@ -73,168 +74,12 @@ wit_bindgen::generate!({ use core::cell::RefCell; use polymorph::dioxus::events::{DomEvent as HostDomEvent, Modifiers}; - -// -- wire encoding ------------------------------------------------------------ -// -// Mirrors host/src/decoder.ts's Cursor, in reverse (encode instead of -// decode). The byte layout is normative in wit/world.wit's "# Opcodes" / -// "# Primitive operand encodings" doc comments (reproduced above the -// `generate!` call via the WIT source itself); cited inline as -// `wit:`. -mod wire { - const NONE_STRREF: u16 = 0xffff; - - pub struct Encoder { - pub ops: Vec, - pub strings: String, - } - - impl Encoder { - pub fn new() -> Self { - Self { ops: Vec::new(), strings: String::new() } - } - - fn u8(&mut self, v: u8) { - self.ops.push(v); - } - fn u16(&mut self, v: u16) { - self.ops.extend_from_slice(&v.to_le_bytes()); - } - fn u32(&mut self, v: u32) { - self.ops.extend_from_slice(&v.to_le_bytes()); - } - - /// strref: u16, 0xffff = none (wit "Primitive operand encodings"). - fn strref(&mut self, v: Option) { - self.u16(v.unwrap_or(NONE_STRREF)); - } - - /// path: u8 length, then that many u8 child indices. - fn path(&mut self, p: &[u8]) { - self.u8(p.len() as u8); - self.ops.extend_from_slice(p); - } - - /// dynstr: u16 UTF-16 code-unit length, then content appended to the - /// string segment (wit "Primitive operand encodings" — the u32 - /// extended-length form is never needed by this fixture's short - /// ASCII strings). - fn dynstr(&mut self, s: &str) { - let len16: usize = s.chars().map(char::len_utf16).sum(); - self.u16(len16 as u16); - self.strings.push_str(s); - } - - // -- ops (wit:0x01.."0x11) -------------------------------------------- - - pub fn cache_string(&mut self, id: u16, s: &str) { - self.u8(0x01); - self.u16(id); - self.dynstr(s); - } - - /// Begin `register-template`; caller writes `nroots` node trees via - /// `element_start`/`attr`/`children_count`/`text_node`/`dynamic_node` - /// in wire order (depth-first, matching decodeTemplateNode). - pub fn register_template_header(&mut self, tmpl: u16, nroots: u16) { - self.u8(0x02); - self.u16(tmpl); - self.u16(nroots); - } - pub fn tmpl_element_header(&mut self, tag: u16, ns: Option, nattrs: u16) { - self.u8(0x00); // node kind: element - self.u16(tag); - self.strref(ns); - self.u16(nattrs); - } - pub fn tmpl_attr(&mut self, name: u16, ns: Option, value: &str) { - self.u16(name); - self.strref(ns); - self.dynstr(value); - } - pub fn tmpl_children_header(&mut self, nchildren: u16) { - self.u16(nchildren); - } - pub fn tmpl_text(&mut self, value: &str) { - self.u8(0x01); - self.dynstr(value); - } - pub fn tmpl_dynamic(&mut self) { - self.u8(0x02); - } - - pub fn append_children(&mut self, id: u32, m: u32) { - self.u8(0x03); - self.u32(id); - self.u32(m); - } - pub fn assign_id(&mut self, path: &[u8], id: u32) { - self.u8(0x04); - self.path(path); - self.u32(id); - } - pub fn create_text_node(&mut self, id: u32, text: &str) { - self.u8(0x06); - self.u32(id); - self.dynstr(text); - } - pub fn load_template(&mut self, tmpl: u16, root: u16, id: u32) { - self.u8(0x07); - self.u16(tmpl); - self.u16(root); - self.u32(id); - } - pub fn replace_placeholder(&mut self, path: &[u8], m: u32) { - self.u8(0x09); - self.path(path); - self.u32(m); - } - pub fn set_attribute_text(&mut self, id: u32, name: u16, ns: Option, value: &str) { - self.u8(0x0c); - self.u32(id); - self.u16(name); - self.strref(ns); - self.u8(0x00); // attrval kind: text - self.dynstr(value); - } - pub fn set_attribute_bool(&mut self, id: u32, name: u16, ns: Option, value: bool) { - self.u8(0x0c); - self.u32(id); - self.u16(name); - self.strref(ns); - self.u8(0x03); // attrval kind: bool - self.u8(value as u8); - } - pub fn set_text(&mut self, id: u32, text: &str) { - self.u8(0x0d); - self.u32(id); - self.dynstr(text); - } - pub fn new_event_listener(&mut self, id: u32, name: u16, bubbles: bool) { - self.u8(0x0e); - self.u32(id); - self.u16(name); - self.u8(bubbles as u8); // flags bit0 = bubbles - } - - pub fn finish(self) -> (Vec, String) { - (self.ops, self.strings) - } - } - - /// Stream-transport framing (wit/world.wit "# Framing"): - /// `frame-len:u32 strings-len:u32 strings ops`. - pub fn frame(ops: &[u8], strings: &str) -> Vec { - let strings_bytes = strings.as_bytes(); - let frame_len = 4 + strings_bytes.len() as u32 + ops.len() as u32; - let mut buf = Vec::with_capacity(4 + frame_len as usize); - buf.extend_from_slice(&frame_len.to_le_bytes()); - buf.extend_from_slice(&(strings_bytes.len() as u32).to_le_bytes()); - buf.extend_from_slice(strings_bytes); - buf.extend_from_slice(ops); - buf - } -} +// `Operation` itself is already in scope: the world `use`s it, so +// `generate!` re-exports it at the crate root. +use polymorph::dioxus::mutations::{ + AssignId, AttrValue, CacheString, CreateTextNode, EventListener, LoadTemplate, PathOp, + RegisterTemplate, SetAttribute, SetText, StackOp, TemplateAttr, TemplateElement, TemplateNode, +}; // -- interned string ids (see module doc) ------------------------------------ @@ -259,62 +104,92 @@ fn event_name_for_id(id: u16) -> &'static str { /// Builds the initial batch: see the module doc's "DOM built" section for /// the exact op sequence and its rationale. -fn build_initial_batch() -> (Vec, String) { - let mut e = wire::Encoder::new(); - - e.cache_string(STR_SECTION, "section"); - e.cache_string(STR_CLASS, "class"); - e.cache_string(STR_CLICK, "click"); - e.cache_string(STR_KEYDOWN, "keydown"); - e.cache_string(STR_INPUT, "input"); - e.cache_string(STR_TITLE, "title"); - e.cache_string(STR_DISABLED, "disabled"); - e.cache_string(STR_TOUCHSTART, "touchstart"); - - // register-template(tmpl=0):
hdr{dyn}{dyn}
- e.register_template_header(0, 1); - e.tmpl_element_header(STR_SECTION, None, 1); - e.tmpl_attr(STR_CLASS, None, "probe"); - e.tmpl_children_header(3); - e.tmpl_text("hdr"); - e.tmpl_dynamic(); // idx1 — replaced below with the "ready" text node - e.tmpl_dynamic(); // idx2 — left as a bare placeholder, assign-id'd - - // register-template(tmpl=1): - e.register_template_header(1, 1); - e.tmpl_element_header(STR_INPUT, None, 0); - e.tmpl_children_header(0); - - // load-template(tmpl=0) -> id=1 (section); stack: [root, section] - e.load_template(0, 0, 1); - // assign-id exercised on the OTHER dynamic slot (idx2), independently - // of the replace-placeholder below (dispatch: "assign-id ... exercise - // ... independently"). - e.assign_id(&[2], 4); - // create-text-node(id=2, "ready"); stack: [root, section, text(2)] - e.create_text_node(2, "ready"); - // replace-placeholder(path=[1], m=1): pop text(2), replace idx1 slot. - // stack: [root, section] - e.replace_placeholder(&[1], 1); - // set-attribute TEXT on section. - e.set_attribute_text(1, STR_TITLE, None, "probe-section"); - // three bubbling listeners on section. - e.new_event_listener(1, STR_CLICK, true); - e.new_event_listener(1, STR_KEYDOWN, true); - e.new_event_listener(1, STR_TOUCHSTART, true); - // append-children(root, m=1): pop section, attach under root. - e.append_children(0, 1); - - // load-template(tmpl=1) -> id=5 (input); stack: [root, input] - e.load_template(1, 0, 5); - // set-attribute BOOL (false -> removed by the applier's boolean- - // attribute table; exercises the bool attrval path regardless). - e.set_attribute_bool(5, STR_DISABLED, None, false); - e.new_event_listener(5, STR_INPUT, true); - // append-children(root, m=1): pop input, attach under root. - e.append_children(0, 1); +fn build_initial_batch() -> Vec { + fn cache(id: u16, s: &str) -> Operation { + Operation::CacheString(CacheString { id, str: s.to_string() }) + } - e.finish() + vec![ + cache(STR_SECTION, "section"), + cache(STR_CLASS, "class"), + cache(STR_CLICK, "click"), + cache(STR_KEYDOWN, "keydown"), + cache(STR_INPUT, "input"), + cache(STR_TITLE, "title"), + cache(STR_DISABLED, "disabled"), + cache(STR_TOUCHSTART, "touchstart"), + // register-template(tmpl=0):
hdr{dyn}{dyn}
+ // `nodes` is the arena, in pre-order; `roots` and `children` index + // into it (wit: `register-template` is not a tree — recursive WIT + // types are rejected). + Operation::RegisterTemplate(RegisterTemplate { + id: 0, + nodes: vec![ + TemplateNode::Element(TemplateElement { + tag: STR_SECTION, + ns: None, + attrs: vec![TemplateAttr { + name: STR_CLASS, + ns: None, + value: "probe".to_string(), + }], + children: vec![1, 2, 3], + }), + TemplateNode::Text("hdr".to_string()), + TemplateNode::Dynamic, // child idx1 — replaced below with "ready" + TemplateNode::Dynamic, // child idx2 — left bare, assign-id'd + ], + roots: vec![0], + }), + // register-template(tmpl=1): + Operation::RegisterTemplate(RegisterTemplate { + id: 1, + nodes: vec![TemplateNode::Element(TemplateElement { + tag: STR_INPUT, + ns: None, + attrs: Vec::new(), + children: Vec::new(), + })], + roots: vec![0], + }), + // load-template(tmpl=0) -> id=1 (section); stack: [root, section] + Operation::LoadTemplate(LoadTemplate { id: 1, tmpl: 0, root: 0 }), + // assign-id exercised on the OTHER dynamic slot (idx2), independently + // of the replace-placeholder below (dispatch: "assign-id ... exercise + // ... independently"). + Operation::AssignId(AssignId { path: vec![2], id: 4 }), + // create-text-node(id=2, "ready"); stack: [root, section, text(2)] + Operation::CreateTextNode(CreateTextNode { id: 2, text: "ready".to_string() }), + // replace-placeholder(path=[1], m=1): pop text(2), replace idx1 slot. + // stack: [root, section] + Operation::ReplacePlaceholder(PathOp { path: vec![1], m: 1 }), + // set-attribute TEXT on section. + Operation::SetAttribute(SetAttribute { + id: 1, + name: STR_TITLE, + ns: None, + value: AttrValue::Text("probe-section".to_string()), + }), + // three bubbling listeners on section. + Operation::NewEventListener(EventListener { id: 1, name: STR_CLICK, bubbles: true }), + Operation::NewEventListener(EventListener { id: 1, name: STR_KEYDOWN, bubbles: true }), + Operation::NewEventListener(EventListener { id: 1, name: STR_TOUCHSTART, bubbles: true }), + // append-children(root, m=1): pop section, attach under root. + Operation::AppendChildren(StackOp { id: 0, m: 1 }), + // load-template(tmpl=1) -> id=5 (input); stack: [root, input] + Operation::LoadTemplate(LoadTemplate { id: 5, tmpl: 1, root: 0 }), + // set-attribute BOOL (false -> removed by the applier's boolean- + // attribute table; exercises the bool attr-value path regardless). + Operation::SetAttribute(SetAttribute { + id: 5, + name: STR_DISABLED, + ns: None, + value: AttrValue::Boolean(false), + }), + Operation::NewEventListener(EventListener { id: 5, name: STR_INPUT, bubbles: true }), + // append-children(root, m=1): pop input, attach under root. + Operation::AppendChildren(StackOp { id: 0, m: 1 }), + ] } /// One-line deterministic summary written into the "ready" text node (id 2) @@ -377,49 +252,44 @@ fn summarize(name: u16, payload: &Payload) -> (String, bool) { } } -fn build_event_batch(name: u16, payload: &Payload) -> (Vec, String, bool) { +fn build_event_batch(name: u16, payload: &Payload) -> (Vec, bool) { let (summary, prevent) = summarize(name, payload); - let mut e = wire::Encoder::new(); - e.set_text(2, &summary); - let (ops, strings) = e.finish(); - (ops, strings, prevent) + (vec![Operation::SetText(SetText { id: 2, text: summary })], prevent) } -// The stream transport's writer half. `run` creates it and parks it here; +// The mutation channel's writer half. `run` creates it and parks it here; // the spawned initial-batch task and every later `handle-event` write take // it out and put it back (dispatch: "stash the writer half ... -// in a thread_local RefCell; writes complete inline while the host session -// is parked, so no cross-task interleaving. Do not hold RefCell borrows +// in a thread_local RefCell; writes complete inline while the host is +// reading, so no cross-task interleaving. Do not hold RefCell borrows // across awaits." — every use below `take()`s the writer out of the cell // before awaiting, and puts it back after, so no borrow spans an `.await`). // Keeping it here is also what holds the stream OPEN: dropping this writer // is what would signal end-of-stream to the host, so it is never dropped. thread_local! { - static WRITER: RefCell>> = const { RefCell::new(None) }; + static WRITER: RefCell>> = const { RefCell::new(None) }; } struct Component; impl Guest for Component { - async fn run() -> wit_bindgen::rt::async_support::StreamReader { + async fn run() -> wit_bindgen::rt::async_support::StreamReader { // Create the channel and hand the read end back as `run`'s return - // value (wit: `export run: async func() -> stream`). Nothing is - // written from this body: a write here would park waiting for a - // reader the host cannot have until this promise settles. - let (writer, reader) = wit_stream::new::(); + // value (wit: `export run: async func() -> stream`). + // Nothing is written from this body: a write here would park waiting + // for a reader the host cannot have until this promise settles. + let (writer, reader) = wit_stream::new::(); WRITER.with(|cell| *cell.borrow_mut() = Some(writer)); // The initial batch goes out from a spawned task, after the return. // Rendezvous semantics make the ordering safe: the write parks until - // the host's `readDirect` session is parked, so it cannot be lost by - // racing ahead of the host. + // the host reads, so it cannot be lost by racing ahead of the host. wit_bindgen::rt::async_support::spawn_local(async move { - let (ops, strings) = build_initial_batch(); - let frame = wire::frame(&ops, &strings); + let batch = build_initial_batch(); let mut writer = WRITER .with(|cell| cell.borrow_mut().take()) .expect("initial batch: writer taken before the first write"); - writer.write_all(frame).await; + writer.write_all(batch).await; WRITER.with(|cell| *cell.borrow_mut() = Some(writer)); }); // This task ends here; the spawned task ends once the initial batch @@ -432,27 +302,13 @@ impl Guest for Component { reader } - /// This fixture exercises the byte channel only. `run-typed` is the - /// other half of the spike's A/B (wit/world.wit: exactly one of - /// `run`/`run-typed` is called per instance), and nothing here mounts - /// through it — so this arm exists to satisfy the trait and closes the - /// stream immediately by dropping the write end. - async fn run_typed() -> wit_bindgen::rt::async_support::StreamReader< - polymorph::dioxus::mutations::Operation, - > { - let (writer, reader) = wit_stream::new::(); - drop(writer); - reader - } - async fn handle_event(_target: u32, name: u16, payload: Payload, ev: &HostDomEvent) { - let (ops, strings, prevent) = build_event_batch(name, &payload); + let (batch, prevent) = build_event_batch(name, &payload); - let frame = wire::frame(&ops, &strings); let mut writer = WRITER .with(|cell| cell.borrow_mut().take()) .expect("handle-event dispatched before run() opened the stream"); - writer.write_all(frame).await; + writer.write_all(batch).await; WRITER.with(|cell| *cell.borrow_mut() = Some(writer)); if prevent { diff --git a/harness/entry.ts b/harness/entry.ts index f311179..b567200 100644 --- a/harness/entry.ts +++ b/harness/entry.ts @@ -132,9 +132,8 @@ async function main(): Promise { // mountApp resolving is necessary but the initial mutation batch may // still be async-in-flight — see host/tests/counter_test.ts's own // waitFor(() => root.querySelector("#count") !== null, ...)). - // - window.__mountedHandle: the raw `Mounted` object (frameDecoder, - // dispatcher, dispose) for smoke assertions (STREAM transport - // engagement via frameDecoder.pending()). + // - window.__mountedHandle: the raw `Mounted` object (applier, + // dispatcher, dispose) for smoke assertions. // - window.__e2eErrors: collected page/onError errors (asserted empty). (globalThis as unknown as { __mountedHandle: typeof mounted }).__mountedHandle = mounted; diff --git a/host/src/applier.ts b/host/src/applier.ts index 9e1aa44..27e04f4 100644 --- a/host/src/applier.ts +++ b/host/src/applier.ts @@ -1,4 +1,5 @@ -// DOM applier for the polymorph:dioxus mutation wire format. +// DOM applier for the polymorph:dioxus mutation schema (wit/world.wit's +// `interface mutations`, applied via operations.ts's `applyOperations`). // // DOM semantics (stack machine, node table, template cloning, path walking, // setAttributeInner rules) are ported from dioxus-web's own interpreter @@ -6,11 +7,55 @@ // https://github.com/DioxusLabs/dioxus, `packages/web/src/js/core.ts` and // `packages/interpreter/src/set_attribute.ts`, vendored here for reference // as /tmp/opencode/dioxus-ref/{core,set_attribute}.ts at authoring time). -// The BYTE FORMAT is ours (see decoder.ts / wit/world.wit); only the DOM -// application rules are ported. Cited inline as `ref:core.ts:` / -// `ref:set_attribute.ts:`. - -import type { OpSink, StrRef, TemplateNodeDesc } from "./decoder.ts"; +// The MUTATION SCHEMA is ours (wit/world.wit's `interface mutations`); only +// the DOM application rules are ported. Cited inline as `ref:core.ts:` +// / `ref:set_attribute.ts:`. + +/** An interned string id (wit/world.wit `mutations.str-ref`, a u16), + * defined by a prior `cache-string` operation. */ +export type StrRef = number; + +/** The recursive tree `operations.ts`'s `rehydrateTemplateArena` builds out + * of a `register-template` operation's flat arena, for `OpSink. + * registerTemplate` to consume. */ +export type TemplateNodeDesc = + | { + kind: "element"; + tag: StrRef; + ns: StrRef | null; + attrs: { name: StrRef; ns: StrRef | null; value: string }[]; + children: TemplateNodeDesc[]; + } + | { kind: "text"; value: string } + | { kind: "dynamic" }; + +/** The sink `operations.ts`'s `applyOperations` drives — one method per + * `mutations.operation` arm (wit/world.wit), `register-template`'s arena + * already rehydrated into a `TemplateNodeDesc` tree. `DomApplier` is the + * only implementor. */ +export interface OpSink { + cacheString(id: number, s: string): void; + registerTemplate(tmpl: number, roots: TemplateNodeDesc[]): void; + appendChildren(id: number, m: number): void; + assignId(path: Uint8Array, id: number): void; + createPlaceholder(id: number): void; + createTextNode(id: number, text: string): void; + loadTemplate(tmpl: number, root: number, id: number): void; + replaceWith(id: number, m: number): void; + replacePlaceholder(path: Uint8Array, m: number): void; + insertAfter(id: number, m: number): void; + insertBefore(id: number, m: number): void; + setAttributeText(id: number, name: StrRef, ns: StrRef | null, value: string): void; + setAttributeFloat(id: number, name: StrRef, ns: StrRef | null, value: number): void; + setAttributeInt(id: number, name: StrRef, ns: StrRef | null, value: bigint): void; + setAttributeBool(id: number, name: StrRef, ns: StrRef | null, value: boolean): void; + setAttributeNone(id: number, name: StrRef, ns: StrRef | null): void; + setText(id: number, text: string): void; + newEventListener(id: number, name: StrRef, bubbles: boolean): void; + removeEventListener(id: number, name: StrRef, bubbles: boolean): void; + remove(id: number): void; + pushRoot(id: number): void; +} export interface ListenerDelegate { add( diff --git a/host/src/decoder.ts b/host/src/decoder.ts deleted file mode 100644 index 0590919..0000000 --- a/host/src/decoder.ts +++ /dev/null @@ -1,444 +0,0 @@ -// Decoder for the polymorph:dioxus mutation wire format. -// -// The wire format is normative in wit/world.wit (the `run` export's doc -// comments on the opcode table, framing, and primitive operand encodings). -// This file implements the "op segment" decoder (decodeBatch) plus the -// stream-transport framing layer (FrameDecoder). See wit/world.wit for the -// authoritative byte layout; cited inline as `wit:
`. - -export type StrRef = number; // u16 interned id - -export type TemplateNodeDesc = - | { - kind: "element"; - tag: StrRef; - ns: StrRef | null; - attrs: { name: StrRef; ns: StrRef | null; value: string }[]; - children: TemplateNodeDesc[]; - } - | { kind: "text"; value: string } - | { kind: "dynamic" }; - -export interface OpSink { - cacheString(id: number, s: string): void; - registerTemplate(tmpl: number, roots: TemplateNodeDesc[]): void; - appendChildren(id: number, m: number): void; - assignId(path: Uint8Array, id: number): void; - createPlaceholder(id: number): void; - createTextNode(id: number, text: string): void; - loadTemplate(tmpl: number, root: number, id: number): void; - replaceWith(id: number, m: number): void; - replacePlaceholder(path: Uint8Array, m: number): void; - insertAfter(id: number, m: number): void; - insertBefore(id: number, m: number): void; - setAttributeText(id: number, name: StrRef, ns: StrRef | null, value: string): void; - setAttributeFloat(id: number, name: StrRef, ns: StrRef | null, value: number): void; - setAttributeInt(id: number, name: StrRef, ns: StrRef | null, value: bigint): void; - setAttributeBool(id: number, name: StrRef, ns: StrRef | null, value: boolean): void; - setAttributeNone(id: number, name: StrRef, ns: StrRef | null): void; - setText(id: number, text: string): void; - newEventListener(id: number, name: StrRef, bubbles: boolean): void; - removeEventListener(id: number, name: StrRef, bubbles: boolean): void; - remove(id: number): void; - pushRoot(id: number): void; -} - -// Opcodes — wit/world.wit "# Opcodes" table. -const OP_CACHE_STRING = 0x01; -const OP_REGISTER_TEMPLATE = 0x02; -const OP_APPEND_CHILDREN = 0x03; -const OP_ASSIGN_ID = 0x04; -const OP_CREATE_PLACEHOLDER = 0x05; -const OP_CREATE_TEXT_NODE = 0x06; -const OP_LOAD_TEMPLATE = 0x07; -const OP_REPLACE_WITH = 0x08; -const OP_REPLACE_PLACEHOLDER = 0x09; -const OP_INSERT_AFTER = 0x0a; -const OP_INSERT_BEFORE = 0x0b; -const OP_SET_ATTRIBUTE = 0x0c; -const OP_SET_TEXT = 0x0d; -const OP_NEW_EVENT_LISTENER = 0x0e; -const OP_REMOVE_EVENT_LISTENER = 0x0f; -const OP_REMOVE = 0x10; -const OP_PUSH_ROOT = 0x11; - -// attrval kinds — wit/world.wit set-attribute op. -const ATTR_TEXT = 0x00; -const ATTR_FLOAT = 0x01; -const ATTR_INT = 0x02; -const ATTR_BOOL = 0x03; -const ATTR_NONE = 0x04; - -// register-template node kinds — wit/world.wit register-template op. -const NODE_ELEMENT = 0x00; -const NODE_TEXT = 0x01; -const NODE_DYNAMIC = 0x02; - -const NONE_STRREF = 0xffff; - -/** - * Cursor over one batch's op segment + string segment. Strings are sliced - * sequentially by UTF-16 code-unit length off of `strings` (never a - * per-string TextDecoder call — wit "string segment" doc says the whole - * segment is decoded in one pass by the caller/FrameDecoder). - */ -class Cursor { - view: DataView; - bytes: Uint8Array; - off = 0; - strings: string; - strOff = 0; - - constructor(ops: Uint8Array, strings: string) { - this.bytes = ops; - this.view = new DataView(ops.buffer, ops.byteOffset, ops.byteLength); - this.strings = strings; - } - - u8(): number { - const v = this.view.getUint8(this.off); - this.off += 1; - return v; - } - u16(): number { - const v = this.view.getUint16(this.off, true); - this.off += 2; - return v; - } - u32(): number { - const v = this.view.getUint32(this.off, true); - this.off += 4; - return v; - } - s64(): bigint { - const v = this.view.getBigInt64(this.off, true); - this.off += 8; - return v; - } - f64(): number { - const v = this.view.getFloat64(this.off, true); - this.off += 8; - return v; - } - - /** strref: u16, 0xffff = none (wit "Primitive operand encodings"). */ - strref(): StrRef | null { - const v = this.u16(); - return v === NONE_STRREF ? null : v; - } - - /** path: u8 length, then that many u8 child indices. */ - path(): Uint8Array { - const len = this.u8(); - const p = this.bytes.subarray(this.off, this.off + len); - this.off += len; - return p; - } - - /** - * dynstr: u16 UTF-16 code-unit length, then (iff 0xffff) u32 actual - * length. Content is the next `length` code units of the decoded string - * segment, consumed sequentially (wit "Primitive operand encodings"). - */ - dynstr(): string { - let len = this.u16(); - if (len === 0xffff) { - len = this.u32(); - } - const s = this.strings.substring(this.strOff, this.strOff + len); - this.strOff += len; - return s; - } -} - -function decodeTemplateNode(c: Cursor): TemplateNodeDesc { - const kind = c.u8(); - switch (kind) { - case NODE_ELEMENT: { - const tag = c.u16(); - const ns = c.strref(); - const nattrs = c.u16(); - const attrs: { name: StrRef; ns: StrRef | null; value: string }[] = []; - for (let i = 0; i < nattrs; i++) { - const name = c.u16(); - const attrNs = c.strref(); - const value = c.dynstr(); - attrs.push({ name, ns: attrNs, value }); - } - const nchildren = c.u16(); - const children: TemplateNodeDesc[] = []; - for (let i = 0; i < nchildren; i++) { - children.push(decodeTemplateNode(c)); - } - return { kind: "element", tag, ns, attrs, children }; - } - case NODE_TEXT: { - const value = c.dynstr(); - return { kind: "text", value }; - } - case NODE_DYNAMIC: - return { kind: "dynamic" }; - default: - throw new Error(`decodeBatch: unknown template node kind ${kind}`); - } -} - -/** Decode ONE batch: `ops` op-segment bytes + already-decoded string segment. */ -export function decodeBatch(ops: Uint8Array, strings: string, sink: OpSink): void { - const c = new Cursor(ops, strings); - const len = ops.byteLength; - while (c.off < len) { - const opcode = c.u8(); - switch (opcode) { - case OP_CACHE_STRING: { - const id = c.u16(); - const s = c.dynstr(); - sink.cacheString(id, s); - break; - } - case OP_REGISTER_TEMPLATE: { - const tmpl = c.u16(); - const nroots = c.u16(); - const roots: TemplateNodeDesc[] = []; - for (let i = 0; i < nroots; i++) { - roots.push(decodeTemplateNode(c)); - } - sink.registerTemplate(tmpl, roots); - break; - } - case OP_APPEND_CHILDREN: { - const id = c.u32(); - const m = c.u32(); - sink.appendChildren(id, m); - break; - } - case OP_ASSIGN_ID: { - const path = c.path(); - const id = c.u32(); - sink.assignId(path, id); - break; - } - case OP_CREATE_PLACEHOLDER: { - const id = c.u32(); - sink.createPlaceholder(id); - break; - } - case OP_CREATE_TEXT_NODE: { - const id = c.u32(); - const text = c.dynstr(); - sink.createTextNode(id, text); - break; - } - case OP_LOAD_TEMPLATE: { - const tmpl = c.u16(); - const root = c.u16(); - const id = c.u32(); - sink.loadTemplate(tmpl, root, id); - break; - } - case OP_REPLACE_WITH: { - const id = c.u32(); - const m = c.u32(); - sink.replaceWith(id, m); - break; - } - case OP_REPLACE_PLACEHOLDER: { - const path = c.path(); - const m = c.u32(); - sink.replacePlaceholder(path, m); - break; - } - case OP_INSERT_AFTER: { - const id = c.u32(); - const m = c.u32(); - sink.insertAfter(id, m); - break; - } - case OP_INSERT_BEFORE: { - const id = c.u32(); - const m = c.u32(); - sink.insertBefore(id, m); - break; - } - case OP_SET_ATTRIBUTE: { - const id = c.u32(); - const name = c.u16(); - const ns = c.strref(); - const attrKind = c.u8(); - switch (attrKind) { - case ATTR_TEXT: - sink.setAttributeText(id, name, ns, c.dynstr()); - break; - case ATTR_FLOAT: - sink.setAttributeFloat(id, name, ns, c.f64()); - break; - case ATTR_INT: - sink.setAttributeInt(id, name, ns, c.s64()); - break; - case ATTR_BOOL: - sink.setAttributeBool(id, name, ns, c.u8() !== 0); - break; - case ATTR_NONE: - sink.setAttributeNone(id, name, ns); - break; - default: - throw new Error(`decodeBatch: unknown attrval kind ${attrKind}`); - } - break; - } - case OP_SET_TEXT: { - const id = c.u32(); - const text = c.dynstr(); - sink.setText(id, text); - break; - } - case OP_NEW_EVENT_LISTENER: { - const id = c.u32(); - const name = c.u16(); - // flags:u8, bit0 = bubbles (dioxus-html event_bubbles verdict, - // guest-computed); bits 1..7 reserved. - const bubbles = (c.u8() & 1) !== 0; - sink.newEventListener(id, name, bubbles); - break; - } - case OP_REMOVE_EVENT_LISTENER: { - const id = c.u32(); - const name = c.u16(); - const bubbles = (c.u8() & 1) !== 0; - sink.removeEventListener(id, name, bubbles); - break; - } - case OP_REMOVE: { - const id = c.u32(); - sink.remove(id); - break; - } - case OP_PUSH_ROOT: { - const id = c.u32(); - sink.pushRoot(id); - break; - } - default: - throw new Error(`decodeBatch: unknown opcode 0x${opcode.toString(16)}`); - } - } -} - -const FRAME_DECODER = new TextDecoder("utf-8", { ignoreBOM: true }); - -/** - * Stream-transport framing layer — wit/world.wit "# Framing (stream - * transport only)": - * - * frame := frame-len:u32 strings-len:u32 strings:u8{strings-len} ops:u8{rest} - * frame-len = byte length of everything after the frame-len field - * = 4 + strings-len + len(ops) - */ -export class FrameDecoder { - #sink: OpSink; - #staged: Uint8Array | null = null; - - constructor(sink: OpSink) { - this.#sink = sink; - } - - pending(): number { - return this.#staged ? this.#staged.byteLength : 0; - } - - /** - * Consume as many whole frames as available from `bytes` (optionally - * prefixed by previously staged bytes). Returns the number of bytes of - * `bytes` itself that are accounted for and need NOT be passed to - * stashRest: - * - * - If no frame could be completed even with `bytes` appended to the - * staged carry (invariant: staged never holds a complete frame, so - * this only happens when zero frames complete), this method already - * copies staged+bytes into its own internal state and returns - * `bytes.byteLength` (i.e. "fully absorbed" — satisfies the - * direct-read "never acknowledge zero bytes" requirement without the - * caller having to do anything). - * - Otherwise (progress was made: at least one frame completed), any - * undecoded tail is purely a suffix of `bytes` (the staged carry, if - * any, is fully consumed as a prefix). This method does NOT copy that - * tail itself; it returns the count of `bytes` consumed by whole - * frames, and the caller must call `stashRest(bytes, thatCount)` to - * preserve the remainder before the view becomes invalid. - */ - feed(bytes: Uint8Array): number { - let buf: Uint8Array; - let stagedLen = 0; - if (this.#staged) { - stagedLen = this.#staged.byteLength; - buf = new Uint8Array(stagedLen + bytes.byteLength); - buf.set(this.#staged, 0); - buf.set(bytes, stagedLen); - this.#staged = null; - } else { - buf = bytes; - } - - let off = 0; - // `buf` never changes inside the loop (only `off` advances), so the view - // is hoisted rather than reconstructed per frame. - const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength); - while (true) { - if (buf.byteLength - off < 8) break; // need frame-len + strings-len - const frameLen = view.getUint32(off, true); - const total = 4 + frameLen; // frame-len field itself + its payload - if (buf.byteLength - off < total) break; // partial frame - - const stringsLen = view.getUint32(off + 4, true); - const stringsStart = off + 8; - const stringsEnd = stringsStart + stringsLen; - const opsEnd = off + total; - - const stringsBytes = buf.subarray(stringsStart, stringsEnd); - const strings = FRAME_DECODER.decode(stringsBytes); - const ops = buf.subarray(stringsEnd, opsEnd); - decodeBatch(ops, strings, this.#sink); - - off += total; - } - - if (off === 0 && stagedLen > 0) { - // No progress at all: retain the full merged carry ourselves (buf is - // already a fresh copy when staged existed) and report `bytes` as - // fully absorbed so the caller need not (but safely may) stashRest. - this.#staged = buf; - return bytes.byteLength; - } - if (off === 0) { - // No staged carry, no progress: same as above but nothing to merge. - // Do NOT self-copy here — let the caller stashRest the tail (keeps - // the "caller stashes `bytes`' own tail" invariant uniform). - return 0; - } - - // Progress was made past any staged carry (invariant: stagedLen never - // holds a complete frame, so off > 0 implies off > stagedLen here). - const consumedFromBytes = off - stagedLen; - this.#staged = null; - return consumedFromBytes; - } - - /** - * Copy `bytes.subarray(offset)` into internal staging, prepended to the - * next feed(). Used by the caller for whatever tail feed() did not - * report as consumed (direct-read callback contract: must not - * acknowledge zero bytes while a partial frame remains parked). - */ - stashRest(bytes: Uint8Array, offset: number): void { - const tail = bytes.subarray(offset); - if (tail.byteLength === 0) return; - if (this.#staged) { - const merged = new Uint8Array(this.#staged.byteLength + tail.byteLength); - merged.set(this.#staged, 0); - merged.set(tail, this.#staged.byteLength); - this.#staged = merged; - } else { - this.#staged = new Uint8Array(tail); // copy, per contract - } - } -} diff --git a/host/src/dispatch.ts b/host/src/dispatch.ts index 567f8b1..e1e8e82 100644 --- a/host/src/dispatch.ts +++ b/host/src/dispatch.ts @@ -15,16 +15,16 @@ // // 2. A scheduler-driven mutation flush. When the guest re-renders off its // own timer/async work — no `handle-event` in flight at all — the -// mutation frames arrive through the stream's direct-read `consume` -// callback, and DOM application happens synchronously INSIDE that -// callback, i.e. inside the guest's stream-write rendezvous. A live -// guest activation is on the stack: the instance's reentrance bracket -// is turn-scoped, taken around every thread resumption and released -// only when the thread parks (.deps/polyengine/runtime/src/task/ -// thread.ts `resumeWith`, and exec/boundary.ts's matching bracket). So -// a native event fired by the mutation itself would enter the guest -// from inside a live turn — the same trap, with nothing "in flight" -// by the window-1 bookkeeping. +// mutation batch arrives through the mutation stream's read loop +// (host.ts's `await ops.read()` resumption), and DOM application +// happens synchronously INSIDE that resumption, i.e. inside the +// guest's stream-write rendezvous. A live guest activation is on the +// stack: the instance's reentrance bracket is turn-scoped, taken +// around every thread resumption and released only when the thread +// parks (.deps/polyengine/runtime/src/task/thread.ts `resumeWith`, and +// exec/boundary.ts's matching bracket). So a native event fired by the +// mutation itself would enter the guest from inside a live turn — the +// same trap, with nothing "in flight" by the window-1 bookkeeping. // // 3. A host IMPORT invoked by the guest. `dom.set-focus` (wit/world.wit, // `interface dom`) runs host code while the guest that called it is @@ -36,15 +36,12 @@ // brackets its body with `beginApply`/`endApply`, so dispatches raised // by the focus change queue and drain once the guest's turn unwinds. // -// Window 2 is also forbidden outright by contract, independently of the -// trap: .deps/polyengine/contracts/embedder-api.md amendment A21 ("Streams -// and futures", direct-read scoping) — "Inside the callback, calls that can -// run guest code or operate this stream are forbidden (reentrancy)". A21 -// gives a second, memory-safety reason: the `DirectSource` view aliases -// guest linear memory, and views are re-derived only per `remaining()` call -// ("a `memory.grow` **between** rendezvous never yields a stale view" — -// within one rendezvous there is no re-derivation). Guest code run -// mid-callback could `memory.grow` and detach the buffer under the decoder. +// Window 2 also risks a memory-safety hazard if guest code were run inside +// it: `applyOperations` reads lifted values already copied out of guest +// linear memory by the embedder's lift step (contracts/embedder-api.md +// "Value mapping"), so unlike a raw-bytes direct-read view there is no +// aliasing hazard here — but running guest code mid-application would still +// reenter the instance, which is what the gate exists to prevent. // // Why deferring to a microtask is sound: microtasks run only when the JS // stack is empty, and a guest turn holds the stack until it parks or @@ -65,7 +62,7 @@ /** * Serializes guest entries so that at most one is live and none is attempted * inside a forbidden window (see the module comment: in-flight call, - * mutation application inside the direct-read callback, or a host import + * mutation application inside the read loop's resumption, or a host import * running inside the calling guest's turn). * * Pure logic — no DOM, no runtime imports — so the ordering rules can be @@ -77,7 +74,7 @@ export class DispatchGate { /** A guest `handle-event` call is in flight (its promise is unsettled). */ #busy = false; /** Inside a host-code window that runs within a live guest turn: the - * direct-read `consume` callback (mutation application), or the body of + * mutation read loop's chunk-application window (host.ts's `beginApply`/`endApply` around applyOperations), or the body of * a host import the guest called. */ #applying = false; #disposed = false; @@ -97,7 +94,7 @@ export class DispatchGate { /** Leave the mutation-application window and drain what it collected. * * The drain is deferred with `queueMicrotask` rather than run inline: - * `endApply` is called from the `finally` inside the direct-read callback, + * `endApply` is called from the `finally` inside the read loop's chunk-application window, * which is still on the guest's rendezvous stack — the instance is not * enterable until that turn unwinds. An empty JS stack is the observable * proxy for "no live guest turn" (module comment). The same holds for a diff --git a/host/src/events.ts b/host/src/events.ts index 52187d0..79ff1ec 100644 --- a/host/src/events.ts +++ b/host/src/events.ts @@ -583,7 +583,7 @@ function isObserverName(name: string): name is ObserverName { interface Registration { bubbles: boolean; /** The interned string id this listener was registered with - * (decoder.ts's StrRef) — wit/world.wit: "Event names cross back on + * (applier.ts's StrRef) — wit/world.wit: "Event names cross back on * handle-event as the same interned u16", so dispatch hands this same * id back rather than re-deriving it from the applier's string table * (applier.ts is consumed unchanged; this keeps the mapping local). */ @@ -661,8 +661,9 @@ export class EventDispatcher implements ListenerDelegate { // `mounted` likewise finds nothing and is harmless. // // Timing: `add()` is called during batch application, i.e. inside the - // DispatchGate's apply window (host.ts's direct-read `consume` brackets - // it with beginApply/endApply). The gate therefore QUEUES this + // DispatchGate's apply window (host.ts's mutation read loop brackets + // each chunk's `applyOperations` call with beginApply/endApply). The + // gate therefore QUEUES this // dispatch and drains it in a microtask after `endApply` — which is // exactly the contract's requirement that `mounted` fire after the // batch that created the element has been fully applied, with the node diff --git a/host/src/host.ts b/host/src/host.ts index 18a2b2a..54ef348 100644 --- a/host/src/host.ts +++ b/host/src/host.ts @@ -1,23 +1,22 @@ // Host runtime wiring for polymorph:dioxus — instantiation, the stream -// mutation transport, and DOM event dispatch back into the guest. +// mutation channel, and DOM event dispatch back into the guest. // // Governing docs: wit/world.wit (world `app`, interface `events`), and // .deps/polyengine/contracts/embedder-api.md ("Module wiring and -// instantiation", "Resources", "Streams and futures" amendment A21, "Value -// mapping"). Cited inline as `contract:
`. +// instantiation", "Resources", "Streams and futures", "Value mapping"). +// Cited inline as `contract:
`. import { instantiate } from "@deltic/runtime/embedder"; import { wasi } from "@polyengine/wasi"; import type { InstantiateSource } from "@deltic/runtime/embedder"; -import type { DirectSource, Stream } from "@deltic/protocol"; +import type { Stream } from "@deltic/protocol"; import { DomApplier } from "./applier.ts"; import { DispatchGate } from "./dispatch.ts"; -import { FrameDecoder } from "./decoder.ts"; import { EventDispatcher, serializePayload } from "./events.ts"; import type { NativeEventLike } from "./events.ts"; -import { applyTyped } from "./typed.ts"; -import type { OperationLifted } from "./typed.ts"; +import { applyOperations } from "./operations.ts"; +import type { Operation } from "./operations.ts"; export interface MountOptions { /** Component artifacts in either form `instantiate` accepts: a @@ -29,17 +28,11 @@ export interface MountOptions { * verbatim to `instantiate`. */ source: InstantiateSource; root: Element; - /** Which mutation channel to mount on. Defaults to "bytes". "typed" uses - * the explicit-WIT-schema channel (`run-typed` / `stream`, - * wit/world.wit `interface mutations`) instead of the hand-rolled byte - * format on `run`. Added to A/B the two encodings (bench/README.md); the - * byte channel remains the baseline and is unaffected by this option. */ - channel?: "bytes" | "typed"; /** Asynchronous failure after a successful mount: the mutation stream's - * parked direct-read session rejecting (guest trap — `PeerTrappedError` — - * or teardown), or a `handle-event` call rejecting. A failure during mount - * itself is NOT routed here: `await exports.run()` rejects and `mountApp` - * throws it to the caller. */ + * read loop rejecting (guest trap — `PeerTrappedError` — or teardown), or + * a `handle-event` call rejecting. A failure during mount itself is NOT + * routed here: `await exports.run()` rejects and `mountApp` throws it to + * the caller. */ onError?: (err: unknown) => void; } @@ -54,17 +47,13 @@ export interface Mounted { /** Exposed for tests: the event dispatcher wired as the applier's * ListenerDelegate. */ dispatcher: EventDispatcher; - /** Exposed for tests: the stream-transport frame decoder. Lets a - * test confirm the zero-copy direct-read path actually engaged — - * `pending()` returns 0 once every delivered byte has been decoded into - * whole frames, with no heavier instrumentation needed. */ - frameDecoder: FrameDecoder; /** Dispatch a native-event-like value at `targetEl` for `name`, exactly * as a real DOM listener would (used by fullstack tests and by real * event listeners alike). */ dispatch(targetEl: Element | null, name: string, ev: NativeEventLike): void; } + /** * A host-implemented resource class for `events.dom-event` * (contracts/embedder-api.md "Resources": "the host provides a plain class @@ -311,9 +300,9 @@ export function createDomImports(applier: DomApplier, gate: DispatchGate) { * `ListenerDelegate`), instantiates the component with the `events`/`dom` * imports wired per contracts/embedder-api.md "Module wiring and * instantiation" (imports keyed by the verbatim interface id), awaits - * `run()` for the mutation stream's read end, and parks a direct-read - * session on it for the life of the instance. Returns a handle for - * dispatching DOM events and (best-effort) tearing down. + * `run()` for the mutation stream's read end, and starts a read loop over + * it for the life of the instance. Returns a handle for dispatching DOM + * events and (best-effort) tearing down. * * Because the read end now comes back as `run`'s return value rather than * through a host import, a mount-time guest trap rejects THIS await and is @@ -326,7 +315,6 @@ export async function mountApp(opts: MountOptions): Promise { dispatchEvent(elementId, nameId, name, ev); }); const applier = new DomApplier(opts.root, dispatcher); - const frameDecoder = new FrameDecoder(applier); let disposed = false; const onError = opts.onError ?? (() => {}); @@ -359,16 +347,15 @@ export async function mountApp(opts: MountOptions): Promise { // // (2) While mutations are being APPLIED, with no `handle-event` in flight // at all. A scheduler-driven flush (guest timer/async re-render) - // delivers frames through the mutation stream's direct-read `consume` - // callback below, and DOM application runs synchronously inside it — - // i.e. inside the guest's stream-write rendezvous, with a live guest - // turn on the stack (the reentrance bracket is turn-scoped: taken - // around every thread resumption, released when the thread parks — + // delivers a batch through the mutation read loop below, and DOM + // application (`applyOperations`) runs synchronously inside that + // loop's `await ... read()` resumption — i.e. inside the guest's + // stream-write rendezvous, with a live guest turn on the stack (the + // reentrance bracket is turn-scoped: taken around every thread + // resumption, released when the thread parks — // .deps/polyengine/runtime/src/task/thread.ts). A native event fired // by the mutation itself would enter the guest straight out of the - // rendezvous. That is forbidden outright by embedder-api.md amendment - // A21 ("Inside the callback, calls that can run guest code or operate - // this stream are forbidden (reentrancy)") as well as trapping. + // rendezvous — forbidden outright, trap or not. // // Fix: serialize entries into the guest through `DispatchGate` (see // ./dispatch.ts for the full rationale, including why a microtask- @@ -397,131 +384,81 @@ export async function mountApp(opts: MountOptions): Promise { ...wasi(), // Keyed by the verbatim interface id (contract:"Module wiring and // instantiation"), so the version tracks the WIT package version — - // now 0.4.0. `events`' sole host-implemented item is the `dom-event` + // now 0.5.0. `events`' sole host-implemented item is the `dom-event` // resource, named by its bindgen-emitted UpperCamel name // (contract:"Resources"); `dom`'s items are functions, named by their // bindgen-emitted lowerCamel names. - "polymorph:dioxus/events@0.4.0": { DomEvent }, - "polymorph:dioxus/dom@0.4.0": createDomImports(applier, gate), + "polymorph:dioxus/events@0.5.0": { DomEvent }, + "polymorph:dioxus/dom@0.5.0": createDomImports(applier, gate), }; const instance = await instantiate(opts.source, imports); handleEventExport = instance.exports.handleEvent as (...a: unknown[]) => unknown; - // `run`/`run-typed` start the app and return the mutation channel's read - // end; the promise settles as soon as the guest hands the reader back - // (the app's scheduler keeps running as a spawned guest task). A trap - // before the return rejects here and propagates out of `mountApp`. - const channel = opts.channel ?? "bytes"; - const ops = channel === "typed" - ? await (instance.exports.runTyped as () => Promise>)() - : await (instance.exports.run as () => Promise>)(); - - if (channel === "typed") { - // Unlike `stream`, a typed stream has no zero-copy direct-read path - // (embedder-api.md amendment A21's `readDirect` is `stream` only — - // wit/world.wit's `run-typed` doc). Read with an ordinary `read()` - // instead. - // - // The guest scheduler's persistent park between renders needs SOME - // host-side reason the store's deadlock verdict stays suppressed - // (embedder-api.md amendment A15, "Deadlock-verdict suppression tracks - // host retention": suppression holds "while the host retains a way to - // act on a stream/future — a retained end, a parked host operation, or - // an unfinished producer pump"). Here that's the first disjunct, not the - // second: `mountApp` holds the lifted readable end (`ops`/`typedStream`) - // for the instance's whole lifetime — never lowered back into the guest - // — which is retention-by-itself, independent of whether a `read()` - // happens to be in flight at any given instant. (Runtime-side, this is - // `HostActivity` arming on the retained end and disarming only on a - // lower-back-to-guest — .deps/polyengine/runtime/src/exec/ - // host_streams.ts ~lines 295-318.) So, unlike the byte channel's parked - // `readDirect` session — which genuinely IS "a parked host operation" - // for as long as it runs — there being a brief gap here with no read - // outstanding (the await resumption between one `read()`'s chunk - // landing and `applyTyped` finishing, before the next `read()` is - // issued) is not itself a hazard: retention already covers it. - // - // The next read is still issued immediately after applying a chunk, - // with no unnecessary work in between — good practice for latency, not - // a correctness requirement. - // - // MAX_TYPED_READ must be large enough that a whole batch (up to ~40k - // operations for the 10k-row bench case) arrives in one chunk. - const MAX_TYPED_READ = 1 << 22; - const typedStream = ops as Stream; - (async () => { - while (!disposed) { - const chunk = await typedStream.read(MAX_TYPED_READ); - if (chunk.length === 0) break; // end of stream - gate.beginApply(); - try { - applyTyped(chunk, applier); - } finally { - gate.endApply(); - } - } - })().catch((err: unknown) => { - if (!disposed) onError(err); - }); - } else { - const byteStream = ops as Stream; - // Park a direct-read session for the instance's lifetime. We always consume - // the FULL view per rendezvous — whole frames decoded+applied, any partial - // tail staged in the FrameDecoder — so `readDirect`'s "never acknowledge - // zero bytes" hazard (embedder-api.md amendment A21) never arises: - // `markRead` always receives `view.length`, never 0. - const consume = (src: DirectSource): "more" | "done" => { - const view = src.remaining(); - // The callback runs DOM application only (decodeBatch via the - // FrameDecoder) — no direct guest call. But DOM application can fire - // synchronous NATIVE events (detaching a focused element fires - // `focusout`), whose listeners would dispatch into the guest from - // inside this rendezvous. The gate enforces the A21 rule ("calls that - // can run guest code ... are forbidden" inside a direct-read callback) - // transitively: dispatches raised in this window are queued and drained - // by a microtask, once the rendezvous' guest turn has unwound. + // `run` starts the app and returns the mutation stream's read end; the + // promise settles as soon as the guest hands the reader back (the app's + // scheduler keeps running as a spawned guest task). A trap before the + // return rejects here and propagates out of `mountApp`. + const ops = await (instance.exports.run as () => Promise>)(); + + // The guest scheduler's persistent park between renders needs SOME + // host-side reason the store's deadlock verdict stays suppressed. + // wit/world.wit's `run` doc, citing .deps/polyengine/contracts/ + // embedder-api.md §"Streams and futures", issue #162 + // ("Deadlock-verdict suppression tracks host retention"): suppression + // holds "while the host retains a way to act on a stream/future — a + // retained end, a parked host operation, or an unfinished producer pump". + // That rule is disjunctive, and what applies here is the first disjunct, + // not the second: `mountApp` holds the lifted readable end (`ops`) for + // the instance's whole lifetime — never lowered back into the guest — + // which is retention-by-itself, independent of whether a `read()` happens + // to be in flight at any given instant. (Runtime-side, this is + // `HostActivity` arming on the retained end and disarming only on a + // lower-back-to-guest — .deps/polyengine/runtime/src/exec/ + // host_streams.ts.) So there being a brief gap with no read outstanding + // (the await resumption between one `read()`'s chunk landing and + // `applyOperations` finishing, before the next `read()` is issued) is not + // itself a hazard: retention already covers it. + // + // The next read is still issued immediately after applying a chunk, with + // no unnecessary work in between — good practice for latency, not a + // correctness requirement. + // + // MAX_READ must be large enough that a whole batch (up to ~40k operations + // for the 10k-row bench case) arrives in one chunk. + const MAX_READ = 1 << 22; + (async () => { + while (!disposed) { + const chunk = await ops.read(MAX_READ); + if (chunk.length === 0) break; // end of stream gate.beginApply(); try { - const n = frameDecoder.feed(view); - if (n < view.length) { - frameDecoder.stashRest(view, n); - } + applyOperations(chunk, applier); } finally { gate.endApply(); } - src.markRead(view.length); - return "more"; - }; - // The session only settles on stream end/drop/fault, which for a healthy - // long-lived app never happens in normal operation. Route a rejection (peer - // trap, teardown) to onError rather than letting it become unhandled. - byteStream.readDirect(consume).catch((err: unknown) => { - if (!disposed) onError(err); - }); - } + } + })().catch((err: unknown) => { + if (!disposed) onError(err); + }); const mounted: Mounted = { applier, dispatcher, - frameDecoder, dispatch(targetEl, name, ev) { dispatcher.dispatchTo(targetEl, name, ev); }, dispose() { // Per-STREAM disposal is the documented release path: `Stream` // exposes `drop()` (.deps/polyengine/protocol/src/handles.ts:68-82, - // "`[Symbol.dispose]` alias"), and embedder-api.md amendment A21 - // makes reader-drop the designed teardown handshake — "reader/writer - // drop resolves the session with its total ... a resolution the - // producer's own `done` did not cause is the reader-gone signal". - // So dropping the read end RESOLVES the parked direct-read session - // (a resolution, not a rejection — `onError` stays silent, and the - // `!disposed` guard on the catch is belt-and-braces), and the guest - // observes reader-gone on its next write (its driver detects leftover - // bytes from `write_all`, sets its `dead` flag, and discards further - // batches with bounded memory — src/driver.rs), so it goes dark. + // "`[Symbol.dispose]` alias"), and dropping the read end resolves the + // read loop's next `read()` with `done` (a resolution, not a + // rejection — `onError` stays silent, and the `!disposed` guard on + // the catch is belt-and-braces), and the guest observes reader-gone + // on its next write (its driver detects leftover bytes from + // `write_all`, sets its `dead` flag, and discards further batches + // with bounded memory — src/driver.rs), so it goes dark. // // Instance-level disposal still does not exist in the embedder API // (`EmbedderInstance` is `{ exports, handle, imports }`), so the diff --git a/host/src/mod.ts b/host/src/mod.ts index 6a29cea..fc86df0 100644 --- a/host/src/mod.ts +++ b/host/src/mod.ts @@ -1,10 +1,10 @@ // Barrel module for the polymorph:dioxus host runtime. -export { decodeBatch, FrameDecoder } from "./decoder.ts"; -export type { OpSink, StrRef, TemplateNodeDesc } from "./decoder.ts"; - export { DomApplier } from "./applier.ts"; -export type { ListenerDelegate } from "./applier.ts"; +export type { ListenerDelegate, OpSink, StrRef, TemplateNodeDesc } from "./applier.ts"; + +export { applyOperations } from "./operations.ts"; +export type { Operation } from "./operations.ts"; export { EventDispatcher, serializePayload } from "./events.ts"; export type { DispatchSink, NativeEventLike } from "./events.ts"; diff --git a/host/src/typed.ts b/host/src/operations.ts similarity index 86% rename from host/src/typed.ts rename to host/src/operations.ts index 1559bf6..181aeae 100644 --- a/host/src/typed.ts +++ b/host/src/operations.ts @@ -1,12 +1,12 @@ -// Typed-channel applier for the polymorph:dioxus explicit WIT mutation -// schema (`interface mutations` / `stream`, wit/world.wit). +// Applies the polymorph:dioxus mutation schema (`interface mutations` / +// `stream`, wit/world.wit — the normative, and only, encoding of +// a mutation) to a DOM applier. // -// This is the typed counterpart of decoder.ts's `decodeBatch`: instead of -// decoding a byte format, it walks already-lifted `operation` values (as -// polyengine's embedder lifts them per .deps/polyengine/contracts/ -// embedder-api.md "Value mapping") and drives the same `OpSink`. +// Walks already-lifted `operation` values (as polyengine's embedder lifts +// them per .deps/polyengine/contracts/embedder-api.md "Value mapping") and +// drives an `OpSink`. -import type { OpSink, StrRef, TemplateNodeDesc } from "./decoder.ts"; +import type { OpSink, StrRef, TemplateNodeDesc } from "./applier.ts"; // -- lifted-value shapes ---------------------------------------------------- // @@ -91,7 +91,7 @@ interface CacheStringLifted { } /** One lifted `operation` variant value. */ -export type OperationLifted = +export type Operation = | { kind: "cache-string"; value: CacheStringLifted } | { kind: "register-template"; value: RegisterTemplateLifted } | { kind: "append-children"; value: StackOpLifted } @@ -117,8 +117,9 @@ export type OperationLifted = * interface doc) into the recursive `TemplateNodeDesc` tree `OpSink. * registerTemplate` wants. * - * The arena admits index graphs the byte grammar cannot express. Two are - * rejected outright, with a thrown Error rather than a hang or a silently + * The arena admits malformed index graphs a natural recursive shape could + * not express. Two are rejected outright, with a thrown Error rather than a + * hang or a silently * wrong tree: an out-of-range index, and a cycle (a node that (transitively) * indexes itself as a child — `state[idx] === 1` below, "on the current * build path"). A THIRD shape is accepted rather than rejected: a DAG, where @@ -139,10 +140,10 @@ function rehydrateTemplateArena(nodes: TemplateNodeLifted[], roots: number[]): T function build(idx: number): TemplateNodeDesc { if (idx < 0 || idx >= nodes.length) { - throw new Error(`applyTyped: register-template arena index ${idx} out of range (${nodes.length} nodes)`); + throw new Error(`applyOperations: register-template arena index ${idx} out of range (${nodes.length} nodes)`); } if (state[idx] === 1) { - throw new Error(`applyTyped: register-template arena has a cycle at index ${idx}`); + throw new Error(`applyOperations: register-template arena has a cycle at index ${idx}`); } if (state[idx] === 2) { return built[idx]!; @@ -177,9 +178,8 @@ function rehydrateTemplateArena(nodes: TemplateNodeLifted[], roots: number[]): T return roots.map(build); } -/** Apply a batch of lifted `operation` values to `sink` — the typed - * counterpart of `decodeBatch`. */ -export function applyTyped(ops: OperationLifted[], sink: OpSink): void { +/** Apply a batch of lifted `operation` values to `sink`. */ +export function applyOperations(ops: Operation[], sink: OpSink): void { for (const op of ops) { switch (op.kind) { case "cache-string": @@ -256,7 +256,7 @@ export function applyTyped(ops: OperationLifted[], sink: OpSink): void { break; default: { const _exhaustive: never = op; - throw new Error(`applyTyped: unknown operation "${(_exhaustive as { kind: string }).kind}"`); + throw new Error(`applyOperations: unknown operation "${(_exhaustive as { kind: string }).kind}"`); } } } diff --git a/host/tests/applier_test.ts b/host/tests/applier_test.ts index 16249c1..21e1c1a 100644 --- a/host/tests/applier_test.ts +++ b/host/tests/applier_test.ts @@ -1,7 +1,7 @@ import { assertEquals } from "jsr:@std/assert@1"; import { parseHTML } from "linkedom"; import { DomApplier, type ListenerDelegate } from "../src/applier.ts"; -import type { TemplateNodeDesc } from "../src/decoder.ts"; +import type { TemplateNodeDesc } from "../src/applier.ts"; function makeRoot() { const { document } = parseHTML("
"); diff --git a/host/tests/counter_test.ts b/host/tests/counter_test.ts index 40e777b..6f8b6df 100644 --- a/host/tests/counter_test.ts +++ b/host/tests/counter_test.ts @@ -1,10 +1,9 @@ // Full-stack host-runtime test for the REAL Dioxus counter example -// (examples/counter/src/lib.rs, launch! uses the stream transport, the only -// transport). This +// (examples/counter/src/lib.rs, launch! uses the mutation stream). This // exercises the whole pipeline: driver.rs (run/handle-event tasks), // writer.rs (mutation batching), events.rs (payload conversion), and the -// host-side applier/dispatcher/decoder — none of which had ever executed -// end to end before this test existed. +// host-side applier/dispatcher/operations applier — none of which had ever +// executed end to end before this test existed. // // Requires `just example counter` to have built // examples/build/counter.component.wasm first. @@ -176,9 +175,9 @@ Deno.test("counter example: mount, click, type, list, form submit", async () => // 7) After dispose the runtime is detached: a further dispatch changes // nothing and surfaces no error. Dropping the mutation stream's read end - // RESOLVES the parked direct-read session (embedder-api.md A21's - // reader-drop rule) rather than rejecting it, so `onError` stays silent; - // the guest sees reader-gone on its next write and goes dark. + // resolves the read loop's next `read()` with `done` rather than + // rejecting it, so `onError` stays silent; the guest sees reader-gone on + // its next write and goes dark. const before = root.innerHTML; const postDispose = click(inc); mounted.dispatch(inc, "click", postDispose); diff --git a/host/tests/decoder_test.ts b/host/tests/decoder_test.ts deleted file mode 100644 index 22b8d42..0000000 --- a/host/tests/decoder_test.ts +++ /dev/null @@ -1,352 +0,0 @@ -import { assertEquals } from "jsr:@std/assert@1"; -import { decodeBatch, FrameDecoder, type OpSink } from "../src/decoder.ts"; - -// Recording sink: turns OpSink calls into plain JSON-shaped op records -// matching the golden-vector expected.json format (see dispatch doc). -function recordingSink(ops: unknown[]): OpSink { - return { - cacheString(id, s) { - ops.push({ op: "cache-string", id, s }); - }, - registerTemplate(tmpl, roots) { - ops.push({ op: "register-template", tmpl, roots }); - }, - appendChildren(id, m) { - ops.push({ op: "append-children", id, m }); - }, - assignId(path, id) { - ops.push({ op: "assign-id", path: Array.from(path), id }); - }, - createPlaceholder(id) { - ops.push({ op: "create-placeholder", id }); - }, - createTextNode(id, text) { - ops.push({ op: "create-text-node", id, text }); - }, - loadTemplate(tmpl, root, id) { - ops.push({ op: "load-template", tmpl, root, id }); - }, - replaceWith(id, m) { - ops.push({ op: "replace-with", id, m }); - }, - replacePlaceholder(path, m) { - ops.push({ op: "replace-placeholder", path: Array.from(path), m }); - }, - insertAfter(id, m) { - ops.push({ op: "insert-after", id, m }); - }, - insertBefore(id, m) { - ops.push({ op: "insert-before", id, m }); - }, - setAttributeText(id, name, ns, value) { - ops.push({ op: "set-attribute", id, name, ns, value: { kind: "text", s: value } }); - }, - setAttributeFloat(id, name, ns, value) { - ops.push({ op: "set-attribute", id, name, ns, value: { kind: "float", f: value } }); - }, - setAttributeInt(id, name, ns, value) { - ops.push({ op: "set-attribute", id, name, ns, value: { kind: "int", i: value.toString() } }); - }, - setAttributeBool(id, name, ns, value) { - ops.push({ op: "set-attribute", id, name, ns, value: { kind: "bool", b: value } }); - }, - setAttributeNone(id, name, ns) { - ops.push({ op: "set-attribute", id, name, ns, value: { kind: "none" } }); - }, - setText(id, text) { - ops.push({ op: "set-text", id, text }); - }, - newEventListener(id, name, bubbles) { - ops.push({ op: "new-event-listener", id, name, bubbles }); - }, - removeEventListener(id, name, bubbles) { - ops.push({ op: "remove-event-listener", id, name, bubbles }); - }, - remove(id) { - ops.push({ op: "remove", id }); - }, - pushRoot(id) { - ops.push({ op: "push-root", id }); - }, - }; -} - -// -- byte-encoding helpers for hand-built test frames ----------------------- - -class Writer { - chunks: number[] = []; - u8(v: number) { - this.chunks.push(v & 0xff); - } - u16(v: number) { - this.u8(v & 0xff); - this.u8((v >>> 8) & 0xff); - } - u32(v: number) { - this.u16(v & 0xffff); - this.u16((v >>> 16) & 0xffff); - } - s64(v: bigint) { - const buf = new ArrayBuffer(8); - new DataView(buf).setBigInt64(0, v, true); - this.chunks.push(...new Uint8Array(buf)); - } - f64(v: number) { - const buf = new ArrayBuffer(8); - new DataView(buf).setFloat64(0, v, true); - this.chunks.push(...new Uint8Array(buf)); - } - strref(v: number | null) { - this.u16(v === null ? 0xffff : v); - } - path(p: number[]) { - this.u8(p.length); - for (const x of p) this.u8(x); - } - bytes(): Uint8Array { - return new Uint8Array(this.chunks); - } -} - -// dynstr writer: appends UTF-8 bytes to the shared string-segment builder -// and writes the UTF-16 code-unit length (+ 0xffff escape + u32) into the op -// writer, per wit's dynstr encoding. -class StringSeg { - parts: string[] = []; - push(w: Writer, s: string) { - const len = s.length; // UTF-16 code units - if (len < 0xffff) { - w.u16(len); - } else { - w.u16(0xffff); - w.u32(len); - } - this.parts.push(s); - } - segment(): string { - return this.parts.join(""); - } -} - -function buildFrame(opsBytes: Uint8Array, strings: string): Uint8Array { - const stringBytes = new TextEncoder().encode(strings); - const frameLen = 4 + stringBytes.byteLength + opsBytes.byteLength; - const out = new Uint8Array(4 + frameLen); - const dv = new DataView(out.buffer); - dv.setUint32(0, frameLen, true); - dv.setUint32(4, stringBytes.byteLength, true); - out.set(stringBytes, 8); - out.set(opsBytes, 8 + stringBytes.byteLength); - return out; -} - -/** Hand-encode: cache-string(0,"x") ; create-text-node(1,"hi") ; - * set-attribute(1, name=0, ns=none, float=1.5) ; push-root(1). */ -function handEncodedOpsAndStrings(): { ops: Uint8Array; strings: string } { - const w = new Writer(); - const ss = new StringSeg(); - - w.u8(0x01); // cache-string - w.u16(0); - ss.push(w, "x"); - - w.u8(0x06); // create-text-node - w.u32(1); - ss.push(w, "hi"); - - w.u8(0x0c); // set-attribute - w.u32(1); - w.u16(0); - w.strref(null); - w.u8(0x01); // float - w.f64(1.5); - - w.u8(0x11); // push-root - w.u32(1); - - return { ops: w.bytes(), strings: ss.segment() }; -} - -Deno.test("decodeBatch: hand-encoded multi-op batch", () => { - const { ops, strings } = handEncodedOpsAndStrings(); - const recorded: unknown[] = []; - decodeBatch(ops, strings, recordingSink(recorded)); - assertEquals(recorded, [ - { op: "cache-string", id: 0, s: "x" }, - { op: "create-text-node", id: 1, text: "hi" }, - { op: "set-attribute", id: 1, name: 0, ns: null, value: { kind: "float", f: 1.5 } }, - { op: "push-root", id: 1 }, - ]); -}); - -Deno.test("FrameDecoder: split at every byte boundary matches unsplit decode", () => { - const { ops: ops1, strings: strings1 } = handEncodedOpsAndStrings(); - const frame1 = buildFrame(ops1, strings1); - - const w2 = new Writer(); - const ss2 = new StringSeg(); - w2.u8(0x01); - w2.u16(1); - ss2.push(w2, "y"); - w2.u8(0x10); // remove - w2.u32(1); - const frame2 = buildFrame(w2.bytes(), ss2.segment()); - - const combined = new Uint8Array(frame1.byteLength + frame2.byteLength); - combined.set(frame1, 0); - combined.set(frame2, frame1.byteLength); - - // unsplit baseline - const baseline: unknown[] = []; - { - const dec = new FrameDecoder(recordingSink(baseline)); - const consumed = dec.feed(combined); - assertEquals(consumed, combined.byteLength); - assertEquals(dec.pending(), 0); - } - - // split at every byte boundary - for (let cut = 0; cut <= combined.byteLength; cut++) { - const part1 = combined.subarray(0, cut); - const part2 = combined.subarray(cut); - const recorded: unknown[] = []; - const dec = new FrameDecoder(recordingSink(recorded)); - - const c1 = dec.feed(part1); - if (c1 < part1.byteLength) { - dec.stashRest(part1, c1); - } - const c2 = dec.feed(part2); - if (c2 < part2.byteLength) { - dec.stashRest(part2, c2); - } - - assertEquals(recorded, baseline, `mismatch at cut=${cut}`); - assertEquals(dec.pending(), 0, `pending leftover at cut=${cut}`); - } -}); - -Deno.test("dynstr: 0xffff length escape, empty string, surrogate pair, leading BOM", () => { - const w = new Writer(); - const ss = new StringSeg(); - - // Long ASCII string forcing the 0xffff escape (>= 0xffff UTF-16 units). - const long = "a".repeat(0xffff + 5); - w.u8(0x06); - w.u32(100); - ss.push(w, long); - - // Empty string. - w.u8(0x06); - w.u32(101); - ss.push(w, ""); - - // Surrogate pair (U+1F600 GRINNING FACE = 2 UTF-16 code units). - const emoji = "\u{1F600}"; - w.u8(0x06); - w.u32(102); - ss.push(w, emoji); - - // Leading U+FEFF must survive (ignoreBOM in FrameDecoder's TextDecoder). - const bomStr = "\uFEFFhello"; - w.u8(0x06); - w.u32(103); - ss.push(w, bomStr); - - const frame = buildFrame(w.bytes(), ss.segment()); - const recorded: unknown[] = []; - const dec = new FrameDecoder(recordingSink(recorded)); - const consumed = dec.feed(frame); - assertEquals(consumed, frame.byteLength); - - assertEquals(recorded, [ - { op: "create-text-node", id: 100, text: long }, - { op: "create-text-node", id: 101, text: "" }, - { op: "create-text-node", id: 102, text: emoji }, - { op: "create-text-node", id: 103, text: bomStr }, - ]); -}); - -// -- golden vectors ---------------------------------------------------------- - -const VECTORS_DIR = new URL("../../vectors/", import.meta.url); - -interface ExpectedVector { - frames: Record[][]; -} - -async function listVectorNames(): Promise { - const names: string[] = []; - try { - for await (const entry of Deno.readDir(VECTORS_DIR)) { - if (entry.isFile && entry.name.endsWith(".bin")) { - names.push(entry.name.slice(0, -".bin".length)); - } - } - } catch (e) { - if (!(e instanceof Deno.errors.NotFound)) throw e; - } - return names.sort(); -} - -function normalizeAttrValue(v: unknown): unknown { - const val = v as { kind: string; i?: unknown }; - if (val.kind === "int") { - return { kind: "int", i: BigInt(val.i as string | number).toString() }; - } - return val; -} - -function normalizeOp(op: Record): Record { - if (op.op === "set-attribute" && op.value) { - return { ...op, value: normalizeAttrValue(op.value) }; - } - return op; -} - -const vectorNames = await listVectorNames(); - -if (vectorNames.length === 0) { - Deno.test("golden vectors pending", { ignore: true }, () => {}); -} else { - for (const name of vectorNames) { - Deno.test(`golden vector: ${name}`, async () => { - const bin = await Deno.readFile(new URL(`${name}.bin`, VECTORS_DIR)); - const expectedText = await Deno.readTextFile(new URL(`${name}.expected.json`, VECTORS_DIR)); - const expected: ExpectedVector = JSON.parse(expectedText); - - const framesRecorded: unknown[][] = []; - let off = 0; - while (off < bin.byteLength) { - // Each expected "frame" entry is one wire frame's ops; parse the - // frame-len header ourselves so a single feed() call (which - // consumes as many WHOLE frames as are available) doesn't merge - // multiple wire frames into one recorded frame. - const dv = new DataView(bin.buffer, bin.byteOffset + off, bin.byteLength - off); - const frameLen = dv.getUint32(0, true); - const total = 4 + frameLen; - const frameBytes = bin.subarray(off, off + total); - - const recorded: unknown[] = []; - const frameSink = recordingSink(recorded); - const frameDec = new FrameDecoder(frameSink); - const consumed = frameDec.feed(frameBytes); - if (consumed !== frameBytes.byteLength) { - throw new Error( - `golden vector ${name}: expected to consume exactly one frame (${frameBytes.byteLength}B) at offset ${off}, consumed ${consumed}`, - ); - } - framesRecorded.push(recorded); - off += total; - } - - const actualFrames = framesRecorded.map((ops) => - ops.map((o) => normalizeOp(o as Record)) - ); - const expectedFrames = expected.frames.map((ops) => - ops.map((o) => normalizeOp(o)) - ); - assertEquals(actualFrames, expectedFrames); - }); - } -} diff --git a/host/tests/fullstack_test.ts b/host/tests/fullstack_test.ts index 69e7efb..27dd948 100644 --- a/host/tests/fullstack_test.ts +++ b/host/tests/fullstack_test.ts @@ -2,7 +2,6 @@ // real translator+runtime instantiation path, and exercises the round trip // described in fixtures/surface-probe/src/lib.rs's module doc (op sequence + // event summary format — the authority for the exact assertions below). -// // Requires `just fixtures` to have built // fixtures/build/surface-probe.component.wasm first. @@ -75,10 +74,6 @@ Deno.test("fullstack (stream transport): mount, event round trip, ordering", asy assertEquals(root.innerHTML, EXPECTED_INITIAL_HTML); assertEquals(errors, []); - // 5) The direct-read path decoded the whole delivered view into complete - // frames — nothing staged. - assertEquals(mounted.frameDecoder.pending(), 0); - const section = root.firstElementChild!; assertEquals(section.tagName, "SECTION"); const input = root.lastElementChild!; @@ -101,7 +96,6 @@ Deno.test("fullstack (stream transport): mount, event round trip, ordering", asy assertEquals(root.innerHTML.includes("click:mouse:7,42"), true); assertEquals(prevented, 1, "prevent-default called (buttons === 7)"); assertEquals(stopped, 1, "stop-propagation called (buttons === 7)"); - assertEquals(mounted.frameDecoder.pending(), 0); // 4a) Dispatch input with a value: assert echo. const inputEvent = { type: "input", value: "hi there" }; @@ -159,7 +153,6 @@ Deno.test("fullstack (stream transport): mount, event round trip, ordering", asy mounted.dispatch(section, "touchstart", touchStartEvent); await waitFor(() => root.innerHTML.includes(EXPECTED_TOUCH_SUMMARY), "touchstart summary"); assertEquals(root.innerHTML.includes(EXPECTED_TOUCH_SUMMARY), true); - assertEquals(mounted.frameDecoder.pending(), 0); assertEquals(errors, [], "no onError callback ever fired"); diff --git a/host/tests/typed_test.ts b/host/tests/operations_test.ts similarity index 61% rename from host/tests/typed_test.ts rename to host/tests/operations_test.ts index 26483cc..354861d 100644 --- a/host/tests/typed_test.ts +++ b/host/tests/operations_test.ts @@ -1,21 +1,26 @@ -// Equivalence test: the byte channel (`run`/decodeBatch) and the typed -// channel (`run-typed`/applyTyped) must produce identical DOM output for -// the same interaction sequence against the same component. A benchmark of -// a wrong implementation is worthless, so this is the load-bearing test for -// the typed track. +// Mounts the real counter example (examples/counter/src/lib.rs) and asserts +// the resulting `innerHTML` against expected literals at each step of a +// fixed interaction sequence, then unit-tests `applyOperations`'s riskier +// corners directly against a recording sink. // -// Uses the counter example (host/tests/counter_test.ts's mount/dispatch/ -// poll pattern) — it exercises templates (including nested children), -// dynamic text, several attribute value kinds, event listeners, and keyed -// list insert/remove. +// AUTHORITY FOR THE EXPECTED LITERALS BELOW: captured from THIS repo's own +// mountApp output before the byte mutation channel (`run`/decodeBatch) was +// deleted, at a point where host/tests/typed_test.ts's differential test +// ("typed channel matches byte channel") was green — i.e. the byte and +// typed channels were proven to produce byte-for-byte identical DOM for +// this exact sequence. That equivalence makes the captured output a +// trustworthy oracle: this test pins today's (post-deletion) output to +// what was, at capture time, independently cross-checked against the other +// implementation. There is no live second implementation to diff against +// any more (deleting one side of a differential test removes its ability +// to prove anything), so an absolute assertion is what replaces it. import { assertEquals, assertNotEquals } from "jsr:@std/assert@1"; import { parseHTML } from "linkedom"; import { defaultTranslator } from "@deltic/translator"; import { mountApp } from "../src/host.ts"; -import type { Mounted } from "../src/host.ts"; -import { applyTyped } from "../src/typed.ts"; -import type { OpSink, TemplateNodeDesc } from "../src/decoder.ts"; +import { applyOperations } from "../src/operations.ts"; +import type { OpSink, TemplateNodeDesc } from "../src/applier.ts"; const COMPONENT_PATH = "../../examples/build/counter.component.wasm"; @@ -75,7 +80,39 @@ function click(): TrackedEvent { }; } -async function mountOn(channel: "bytes" | "typed"): Promise<{ root: Element; mounted: Mounted; errors: unknown[] }> { +const EXPECTED_INITIAL = + '
' + + '0' + + '

count is 0

' + + '

' + + '' + + '
  • alpha
  • beta
' + + '
' + + '

submitted 0 time(s)

'; + +const EXPECTED_AFTER_INC1 = EXPECTED_INITIAL + .replace('0', '1') + .replace('class="even" id="parity">count is 0<', 'class="odd" id="parity">count is 1<'); + +const EXPECTED_AFTER_INC2 = EXPECTED_INITIAL + .replace('0', '2') + .replace('class="even" id="parity">count is 0<', 'class="even" id="parity">count is 2<'); + +const EXPECTED_AFTER_DEC = EXPECTED_AFTER_INC1; // 2 -> 1: back to the same rendered string as after the first inc + +const EXPECTED_AFTER_INC3 = EXPECTED_AFTER_INC2; // 1 -> 2: same rendered string as after the second inc + +const EXPECTED_AFTER_INPUT = EXPECTED_AFTER_INC3 + .replace('', '') + .replace('

', '

hello

'); + +const EXPECTED_AFTER_LIST = EXPECTED_AFTER_INPUT + .replace('
  • alpha
  • beta
', '
  • alpha
  • beta
  • item-0
'); + +const EXPECTED_AFTER_SUBMIT = EXPECTED_AFTER_LIST + .replace('

submitted 0 time(s)

', '

submitted 1 time(s)

'); + +Deno.test("mountApp + applyOperations: counter example, full interaction sequence, absolute DOM assertions", async () => { const root = makeRoot(); const componentBytes = await loadComponentBytes(); const translator = await defaultTranslator(); @@ -83,85 +120,63 @@ async function mountOn(channel: "bytes" | "typed"): Promise<{ root: Element; mou const mounted = await mountApp({ source: { componentBytes, translator }, root, - channel, onError: (err) => errors.push(err), }); - await waitFor(() => root.querySelector("#count") !== null, `${channel}: initial mount`); - return { root, mounted, errors }; -} - -Deno.test("typed channel matches byte channel: counter example, full interaction sequence", async () => { - const bytes = await mountOn("bytes"); - const typed = await mountOn("typed"); - - function assertSame(step: string) { - assertEquals(typed.root.innerHTML, bytes.root.innerHTML, `DOM mismatch after ${step}`); - } - - assertSame("initial mount"); - assertEquals(bytes.errors, []); - assertEquals(typed.errors, []); + await waitFor(() => root.querySelector("#count") !== null, "initial mount"); + assertEquals(root.innerHTML, EXPECTED_INITIAL, "initial mount"); + assertEquals(errors, []); // +/- buttons: set-text + set-attribute (class toggling even/odd). + const steps: [string, string][] = [ + ["inc", EXPECTED_AFTER_INC1], + ["inc", EXPECTED_AFTER_INC2], + ["dec", EXPECTED_AFTER_DEC], + ["inc", EXPECTED_AFTER_INC3], + ]; let expectedCount = 0; - for (const step of ["inc", "inc", "dec", "inc"]) { + for (const [step, expected] of steps) { expectedCount += step === "inc" ? 1 : -1; const want = String(expectedCount); - bytes.mounted.dispatch(byId(bytes.root, step), "click", click()); - typed.mounted.dispatch(byId(typed.root, step), "click", click()); - await waitFor(() => byId(bytes.root, "count").textContent === want, `${step} (bytes)`); - await waitFor(() => byId(typed.root, "count").textContent === want, `${step} (typed)`); - assertSame(`click ${step}`); + mounted.dispatch(byId(root, step), "click", click()); + await waitFor(() => byId(root, "count").textContent === want, `click ${step}`); + assertEquals(root.innerHTML, expected, `after click ${step}`); } // Typed text input: attribute of "text" kind (value) plus dynamic text. - bytes.mounted.dispatch(byId(bytes.root, "draft"), "input", { type: "input", value: "hello" }); - typed.mounted.dispatch(byId(typed.root, "draft"), "input", { type: "input", value: "hello" }); - await waitFor(() => byId(bytes.root, "echo").textContent === "hello", "input (bytes)"); - await waitFor(() => byId(typed.root, "echo").textContent === "hello", "input (typed)"); - assertSame("typed input"); + mounted.dispatch(byId(root, "draft"), "input", { type: "input", value: "hello" }); + await waitFor(() => byId(root, "echo").textContent === "hello", "input"); + assertEquals(root.innerHTML, EXPECTED_AFTER_INPUT, "after typed input"); // List add/add/remove: keyed diff exercises load-template (nested // template children: li > text) / assign-id / replace-placeholder / // remove. for (const step of ["add", "add", "remove"]) { - bytes.mounted.dispatch(byId(bytes.root, step), "click", click()); - typed.mounted.dispatch(byId(typed.root, step), "click", click()); + mounted.dispatch(byId(root, step), "click", click()); } - await waitFor( - () => bytes.root.querySelectorAll("#items li").length === 3, - "list settle (bytes)", - ); - await waitFor( - () => typed.root.querySelectorAll("#items li").length === 3, - "list settle (typed)", - ); - assertSame("list add/add/remove"); + await waitFor(() => root.querySelectorAll("#items li").length === 3, "list settle"); + assertEquals(root.innerHTML, EXPECTED_AFTER_LIST, "after list add/add/remove"); - // Form submit: onsubmit calls prevent_default(); assert the DOM (submitted - // counter text) stays identical across channels. - bytes.mounted.dispatch(byId(bytes.root, "form"), "submit", { ...click(), type: "submit" }); - typed.mounted.dispatch(byId(typed.root, "form"), "submit", { ...click(), type: "submit" }); - await waitFor(() => byId(bytes.root, "submitted").textContent === "submitted 1 time(s)", "submit (bytes)"); - await waitFor(() => byId(typed.root, "submitted").textContent === "submitted 1 time(s)", "submit (typed)"); - assertSame("form submit"); + // Form submit: onsubmit calls prevent_default(); assert the DOM + // (submitted counter text) matches. + mounted.dispatch(byId(root, "form"), "submit", { ...click(), type: "submit" }); + await waitFor(() => byId(root, "submitted").textContent === "submitted 1 time(s)", "submit"); + assertEquals(root.innerHTML, EXPECTED_AFTER_SUBMIT, "after form submit"); - assertEquals(bytes.errors, [], "no onError on the byte channel"); - assertEquals(typed.errors, [], "no onError on the typed channel"); + assertEquals(errors, [], "no onError on the mutation channel"); - bytes.mounted.dispose(); - typed.mounted.dispose(); + mounted.dispose(); }); -// -- typed-path risk areas: unit tests directly against applyTyped -------- +// -- applyOperations risk areas: unit tests directly against a recording -- +// OpSink ------------------------------------------------------------------- // // The counter example above never happens to emit a template with more // than one level of nesting under `register-template` per templates // batch, nor a non-text `attr-value` case (dioxus's own attribute encoding // only uses `text` for string-interpolated attrs, which is everything -// counter has) — so those two typed-path-specific risks (the arena -// rehydration walk; the four non-text attr-value cases) are exercised -// here directly against a recording OpSink, independent of any guest. +// counter has) — so those two risks (the arena rehydration walk; the four +// non-text attr-value cases) are exercised here directly against a +// recording OpSink, independent of any guest. function recordingSink(ops: unknown[]): OpSink { return { @@ -231,7 +246,7 @@ function recordingSink(ops: unknown[]): OpSink { }; } -Deno.test("applyTyped: register-template arena rehydrates nested children into a tree", () => { +Deno.test("applyOperations: register-template arena rehydrates nested children into a tree", () => { const ops: unknown[] = []; const sink = recordingSink(ops); @@ -248,7 +263,7 @@ Deno.test("applyTyped: register-template arena rehydrates nested children into a { kind: "dynamic" }, { kind: "text", value: "text1" }, ]; - applyTyped([{ kind: "register-template", value: { id: 7, nodes, roots: [0] } }] as never, sink); + applyOperations([{ kind: "register-template", value: { id: 7, nodes, roots: [0] } }] as never, sink); assertEquals(ops.length, 1); const roots = (ops[0] as { roots: TemplateNodeDesc[] }).roots; @@ -273,11 +288,11 @@ Deno.test("applyTyped: register-template arena rehydrates nested children into a ]); }); -Deno.test("applyTyped: register-template rejects an out-of-range arena index", () => { +Deno.test("applyOperations: register-template rejects an out-of-range arena index", () => { const nodes = [{ kind: "element", value: { tag: 0, attrs: [], children: [99] } }]; let threw = false; try { - applyTyped( + applyOperations( [{ kind: "register-template", value: { id: 0, nodes, roots: [0] } }] as never, recordingSink([]), ); @@ -288,12 +303,12 @@ Deno.test("applyTyped: register-template rejects an out-of-range arena index", ( assertEquals(threw, true, "expected a thrown Error, not a silently wrong tree"); }); -Deno.test("applyTyped: register-template rejects a cyclic arena", () => { +Deno.test("applyOperations: register-template rejects a cyclic arena", () => { // nodes[0].children includes 0 itself. const nodes = [{ kind: "element", value: { tag: 0, attrs: [], children: [0] } }]; let threw = false; try { - applyTyped( + applyOperations( [{ kind: "register-template", value: { id: 0, nodes, roots: [0] } }] as never, recordingSink([]), ); @@ -304,11 +319,11 @@ Deno.test("applyTyped: register-template rejects a cyclic arena", () => { assertEquals(threw, true, "expected a thrown Error, not a hang"); }); -Deno.test("applyTyped: set-attribute's non-text attr-value cases survive to the sink", () => { +Deno.test("applyOperations: set-attribute's non-text attr-value cases survive to the sink", () => { const ops: unknown[] = []; const sink = recordingSink(ops); - applyTyped( + applyOperations( [ { kind: "set-attribute", value: { id: 1, name: 2, value: { kind: "float", value: 1.5 } } }, { kind: "set-attribute", value: { id: 1, name: 3, value: { kind: "int", value: 42n } } }, @@ -328,10 +343,10 @@ Deno.test("applyTyped: set-attribute's non-text attr-value cases survive to the assertEquals(typeof (ops[1] as { value: unknown }).value, "bigint"); }); -Deno.test("applyTyped: option ns absent lifts to null, not {kind:'none'}", () => { +Deno.test("applyOperations: option ns absent lifts to null, not {kind:'none'}", () => { const ops: unknown[] = []; const sink = recordingSink(ops); - applyTyped( + applyOperations( [{ kind: "set-attribute", value: { id: 1, name: 2, value: { kind: "text", value: "v" } } }] as never, sink, ); @@ -341,20 +356,19 @@ Deno.test("applyTyped: option ns absent lifts to null, not {kind:'none' // -- F12 coverage gaps ------------------------------------------------------ // -// The counter example's full-stack equivalence test above never exercises -// these shapes (its listeners are all bubbling, its templates are -// single-root with no namespaced attrs, and its list never empties to a -// placeholder) — cheap unit cases against the recording sink instead of new -// integration mounts. +// The counter example's full-stack test above never exercises these shapes +// (its listeners are all bubbling, its templates are single-root with no +// namespaced attrs, and its list never empties to a placeholder) — cheap +// unit cases against the recording sink instead of new integration mounts. -Deno.test("applyTyped: new-event-listener/remove-event-listener carry bubbles verbatim, both values", () => { +Deno.test("applyOperations: new-event-listener/remove-event-listener carry bubbles verbatim, both values", () => { // This matters more than most: an inverted `bubbles` bit would NOT show // up in an innerHTML diff at all (it only changes the host's listener // delegation strategy — root-delegated vs per-element — not the // markup), so this has to be asserted directly against the sink. const ops: unknown[] = []; const sink = recordingSink(ops); - applyTyped( + applyOperations( [ { kind: "new-event-listener", value: { id: 1, name: 10, bubbles: true } }, { kind: "new-event-listener", value: { id: 2, name: 11, bubbles: false } }, @@ -371,12 +385,12 @@ Deno.test("applyTyped: new-event-listener/remove-event-listener carry bubbles ve ]); }); -Deno.test("applyTyped: option ns PRESENT lifts to the bare id, on set-attribute and template attrs", () => { +Deno.test("applyOperations: option ns PRESENT lifts to the bare id, on set-attribute and template attrs", () => { const ops: unknown[] = []; const sink = recordingSink(ops); // set-attribute path. - applyTyped( + applyOperations( [{ kind: "set-attribute", value: { id: 1, name: 2, ns: 9, value: { kind: "text", value: "v" } } }] as never, sink, ); @@ -388,12 +402,12 @@ Deno.test("applyTyped: option ns PRESENT lifts to the bare id, on set-a const nodes = [ { kind: "element", value: { tag: 0, ns: 9, attrs: [{ name: 3, ns: 9, value: "v" }], children: [] } }, ]; - applyTyped([{ kind: "register-template", value: { id: 0, nodes, roots: [0] } }] as never, sink); + applyOperations([{ kind: "register-template", value: { id: 0, nodes, roots: [0] } }] as never, sink); const roots = (ops[0] as { roots: TemplateNodeDesc[] }).roots; assertEquals(roots, [{ kind: "element", tag: 0, ns: 9, attrs: [{ name: 3, ns: 9, value: "v" }], children: [] }]); }); -Deno.test("applyTyped: register-template with multiple roots indexes each root correctly", () => { +Deno.test("applyOperations: register-template with multiple roots indexes each root correctly", () => { // Arena for two roots: root0 = text "a", root1 = element with a child. // nodes[0] = text "a" (root 0) // nodes[1] = element, children = [2] (root 1) @@ -405,7 +419,7 @@ Deno.test("applyTyped: register-template with multiple roots indexes each root c { kind: "element", value: { tag: 0, attrs: [], children: [2] } }, { kind: "text", value: "b" }, ]; - applyTyped([{ kind: "register-template", value: { id: 0, nodes, roots: [0, 1] } }] as never, sink); + applyOperations([{ kind: "register-template", value: { id: 0, nodes, roots: [0, 1] } }] as never, sink); const roots = (ops[0] as { roots: TemplateNodeDesc[] }).roots; assertEquals(roots, [ @@ -420,9 +434,9 @@ Deno.test("applyTyped: register-template with multiple roots indexes each root c ]); }); -Deno.test("applyTyped: create-placeholder reaches the sink with the bare element id", () => { +Deno.test("applyOperations: create-placeholder reaches the sink with the bare element id", () => { const ops: unknown[] = []; const sink = recordingSink(ops); - applyTyped([{ kind: "create-placeholder", value: 4 }] as never, sink); + applyOperations([{ kind: "create-placeholder", value: 4 }] as never, sink); assertEquals(ops, [{ op: "create-placeholder", id: 4 }]); }); diff --git a/justfile b/justfile index 273d011..d954db5 100644 --- a/justfile +++ b/justfile @@ -82,12 +82,6 @@ example name: examples/build/{{name}}.component.wasm \ -o examples/build/{{name}}.plan.json -# Regenerate golden vectors (runs the Rust generator, then verifies the TS -# decoder agrees). -vectors: - cargo test --test vectors -- --ignored generate - deno task test - # Real-browser (Chromium via Playwright) E2E lane for the counter example. # First run: `cd e2e && npm install && npx playwright install chromium --with-deps`. # GitHub-Pages-ready static site for the TodoMVC example, assembled flat diff --git a/src/driver.rs b/src/driver.rs index d7517d4..88e0bb5 100644 --- a/src/driver.rs +++ b/src/driver.rs @@ -23,8 +23,8 @@ //! //! # Why `run` returns immediately and spawns the scheduler //! -//! `run` is `async func() -> stream`: the host awaits its promise to -//! obtain the read end, then parks a `readDirect` session on it. Under the +//! `run` is `async func() -> stream`: the host awaits its promise +//! to obtain the read end, then reads batches from it. Under the //! component-model async ABI, an async export's Rust body *returning* is //! task.return followed by task exit — so the scheduler cannot live in //! `run`'s own body. Instead `run` creates the stream, stashes the writer @@ -34,46 +34,40 @@ //! //! Ordering is safe by rendezvous: the spawned task's first `write` parks //! until the host actually reads, so nothing is lost if it runs before the -//! host has its session parked. The converse would deadlock, which is why -//! `run`'s own body must not write anything before returning the reader — -//! that write would park while the host is still awaiting `run`'s promise -//! for the reader it needs in order to read. +//! host is reading. The converse would deadlock, which is why `run`'s own +//! body must not write anything before returning the reader — that write +//! would park while the host is still awaiting `run`'s promise for the +//! reader it needs in order to read. //! //! All driver state (`RENDERER`/`RUNTIME`/`INTERNER`/`VDOM`) is installed //! before `run` returns, so a `handle-event` arriving immediately after the //! return finds consistent state. It cannot find a listener yet (no batch //! has been applied), but it will not observe an uninitialized cell. //! +//! # Why the scheduler may park forever +//! +//! The scheduler task's wait between renders is a plain Rust future woken +//! cross-task, with no WIT waitable pending — a state the runtime would +//! otherwise be entitled to call a deadlock. It is legal because the host +//! retains the readable end of the mutation stream for the instance's +//! lifetime, and polyengine's retention rule (#162, +//! `.deps/polyengine/contracts/embedder-api.md` §"Streams and futures") +//! makes "a retained end, a parked host operation, or an unfinished producer +//! pump" each sufficient on its own: a stalled guest is then reported as the +//! documented embedder-may-act hang, never a deadlock trap. The runtime's +//! `HostActivity` (`.deps/polyengine/runtime/src/exec/host_streams.ts`) arms +//! on that retained end and disarms only when the end is lowered back into a +//! guest, which never happens here. +//! //! # How failure surfaces //! //! (Historical: an earlier revision passed the read end to a host import and //! `run`'s promise never settled, so a mount-time failure had nowhere to go //! but that held-open promise.) Now `run`'s promise settles as soon as the //! reader is handed back, and a trap in the spawned scheduler task surfaces -//! as a rejection of the host's parked direct-read session -//! (`PeerTrappedError`) — the channel the host actually watches. A trap -//! during `run`'s own body rejects the host's `await exports.run()`. -//! -//! # The second, typed channel -//! -//! `run-typed` is the same driver against `wit/world.wit`'s `mutations` -//! interface: `stream` instead of `stream`, filled by -//! [`crate::typed::TypedWriter`] instead of [`MutationWriter`]. It exists so -//! the two encodings can be benchmarked against each other. -//! -//! **Exactly one of `run` / `run-typed` is used per instance** — the host -//! picks the channel at mount, per the WIT. The two have separate renderer -//! thread-locals ([`RENDERER`] / [`TYPED_RENDERER`]) and exactly one of them -//! is ever installed; `handle_event` dispatches on which one it finds. That -//! is one check per event, not per op. -//! -//! The typed half is written as a deliberate duplicate of the byte half -//! rather than a generic `Renderer`: the byte path is the measurement -//! baseline for this spike, and not perturbing it is worth more than the -//! shared code. Everything the two do is structurally identical — the same -//! in-flight-write staging, the same reader-gone handling, the same -//! no-borrow-across-an-await discipline — so the two `flush`es and the two -//! `render`s should be read as a pair and kept in step. +//! as a rejection of the host's pending read (`PeerTrappedError`) — the +//! channel the host actually watches. A trap during `run`'s own body rejects +//! the host's `await exports.run()`. use std::cell::RefCell; use std::future::Future; @@ -90,18 +84,13 @@ use crate::bindings::polymorph::dioxus::events::Payload; use crate::bindings::polymorph::dioxus::mutations::Operation; use crate::bindings::{wit_stream, DomEvent}; use crate::events::{WitEventConverter, WitEventData}; -use crate::protocol::Interner; -use crate::typed::TypedWriter; +use crate::interner::Interner; use crate::writer::MutationWriter; /// The read end of the mutation channel: what `run` hands back to the host. /// Named here so [`crate::launch!`] can spell the export's return type /// without the app crate naming wit-bindgen's runtime module. -pub type MutationStream = StreamReader; - -/// The read end of the typed mutation channel: what `run_typed` hands back. -/// Same role as [`MutationStream`], for the `stream` twin. -pub type TypedMutationStream = StreamReader; +pub type MutationStream = StreamReader; /// Everything the flush path needs, shared by the `run` loop and by /// `handle-event`. @@ -110,46 +99,25 @@ struct Renderer { /// Taken for the duration of a write so a second flusher can detect an /// in-flight write instead of panicking on a re-entrant `RefCell` /// borrow. - stream: Option>, - /// Frame bytes staged while another task owns `stream`. The in-flight - /// flusher drains this before giving the writer back, which keeps frames - /// in batch order. - pending: Vec, - /// Reused frame scratch buffer (`Batch::take_frame` appends). - scratch: Vec, - /// Set once the host has dropped the read end of the stream transport - /// (see the reader-gone branch in [`flush`]). Once dead, `flush` drops - /// staged/incoming batches instead of accumulating them in `pending`: - /// this is only reachable at host teardown, when there is no longer a - /// reader to receive anything, so bounded memory beats a slow leak from - /// unboundedly growing `pending` across further flushes. - dead: bool, -} - -/// The typed channel's analogue of [`Renderer`]. Field-for-field the same -/// except that the unit of flow is an `Operation` rather than a byte, so -/// there is no frame scratch buffer: a batch is the writer's `Vec` itself, -/// taken whole. -struct TypedRenderer { - writer: TypedWriter, - /// Taken for the duration of a write, exactly as [`Renderer::stream`]. stream: Option>, /// Operations staged while another task owns `stream`, drained by the /// in-flight flusher before it hands the writer back — keeping batches - /// in order. (Batch boundaries are not preserved across staging, just as - /// the byte channel's staged frames are concatenated; the host applies - /// operations in order and does not depend on where a write ends.) + /// in order. (Batch boundaries are not preserved across staging; the + /// host applies operations in order and does not depend on where a + /// write ends.) pending: Vec, - /// Set once the host has dropped the read end. See [`Renderer::dead`]. + /// Set once the host has dropped the read end of the stream (see the + /// reader-gone branch in [`flush`]). Once dead, `flush` drops + /// staged/incoming batches instead of accumulating them in `pending`: + /// this is only reachable at host teardown, when there is no longer a + /// reader to receive anything, so bounded memory beats a slow leak from + /// unboundedly growing `pending` across further flushes. dead: bool, } thread_local! { static VDOM: RefCell> = const { RefCell::new(None) }; static RENDERER: RefCell> = const { RefCell::new(None) }; - /// The typed channel's renderer. Exactly one of this and [`RENDERER`] is - /// ever installed (`run` xor `run-typed`; see the module doc). - static TYPED_RENDERER: RefCell> = const { RefCell::new(None) }; /// Kept separately from `VDOM` so event dispatch never has to borrow the /// VirtualDom itself (`Runtime::handle_event` only needs the runtime). static RUNTIME: RefCell>> = const { RefCell::new(None) }; @@ -158,97 +126,13 @@ thread_local! { static INTERNER: RefCell>>> = const { RefCell::new(None) }; } -/// Stage the current batch (if non-empty) and push it to the host. +/// Push the current batch (if non-empty) to the host: one batch is one +/// stream write of the whole `Vec`. /// -/// Appends one frame and writes it. `StreamWriter::write_all` returns the -/// values it could *not* write, which happens only once the read end is -/// gone; there is no short-write case to retry because `write_all` already -/// loops. When the host's direct-read session is parked the whole write -/// completes inline, so this await normally does not yield. +/// `StreamWriter::write_all` returns the values it could *not* write, which +/// happens only once the read end is gone; there is no short-write case to +/// retry because `write_all` already loops. async fn flush() { - enum Action { - Nothing, - Stream(StreamWriter, Vec), - /// Another task owns the stream writer; our frame was staged and will - /// be drained by that task in order. - Staged, - } - - let action = RENDERER.with_borrow_mut(|r| { - let r = r.as_mut().expect("driver: renderer not initialized"); - if r.writer.batch.is_empty() { - return Action::Nothing; - } - if r.dead { - // The reader is gone; discard this batch instead of staging it - // into `pending` (which would otherwise grow unboundedly across - // every future flush once the host has torn down its read end). - r.writer.batch.clear(); - return Action::Nothing; - } - r.scratch.clear(); - r.writer.batch.take_frame(&mut r.scratch); - match r.stream.take() { - Some(w) => Action::Stream(w, std::mem::take(&mut r.scratch)), - None => { - r.pending.extend_from_slice(&r.scratch); - Action::Staged - } - } - }); - - match action { - Action::Nothing | Action::Staged => {} - Action::Stream(mut w, mut bytes) => { - loop { - // `write_all` loops internally over partial writes and gives - // back whatever it could not deliver; a non-empty remainder - // means the read end is gone, not a short write to retry. - let leftover = w.write_all(bytes).await; - if !leftover.is_empty() { - // The host dropped the read end: the mutation channel is - // gone and there is nothing useful left to do with this - // writer. - // Mark the renderer dead and drop whatever was staged so - // far — only reachable at host teardown, and bounded - // memory beats a slow leak from `pending` growing on - // every subsequent flush with no reader left to drain it. - RENDERER.with_borrow_mut(|r| { - let r = r.as_mut().unwrap(); - r.dead = true; - r.pending.clear(); - }); - return; - } - // Anything another task staged while we were awaiting goes out - // now, before the writer becomes available again — otherwise - // frames would leave the guest out of batch order. - let staged = RENDERER.with_borrow_mut(|r| { - let r = r.as_mut().unwrap(); - // `leftover` is the drained buffer; hand its capacity back - // to the scratch slot so steady-state flushing does not - // reallocate. - r.scratch = leftover; - std::mem::take(&mut r.pending) - }); - if staged.is_empty() { - RENDERER.with_borrow_mut(|r| r.as_mut().unwrap().stream = Some(w)); - return; - } - bytes = staged; - } - } - } -} - -/// The typed channel's [`flush`]: push the current batch of operations to -/// the host as one `write_all`. -/// -/// One batch = one stream write of the whole `Vec` — that is the -/// property being benchmarked, so it is not chunked here. Staging, -/// ordering, and reader-gone handling are identical to [`flush`]; read the -/// two together. -async fn flush_typed() { enum Action { Nothing, Stream(StreamWriter, Vec), @@ -257,14 +141,14 @@ async fn flush_typed() { Staged, } - let action = TYPED_RENDERER.with_borrow_mut(|r| { - let r = r.as_mut().expect("driver: typed renderer not initialized"); + let action = RENDERER.with_borrow_mut(|r| { + let r = r.as_mut().expect("driver: renderer not initialized"); if r.writer.batch.is_empty() { return Action::Nothing; } if r.dead { // The reader is gone; discard rather than growing `pending` - // unboundedly across every future flush (see `flush`). + // unboundedly across every future flush. r.writer.batch.clear(); return Action::Nothing; } @@ -290,7 +174,14 @@ async fn flush_typed() { // means the read end is gone, not a short write to retry. let leftover = w.write_all(ops).await; if !leftover.is_empty() { - TYPED_RENDERER.with_borrow_mut(|r| { + // The host dropped the read end: the mutation channel is + // gone and there is nothing useful left to do with this + // writer. Mark the renderer dead and drop whatever was + // staged so far — only reachable at host teardown, and + // bounded memory beats a slow leak from `pending` + // growing on every subsequent flush with no reader left + // to drain it. + RENDERER.with_borrow_mut(|r| { let r = r.as_mut().unwrap(); r.dead = true; r.pending.clear(); @@ -300,18 +191,14 @@ async fn flush_typed() { // Anything another task staged while we were awaiting goes // out now, before the writer becomes available again — // otherwise operations would leave the guest out of order. - let staged = TYPED_RENDERER.with_borrow_mut(|r| { + let staged = RENDERER.with_borrow_mut(|r| { let r = r.as_mut().unwrap(); // `leftover` is the drained batch: an empty `Vec` that // kept its capacity. Hand that capacity back to the // writer so steady-state flushing does not regrow the - // batch from zero — the same recycling the byte - // channel's `flush` does with its scratch buffer, and - // required for the A/B to be fair (a batch is tens of - // thousands of operations at bench sizes, so dropping - // the capacity would charge the typed column a dozen - // reallocations per batch that the byte column does not - // pay). + // batch from zero (a batch is tens of thousands of + // operations at bench sizes, so dropping the capacity + // would cost a dozen reallocations per batch). // // Only when the writer has not already started filling // the next batch: another task may have rendered into it @@ -323,7 +210,7 @@ async fn flush_typed() { std::mem::take(&mut r.pending) }); if staged.is_empty() { - TYPED_RENDERER.with_borrow_mut(|r| r.as_mut().unwrap().stream = Some(w)); + RENDERER.with_borrow_mut(|r| r.as_mut().unwrap().stream = Some(w)); return; } ops = staged; @@ -345,6 +232,18 @@ fn wait_for_work() -> impl Future { }) } +/// Run one render step with both thread-locals borrowed, and nothing awaited +/// in between. +fn render(step: impl FnOnce(&mut VirtualDom, &mut MutationWriter)) { + VDOM.with_borrow_mut(|dom| { + RENDERER.with_borrow_mut(|r| { + let dom = dom.as_mut().expect("driver: vdom not initialized"); + let r = r.as_mut().expect("driver: renderer not initialized"); + step(dom, &mut r.writer); + }) + }) +} + /// Implementation of the world's `run` export. /// /// Installs the event converter, builds the VirtualDom, creates the mutation @@ -371,7 +270,6 @@ pub async fn run(root: fn() -> Element) -> MutationStream { writer: MutationWriter::new(interner), stream: Some(writer), pending: Vec::new(), - scratch: Vec::new(), dead: false, })); @@ -379,11 +277,8 @@ pub async fn run(root: fn() -> Element) -> MutationStream { render(|dom, w| dom.rebuild(w)); flush().await; - // The persistent scheduler loop's park (`wait_for_work`: a plain Rust - // future woken cross-task, no WIT waitable) is legal because the host - // retains a parked direct-read session on our ops stream — amendment - // A15 host retention, so a quiescent instance is the documented - // embedder-may-act state. + // The scheduler loop's persistent park is legal because the host + // retains the readable end of this stream — see the module doc. loop { wait_for_work().await; render(|dom, w| dom.render_immediate(w)); @@ -394,77 +289,6 @@ pub async fn run(root: fn() -> Element) -> MutationStream { reader } -/// Run one render step with both thread-locals borrowed, and nothing awaited -/// in between. -fn render(step: impl FnOnce(&mut VirtualDom, &mut MutationWriter)) { - VDOM.with_borrow_mut(|dom| { - RENDERER.with_borrow_mut(|r| { - let dom = dom.as_mut().expect("driver: vdom not initialized"); - let r = r.as_mut().expect("driver: renderer not initialized"); - step(dom, &mut r.writer); - }) - }) -} - -/// The typed channel's [`render`]: one render step with both thread-locals -/// borrowed and nothing awaited in between (same invariant, same reason). -fn render_typed(step: impl FnOnce(&mut VirtualDom, &mut TypedWriter)) { - VDOM.with_borrow_mut(|dom| { - TYPED_RENDERER.with_borrow_mut(|r| { - let dom = dom.as_mut().expect("driver: vdom not initialized"); - let r = r.as_mut().expect("driver: typed renderer not initialized"); - step(dom, &mut r.writer); - }) - }) -} - -/// Implementation of the world's `run-typed` export: the typed twin of -/// [`run`]. -/// -/// Structurally identical to [`run`] — install the converter, build the -/// VirtualDom, install RUNTIME/INTERNER/VDOM and the typed renderer, create -/// the stream, spawn the mount-and-serve task, return the reader — against -/// `stream` instead of `stream`. Everything the module doc -/// says about `run`'s lifecycle (why the scheduler is a spawned task, why -/// nothing may be written before the reader is returned, why the park is -/// legal, how failure surfaces) applies here unchanged. Exactly one of the -/// two is called per instance. -pub async fn run_typed(root: fn() -> Element) -> TypedMutationStream { - // dioxus-html's converter slot is global and write-once per process; a - // component instance is a fresh process image, so this runs exactly once. - dioxus_html::set_event_converter(Box::new(WitEventConverter)); - - let dom = VirtualDom::new(root); - let interner = Rc::new(RefCell::new(Interner::new())); - RUNTIME.set(Some(dom.runtime())); - INTERNER.set(Some(interner.clone())); - VDOM.set(Some(dom)); - - let (writer, reader) = wit_stream::new(); - - // Installed before returning, so a `handle-event` racing the host's very - // first read finds initialized state rather than tripping an `expect`. - TYPED_RENDERER.set(Some(TypedRenderer { - writer: TypedWriter::new(interner), - stream: Some(writer), - pending: Vec::new(), - dead: false, - })); - - spawn_local(async move { - render_typed(|dom, w| dom.rebuild(w)); - flush_typed().await; - - loop { - wait_for_work().await; - render_typed(|dom, w| dom.render_immediate(w)); - flush_typed().await; - } - }); - - reader -} - /// Implementation of the world's `handle-event` export. /// /// Dispatch is synchronous (Dioxus's synthetic bubbling included). Afterwards @@ -501,17 +325,8 @@ pub async fn handle_event(target: u32, name: u16, payload: Payload, ev: &DomEven ev.prevent_default(); } - // Flush on whichever channel this instance mounted. Exactly one of the - // two renderers is installed (`run` xor `run-typed`), and this is the - // only place that has to ask: one check per event, and the byte path - // takes the first branch without touching typed state. - if RENDERER.with_borrow(|r| r.is_some()) { - render(|dom, w| dom.render_immediate(w)); - flush().await; - } else { - render_typed(|dom, w| dom.render_immediate(w)); - flush_typed().await; - } + render(|dom, w| dom.render_immediate(w)); + flush().await; } /// Wire an app crate's root component into the `polymorph:dioxus/app` world. @@ -533,10 +348,6 @@ macro_rules! launch { $crate::driver::run($root).await } - async fn run_typed() -> $crate::driver::TypedMutationStream { - $crate::driver::run_typed($root).await - } - async fn handle_event( target: u32, name: u16, diff --git a/src/interner.rs b/src/interner.rs new file mode 100644 index 0000000..a800239 --- /dev/null +++ b/src/interner.rs @@ -0,0 +1,93 @@ +//! [`Interner`]: the `&'static str` name table shared by the mutation +//! writer and the event-dispatch path. +//! +//! Names (tags, attribute names, event names, namespaces) cross the boundary +//! once as a `cache-string` operation and are referenced by `u16` id +//! thereafter; `handle-event` names arrive back as those same ids, so the +//! reverse lookup lives here too. See the `mutations` interface in +//! `wit/world.wit` for the protocol's side of this. + +use rustc_hash::FxHashMap; + +/// Interns `&'static str` names (tags, attribute/event names, namespaces) by +/// pointer identity, assigning each a `u16` id on first sight of a given +/// pointer+length. +/// +/// Pointer-identity interning (rather than content hashing) is the same +/// keying [`crate::writer::MutationWriter`] uses for template identity, and +/// carries the same tradeoff: two distinct statics with equal contents may +/// get two ids. That's harmless (a few extra `cache-string` operations / +/// interned slots at worst), and avoids hashing string contents on every +/// intern call. +pub struct Interner { + /// Keyed by `(ptr as usize, len)` — the fat-pointer components of the + /// `&'static str`, which uniquely identify a given static allocation. + ids: FxHashMap<(usize, usize), u16>, + /// Reverse map for event dispatch (`resolve`): id -> the original + /// `&'static str`. + names: Vec<&'static str>, +} + +impl Interner { + /// Create an empty interner. + pub fn new() -> Self { + Interner { ids: FxHashMap::default(), names: Vec::new() } + } + + /// Return the interned id for `s`, plus whether *this* call is the one + /// that defined it (i.e. whether the caller owes the wire a + /// `cache-string` operation for it). + /// + /// Panics if more than `u16::MAX + 1` distinct strings are interned: + /// `str-ref` is a `u16` and every value is a legal id (optionality is + /// carried by `option` in the schema, so no id is reserved). + pub fn intern(&mut self, s: &'static str) -> (u16, bool) { + let key = (s.as_ptr() as usize, s.len()); + if let Some(&id) = self.ids.get(&key) { + return (id, false); + } + let next = self.names.len(); + assert!( + next <= u16::MAX as usize, + "interner: interned more than {} strings; str-ref id space (u16) \ + exhausted", + u16::MAX as usize + 1 + ); + let id = next as u16; + self.ids.insert(key, id); + self.names.push(s); + (id, true) + } + + /// Reverse lookup for event dispatch: interned id -> the original + /// `&'static str`, or `None` if `id` was never interned. + pub fn resolve(&self, id: u16) -> Option<&'static str> { + self.names.get(id as usize).copied() + } +} + +impl Default for Interner { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Interning is idempotent per pointer identity, and the reverse lookup + /// `handle-event` depends on round-trips. + #[test] + fn intern_is_idempotent_and_resolves() { + let mut i = Interner::new(); + static DIV: &str = "div"; + let (a, a_new) = i.intern(DIV); + let (b, b_new) = i.intern(DIV); + assert_eq!(a, b); + assert!(a_new, "first sight defines the slot"); + assert!(!b_new, "second sight must not redefine it"); + assert_eq!(i.resolve(a), Some("div")); + assert_eq!(i.resolve(a + 1), None); + } +} diff --git a/src/lib.rs b/src/lib.rs index 8e6f478..86dd759 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,22 +1,18 @@ //! Dioxus renderer for components running on polyengine. //! -//! - [`protocol`]: the batch encoder (op segment + string segment, interned -//! and dynamic strings) matching the wire format documented in -//! `wit/world.wit`. -//! - [`writer`]: the `dioxus_core::WriteMutations` sink that fills a batch. -//! - `bindings` / `driver` / `events` / `typed` (wasm32 only): the generated -//! WIT bindings, the `run`/`run-typed`/`handle-event` implementation, the +//! - [`interner`]: the `&'static str` name table behind the protocol's +//! `cache-string` / `str-ref` interning. +//! - `bindings` / `driver` / `events` / `writer` (wasm32 only): the generated +//! WIT bindings, the `run`/`handle-event` implementation, the //! `HtmlEventConverter` over the WIT payload types, and the -//! `WriteMutations` sink for the typed channel's `stream`. -//! These are gated on `target_arch = "wasm32"` so `cargo test` can -//! exercise the encoder and writer natively. `typed` names the generated -//! bindings and so is not covered by `cargo test`; it is kept obviously -//! parallel to [`writer`] and checked host-side for equivalence. +//! `dioxus_core::WriteMutations` sink that fills a batch of +//! `mutations::operation` values. These are gated on +//! `target_arch = "wasm32"` because they name the generated bindings; +//! [`interner`] does not, so `cargo test` can exercise it natively. //! //! An application crate wires itself up with [`launch!`]. -pub mod protocol; -pub mod writer; +pub mod interner; #[cfg(target_arch = "wasm32")] pub mod bindings; @@ -25,4 +21,4 @@ pub mod driver; #[cfg(target_arch = "wasm32")] pub mod events; #[cfg(target_arch = "wasm32")] -pub mod typed; +pub mod writer; diff --git a/src/protocol.rs b/src/protocol.rs deleted file mode 100644 index c753426..0000000 --- a/src/protocol.rs +++ /dev/null @@ -1,546 +0,0 @@ -//! Batch encoder for the wire format documented in `wit/world.wit`. -//! -//! A [`Batch`] accumulates one op segment and one string segment. The two -//! segments are decoded together host-side: op operands reference dynamic -//! string bytes by UTF-16 code-unit length, consumed sequentially from the -//! string segment (itself one contiguous UTF-8 blob, decoded in a single -//! `TextDecoder` pass). See `wit/world.wit`'s `run` export doc -//! comment for the normative format; this module must match it exactly. - -use rustc_hash::FxHashMap; - -/// `strref` sentinel for "no namespace" (wit/world.wit: "0xffff = none where -/// the operand is optional"). -const STRREF_NONE: u16 = 0xffff; - -/// `dynstr` UTF-16-length escape: a length field of `0xffff` is followed by -/// a u32 actual length (wit/world.wit `dynstr` primitive encoding). -const DYNSTR_ESCAPE: u16 = 0xffff; - -/// Opcodes, `wit/world.wit` "# Opcodes" section. Numbering is normative. -mod op { - pub const CACHE_STRING: u8 = 0x01; - pub const REGISTER_TEMPLATE: u8 = 0x02; - pub const APPEND_CHILDREN: u8 = 0x03; - pub const ASSIGN_ID: u8 = 0x04; - pub const CREATE_PLACEHOLDER: u8 = 0x05; - pub const CREATE_TEXT_NODE: u8 = 0x06; - pub const LOAD_TEMPLATE: u8 = 0x07; - pub const REPLACE_WITH: u8 = 0x08; - pub const REPLACE_PLACEHOLDER: u8 = 0x09; - pub const INSERT_AFTER: u8 = 0x0a; - pub const INSERT_BEFORE: u8 = 0x0b; - pub const SET_ATTRIBUTE: u8 = 0x0c; - pub const SET_TEXT: u8 = 0x0d; - pub const NEW_EVENT_LISTENER: u8 = 0x0e; - pub const REMOVE_EVENT_LISTENER: u8 = 0x0f; - pub const REMOVE: u8 = 0x10; - pub const PUSH_ROOT: u8 = 0x11; -} - -/// `register-template` node kinds, `wit/world.wit` `node := kind:u8 ...`. -mod node_kind { - pub const ELEMENT: u8 = 0x00; - pub const TEXT: u8 = 0x01; - pub const DYNAMIC: u8 = 0x02; -} - -/// `set-attribute`'s `attrval := kind:u8 ...` tags. -mod attrval_kind { - pub const TEXT: u8 = 0x00; - pub const FLOAT: u8 = 0x01; - pub const INT: u8 = 0x02; - pub const BOOL: u8 = 0x03; - pub const NONE: u8 = 0x04; -} - -/// Compute the `dynstr` UTF-16 code-unit length of `s`. -/// -/// Fast path: an all-ASCII string's UTF-16 length equals its UTF-8 byte -/// length. Otherwise sum `char::len_utf16` (wit/world.wit dynstr doc: -/// "Rust: sum of char::len_utf16, with an all-ASCII fast path where it -/// equals the byte length"). -fn utf16_len(s: &str) -> u32 { - if s.is_ascii() { - s.len() as u32 - } else { - s.chars().map(char::len_utf16).sum::() as u32 - } -} - -/// Convert an `Option` strref into its wire encoding (`0xffff` = none). -fn strref(ns: Option) -> u16 { - ns.unwrap_or(STRREF_NONE) -} - -/// One batch of protocol ops: an op segment and the string segment its -/// `dynstr` operands reference. See the module doc and `wit/world.wit` for -/// the wire format. -/// -/// A fresh `Batch` is emptied by `take_frame`, so a single -/// instance can be reused across renders without reallocating buffers each -/// time (only `Vec::clear`, which retains capacity). -pub struct Batch { - ops: Vec, - strings: String, -} - -impl Batch { - /// Create an empty batch. - pub fn new() -> Self { - Batch { ops: Vec::new(), strings: String::new() } - } - - /// True if no ops have been recorded (and hence no strings either). - pub fn is_empty(&self) -> bool { - self.ops.is_empty() - } - - /// Append a `dynstr` operand: UTF-16 length (with the `0xffff` escape - /// for lengths that collide with the sentinel or exceed u16), followed - /// by the string's UTF-8 bytes appended to the string segment. - fn push_dynstr(&mut self, s: &str) { - let len16 = utf16_len(s); - if len16 >= DYNSTR_ESCAPE as u32 { - self.ops.extend_from_slice(&DYNSTR_ESCAPE.to_le_bytes()); - self.ops.extend_from_slice(&len16.to_le_bytes()); - } else { - self.ops.extend_from_slice(&(len16 as u16).to_le_bytes()); - } - self.strings.push_str(s); - } - - fn push_strref(&mut self, id: Option) { - self.ops.extend_from_slice(&strref(id).to_le_bytes()); - } - - fn push_path(&mut self, path: &[u8]) { - assert!( - path.len() <= 255, - "protocol: path length {} exceeds u8 max (255)", - path.len() - ); - self.ops.push(path.len() as u8); - self.ops.extend_from_slice(path); - } - - /// `0x01 cache-string id:u16 s:dynstr` — define (or overwrite) interned - /// slot `id`. Normally driven by [`Interner`], not called directly. - pub fn cache_string(&mut self, id: u16, s: &str) { - self.ops.push(op::CACHE_STRING); - self.ops.extend_from_slice(&id.to_le_bytes()); - self.push_dynstr(s); - } - - /// `0x02 register-template` header: `tmpl:u16 nroots:u16`. The caller - /// must follow with exactly `nroots` calls to the node-emitting methods - /// (`template_element_open` + attrs + children, `template_text`, or - /// `template_dynamic`), matching the recursive `node` grammar in - /// `wit/world.wit`. - pub fn register_template(&mut self, tmpl: u16, nroots: u16) { - self.ops.push(op::REGISTER_TEMPLATE); - self.ops.extend_from_slice(&tmpl.to_le_bytes()); - self.ops.extend_from_slice(&nroots.to_le_bytes()); - } - - /// Emit an element `node` header: `kind=0x00 tag:strref ns:strref - /// nattrs:u16`. The caller must follow with exactly `nattrs` calls to - /// `template_attr`, then one call to `template_element_children`, then - /// that many child nodes. - pub fn template_element_open(&mut self, tag: u16, ns: Option, nattrs: u16) { - self.ops.push(node_kind::ELEMENT); - self.ops.extend_from_slice(&tag.to_le_bytes()); - self.push_strref(ns); - self.ops.extend_from_slice(&nattrs.to_le_bytes()); - } - - /// Emit one static `attr := name:strref ns:strref value:dynstr` inside - /// an open element (must follow `template_element_open`, before - /// `template_element_children`). - pub fn template_attr(&mut self, name: u16, ns: Option, value: &str) { - self.ops.extend_from_slice(&name.to_le_bytes()); - self.push_strref(ns); - self.push_dynstr(value); - } - - /// Declare the child count for the currently-open element: - /// `nchildren:u16`. Must follow all of that element's `template_attr` - /// calls; the caller then emits exactly `nchildren` nodes. - pub fn template_element_children(&mut self, nchildren: u16) { - self.ops.extend_from_slice(&nchildren.to_le_bytes()); - } - - /// Emit a text `node`: `kind=0x01 value:dynstr`. - pub fn template_text(&mut self, value: &str) { - self.ops.push(node_kind::TEXT); - self.push_dynstr(value); - } - - /// Emit a dynamic-placeholder `node`: `kind=0x02` (no operands). - pub fn template_dynamic(&mut self) { - self.ops.push(node_kind::DYNAMIC); - } - - /// `0x03 append-children id m`. - pub fn append_children(&mut self, id: u32, m: u32) { - self.ops.push(op::APPEND_CHILDREN); - self.ops.extend_from_slice(&id.to_le_bytes()); - self.ops.extend_from_slice(&m.to_le_bytes()); - } - - /// `0x04 assign-id path id`. Panics if `path.len() > 255` (path's `u8` - /// length prefix cannot represent more). - pub fn assign_id(&mut self, path: &[u8], id: u32) { - self.ops.push(op::ASSIGN_ID); - self.push_path(path); - self.ops.extend_from_slice(&id.to_le_bytes()); - } - - /// `0x05 create-placeholder id`. - pub fn create_placeholder(&mut self, id: u32) { - self.ops.push(op::CREATE_PLACEHOLDER); - self.ops.extend_from_slice(&id.to_le_bytes()); - } - - /// `0x06 create-text-node id text:dynstr`. - pub fn create_text_node(&mut self, id: u32, text: &str) { - self.ops.push(op::CREATE_TEXT_NODE); - self.ops.extend_from_slice(&id.to_le_bytes()); - self.push_dynstr(text); - } - - /// `0x07 load-template tmpl root-index:u16 id`. - pub fn load_template(&mut self, tmpl: u16, root: u16, id: u32) { - self.ops.push(op::LOAD_TEMPLATE); - self.ops.extend_from_slice(&tmpl.to_le_bytes()); - self.ops.extend_from_slice(&root.to_le_bytes()); - self.ops.extend_from_slice(&id.to_le_bytes()); - } - - /// `0x08 replace-with id m`. - pub fn replace_with(&mut self, id: u32, m: u32) { - self.ops.push(op::REPLACE_WITH); - self.ops.extend_from_slice(&id.to_le_bytes()); - self.ops.extend_from_slice(&m.to_le_bytes()); - } - - /// `0x09 replace-placeholder path m`. Panics if `path.len() > 255`. - pub fn replace_placeholder(&mut self, path: &[u8], m: u32) { - self.ops.push(op::REPLACE_PLACEHOLDER); - self.push_path(path); - self.ops.extend_from_slice(&m.to_le_bytes()); - } - - /// `0x0a insert-after id m`. - pub fn insert_after(&mut self, id: u32, m: u32) { - self.ops.push(op::INSERT_AFTER); - self.ops.extend_from_slice(&id.to_le_bytes()); - self.ops.extend_from_slice(&m.to_le_bytes()); - } - - /// `0x0b insert-before id m`. - pub fn insert_before(&mut self, id: u32, m: u32) { - self.ops.push(op::INSERT_BEFORE); - self.ops.extend_from_slice(&id.to_le_bytes()); - self.ops.extend_from_slice(&m.to_le_bytes()); - } - - fn set_attribute_header(&mut self, id: u32, name: u16, ns: Option) { - self.ops.push(op::SET_ATTRIBUTE); - self.ops.extend_from_slice(&id.to_le_bytes()); - self.ops.extend_from_slice(&name.to_le_bytes()); - self.push_strref(ns); - } - - /// `0x0c set-attribute` with `attrval` kind `0x00 text s:dynstr`. - pub fn set_attribute_text(&mut self, id: u32, name: u16, ns: Option, value: &str) { - self.set_attribute_header(id, name, ns); - self.ops.push(attrval_kind::TEXT); - self.push_dynstr(value); - } - - /// `0x0c set-attribute` with `attrval` kind `0x01 float f64`. - pub fn set_attribute_float(&mut self, id: u32, name: u16, ns: Option, value: f64) { - self.set_attribute_header(id, name, ns); - self.ops.push(attrval_kind::FLOAT); - self.ops.extend_from_slice(&value.to_le_bytes()); - } - - /// `0x0c set-attribute` with `attrval` kind `0x02 int s64`. - pub fn set_attribute_int(&mut self, id: u32, name: u16, ns: Option, value: i64) { - self.set_attribute_header(id, name, ns); - self.ops.push(attrval_kind::INT); - self.ops.extend_from_slice(&value.to_le_bytes()); - } - - /// `0x0c set-attribute` with `attrval` kind `0x03 bool u8`. - pub fn set_attribute_bool(&mut self, id: u32, name: u16, ns: Option, value: bool) { - self.set_attribute_header(id, name, ns); - self.ops.push(attrval_kind::BOOL); - self.ops.push(value as u8); - } - - /// `0x0c set-attribute` with `attrval` kind `0x04 none` (remove the - /// attribute; no value operand). - pub fn set_attribute_none(&mut self, id: u32, name: u16, ns: Option) { - self.set_attribute_header(id, name, ns); - self.ops.push(attrval_kind::NONE); - } - - /// `0x0d set-text id text:dynstr`. - pub fn set_text(&mut self, id: u32, text: &str) { - self.ops.push(op::SET_TEXT); - self.ops.extend_from_slice(&id.to_le_bytes()); - self.push_dynstr(text); - } - - /// `0x0e new-event-listener id name:strref flags:u8` (name is never - /// `none` here — the `strref` sentinel is namespace-only per - /// wit/world.wit). `bubbles` is flags bit0: dioxus-html's event_bubbles - /// verdict, looked up by the caller; the host's delegation strategy - /// (root-delegated vs per-element) hangs off it. - pub fn new_event_listener(&mut self, id: u32, name: u16, bubbles: bool) { - self.ops.push(op::NEW_EVENT_LISTENER); - self.ops.extend_from_slice(&id.to_le_bytes()); - self.ops.extend_from_slice(&name.to_le_bytes()); - self.ops.push(bubbles as u8); - } - - /// `0x0f remove-event-listener id name:strref flags:u8` (same flags as - /// new-event-listener; the host needs the bubbles bit to locate the - /// registration it is removing). - pub fn remove_event_listener(&mut self, id: u32, name: u16, bubbles: bool) { - self.ops.push(op::REMOVE_EVENT_LISTENER); - self.ops.extend_from_slice(&id.to_le_bytes()); - self.ops.extend_from_slice(&name.to_le_bytes()); - self.ops.push(bubbles as u8); - } - - /// `0x10 remove id`. - pub fn remove(&mut self, id: u32) { - self.ops.push(op::REMOVE); - self.ops.extend_from_slice(&id.to_le_bytes()); - } - - /// `0x11 push-root id`. - pub fn push_root(&mut self, id: u32) { - self.ops.push(op::PUSH_ROOT); - self.ops.extend_from_slice(&id.to_le_bytes()); - } - - /// Discard the accumulated batch without encoding anything, retaining - /// buffer capacity. The teardown path's cheap alternative to assembling - /// a frame nobody will read. - /// - /// NOTE: interned-string state lives in [`Interner`], not here, and an - /// interner emits each `cache-string` op exactly once — so ops discarded - /// by this method may include cache-string definitions that will never - /// be re-sent. That is fine on a dead channel (nothing will read future - /// batches either); it is NOT a way to "skip" a batch on a live one. - pub fn clear(&mut self) { - self.ops.clear(); - self.strings.clear(); - } - - /// Append one frame to `out` and clear `self`. - /// - /// Frame layout (`wit/world.wit` "# Framing"): - /// `frame-len:u32 strings-len:u32 strings:u8{strings-len} ops:u8{rest}`, - /// where `frame-len` counts everything *after* the frame-len field - /// itself (`4 + strings-len + len(ops)`). - pub fn take_frame(&mut self, out: &mut Vec) { - let strings_len = self.strings.len() as u32; - let frame_len = 4u32 + strings_len + self.ops.len() as u32; - out.extend_from_slice(&frame_len.to_le_bytes()); - out.extend_from_slice(&strings_len.to_le_bytes()); - out.extend_from_slice(self.strings.as_bytes()); - out.extend_from_slice(&self.ops); - self.ops.clear(); - self.strings.clear(); - } -} - -impl Default for Batch { - fn default() -> Self { - Self::new() - } -} - -/// Interns `&'static str` names (tags, attribute/event names, namespaces) by -/// pointer identity, emitting a `cache-string` op into the batch the first -/// time a given pointer+length is seen. -/// -/// Pointer-identity interning (rather than content hashing) is the fast -/// path documented for `register-template` guest identity ("templates are -/// 'static with unique identity, keyed guest-side by pointer" — -/// wit/world.wit `register-template` doc) and is reused here for all -/// `&'static str` names: two distinct statics with equal contents may get -/// two ids. That's harmless (a few extra cache-string ops / interned slots -/// at worst), and avoids hashing string contents on every intern call. -pub struct Interner { - /// Keyed by `(ptr as usize, len)` — the fat-pointer components of the - /// `&'static str`, which uniquely identify a given static allocation. - ids: FxHashMap<(usize, usize), u16>, - /// Reverse map for event dispatch (`resolve`): id -> the original - /// `&'static str`. - names: Vec<&'static str>, -} - -impl Interner { - /// Create an empty interner. - pub fn new() -> Self { - Interner { ids: FxHashMap::default(), names: Vec::new() } - } - - /// Return the interned id for `s`, plus whether *this* call is the one - /// that defined it (i.e. whether the caller owes the wire a - /// `cache-string` for it). - /// - /// This is the emission-agnostic core of interning: the id space, the - /// pointer-identity keying and the reverse map live here, and each - /// encoder decides how to emit the definition. [`Self::intern`] is the - /// byte encoder's wrapper; `typed::TypedWriter` pushes an - /// `Operation::CacheString` instead. Only one encoder is live per - /// instance (`run` xor `run-typed`, per `wit/world.wit`), so an id is - /// never defined on one channel and referenced on the other. - /// - /// Panics if more than `u16::MAX - 1` distinct strings are interned - /// (id `0xffff` is reserved as the `strref` "none" sentinel, so it must - /// never be assigned). - pub fn intern_raw(&mut self, s: &'static str) -> (u16, bool) { - let key = (s.as_ptr() as usize, s.len()); - if let Some(&id) = self.ids.get(&key) { - return (id, false); - } - let next = self.names.len(); - assert!( - next < STRREF_NONE as usize, - "protocol: interned more than {} strings; id space exhausted \ - (0xffff is reserved as the strref \"none\" sentinel)", - STRREF_NONE - ); - let id = next as u16; - self.ids.insert(key, id); - self.names.push(s); - (id, true) - } - - /// Return the interned id for `s`, emitting `cache-string` into `batch` - /// on first sight of this pointer identity. - /// - /// Panics if more than `u16::MAX - 1` distinct strings are interned - /// (see [`Self::intern_raw`]). - pub fn intern(&mut self, batch: &mut Batch, s: &'static str) -> u16 { - let (id, is_new) = self.intern_raw(s); - if is_new { - batch.cache_string(id, s); - } - id - } - - /// Reverse lookup for event dispatch: interned id -> the original - /// `&'static str`, or `None` if `id` was never interned. - pub fn resolve(&self, id: u16) -> Option<&'static str> { - self.names.get(id as usize).copied() - } -} - -impl Default for Interner { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Parse a `take_frame`-produced buffer back into `(ops, strings)`, for - /// tests that want to inspect the segments directly (the frame is the - /// only segment-extraction API since the call transport's `take_segments` - /// was removed). - fn unframe(frame: &[u8]) -> (Vec, String) { - let strings_len = u32::from_le_bytes(frame[4..8].try_into().unwrap()) as usize; - let strings = String::from_utf8(frame[8..8 + strings_len].to_vec()).unwrap(); - let ops = frame[8 + strings_len..].to_vec(); - (ops, strings) - } - - /// UTF-16 code-unit counting per the `dynstr` doc: ASCII fast path, - /// multi-byte BMP chars (1 code unit each), and surrogate pairs (2 code - /// units for one `char` outside the BMP). - #[test] - fn utf16_len_cases() { - assert_eq!(utf16_len(""), 0); - assert_eq!(utf16_len("hello"), 5); - // "wörld": 'ö' is one BMP char, one UTF-16 code unit, but 2 UTF-8 - // bytes -- exercises the non-ASCII path diverging from byte length. - assert_eq!(utf16_len("wörld"), 5); - // CJK: each char is one BMP code unit (3 UTF-8 bytes each). - assert_eq!(utf16_len("你好世界"), 4); - // Emoji outside the BMP: each is a surrogate pair (2 UTF-16 units, - // 4 UTF-8 bytes). "👍🏽" is thumbs-up + skin-tone modifier, both - // outside the BMP: 2 chars * 2 units = 4. - assert_eq!(utf16_len("👍🏽"), 4); - assert_eq!(utf16_len("👍🏽 emoji"), 4 + 6); - } - - #[test] - fn dynstr_escape_roundtrip_length() { - // A string whose UTF-16 length exceeds u16::MAX-ish must use the - // 0xffff escape followed by a u32 actual length. - let long = "ab£".repeat(30000); // 'a','b' (1 unit each) + '£' (1 unit) = 3 units/rep - let expected_len16 = utf16_len(&long); - assert!(expected_len16 as u32 >= DYNSTR_ESCAPE as u32); - - let mut b = Batch::new(); - b.create_text_node(0, &long); - let mut out = Vec::new(); - b.take_frame(&mut out); - let (ops, strings) = unframe(&out); - // op byte, id:u32, then dynstr: 0xffff u16 + u32 actual len - assert_eq!(ops[0], op::CREATE_TEXT_NODE); - let len16_field = u16::from_le_bytes([ops[5], ops[6]]); - assert_eq!(len16_field, DYNSTR_ESCAPE); - let actual_len = u32::from_le_bytes([ops[7], ops[8], ops[9], ops[10]]); - assert_eq!(actual_len, expected_len16); - assert_eq!(strings, long); - } - - #[test] - fn cache_string_hits_reuse_id() { - let mut batch = Batch::new(); - let mut interner = Interner::new(); - static DIV: &str = "div"; - let a = interner.intern(&mut batch, DIV); - let b = interner.intern(&mut batch, DIV); - assert_eq!(a, b); - assert_eq!(interner.resolve(a), Some("div")); - // Only one cache-string op should have been emitted. - let mut out = Vec::new(); - batch.take_frame(&mut out); - let (ops, _) = unframe(&out); - assert_eq!(ops[0], op::CACHE_STRING); - assert_eq!(ops.iter().filter(|&&b| b == op::CACHE_STRING).count(), 1); - } - - #[test] - #[should_panic(expected = "path length")] - fn assign_id_rejects_long_path() { - let mut b = Batch::new(); - b.assign_id(&[0u8; 256], 0); - } - - #[test] - fn frame_header_matches_layout() { - let mut b = Batch::new(); - b.push_root(42); - let mut out = Vec::new(); - b.take_frame(&mut out); - assert!(b.is_empty()); - let frame_len = u32::from_le_bytes(out[0..4].try_into().unwrap()); - let strings_len = u32::from_le_bytes(out[4..8].try_into().unwrap()); - assert_eq!(strings_len, 0); - assert_eq!(frame_len as usize, out.len() - 4); - } -} diff --git a/src/typed.rs b/src/typed.rs deleted file mode 100644 index 030c763..0000000 --- a/src/typed.rs +++ /dev/null @@ -1,322 +0,0 @@ -//! [`TypedWriter`]: the `run-typed` channel's `dioxus_core::WriteMutations` -//! sink, pushing `mutations::Operation` values into a `Vec` instead of -//! encoding bytes. -//! -//! This is [`crate::writer::MutationWriter`] op-for-op, against the explicit -//! WIT schema in `wit/world.wit`'s `mutations` interface rather than the -//! byte format documented on `run`. The two are deliberately parallel and -//! deliberately not factored together: the byte channel is the benchmark -//! baseline for this spike, so nothing here may perturb it. Read the two -//! side by side — a divergence between them is a bug in this file, and the -//! host-side equivalence test is what catches it (this module names the -//! generated bindings, so it is `wasm32`-only and `cargo test` never builds -//! it). -//! -//! # Interning invariant -//! -//! `writer.rs`'s module doc states the discipline for the byte encoder: a -//! `cache-string` op must never land in the middle of another op's operands, -//! so every name a composite op needs is interned before that op's first -//! byte is written. Here the *mechanics* of the hazard are gone — an -//! `Operation` is a value, and pushing a `CacheString` cannot split one — -//! but the *ordering* requirement is identical and just as binding: the host -//! resolves a `str-ref` against definitions it has already seen, so every -//! `CacheString` must precede the operation referencing it in the batch. -//! This module therefore keeps the same two-pass template walk -//! (`intern_template_node` then `flatten_template_node`) and the same -//! intern-then-build order in `set_attribute` and the listener ops. -//! -//! # Why template registration flattens -//! -//! WIT forbids recursive type definitions, so `register-template` carries an -//! arena (`nodes`, plus `u32` indices in `roots` and each element's -//! `children`) where the byte grammar carries a self-delimiting recursive -//! tree. See the `mutations` interface doc in `wit/world.wit`. - -use std::cell::RefCell; -use std::rc::Rc; - -use dioxus_core::{AttributeValue, ElementId, Template, TemplateAttribute, TemplateNode, WriteMutations}; -use rustc_hash::FxHashMap; - -use crate::bindings::polymorph::dioxus::mutations as m; -use crate::protocol::Interner; - -/// Encodes dioxus mutations as `mutations::Operation` values. -/// -/// The interner is shared (`Rc>`) with the event-dispatch path, -/// which needs the reverse `u16 -> &'static str` lookup to turn a -/// `handle-event` name id back into a dioxus event name — exactly as -/// [`crate::writer::MutationWriter`] does. -pub struct TypedWriter { - /// The batch being filled. The driver drains it with `std::mem::take` - /// once per flush and writes the whole `Vec` in one `write_all`. The - /// operations are moved into the write, but the `Vec` itself comes back - /// from `write_all` emptied with its capacity intact, and the driver - /// hands that capacity back here — so a steady-state batch reuses one - /// allocation, exactly as the byte channel reuses its frame scratch - /// buffer. See `driver::flush_typed`. - pub batch: Vec, - interner: Rc>, - /// Guest-assigned template ids, keyed exactly as - /// [`crate::writer::MutationWriter`]'s are: the pointer identity of - /// `template`'s `roots`/`node_paths`/`attr_paths` slices. See that - /// field's doc for the tradeoff (duplicate registration of structurally - /// identical templates in unmerged-statics builds). - templates: FxHashMap<(usize, usize, usize), u16>, -} - -impl TypedWriter { - /// Create a writer sharing `interner` with the event-dispatch path. - pub fn new(interner: Rc>) -> Self { - TypedWriter { batch: Vec::new(), interner, templates: FxHashMap::default() } - } - - /// Intern `s`, pushing `Operation::CacheString` on first sight of this - /// pointer identity — the same points at which `MutationWriter` emits - /// the byte format's `cache-string` op. - fn intern(&mut self, s: &'static str) -> u16 { - let (id, is_new) = self.interner.borrow_mut().intern_raw(s); - if is_new { - self.batch.push(m::Operation::CacheString(m::CacheString { id, str: s.to_string() })); - } - id - } - - fn intern_opt(&mut self, s: Option<&'static str>) -> Option { - s.map(|s| self.intern(s)) - } - - /// Pass 1 of template registration: intern every `&'static str` the - /// template references, so all their `CacheString` operations precede - /// the `RegisterTemplate` that references their ids. - fn intern_template_node(&mut self, node: &'static TemplateNode) { - if let TemplateNode::Element { tag, namespace, attrs, children } = node { - self.intern(tag); - self.intern_opt(*namespace); - for attr in *attrs { - // Dynamic template attributes are realized later through - // `set_attribute`; only static ones are part of the template. - if let TemplateAttribute::Static { name, namespace, .. } = attr { - self.intern(name); - self.intern_opt(*namespace); - } - } - for child in *children { - self.intern_template_node(child); - } - } - } - - /// Pass 2: append `node` and its subtree to `nodes` in pre-order, - /// returning `node`'s own index. - /// - /// The node is reserved in `nodes` *before* its children are walked (a - /// `Dynamic` placeholder stands in), so that the parent's index is fixed - /// while the children — which occupy later slots — are appended. The - /// reserved slot is then overwritten with the real element carrying the - /// child indices just collected. Must run after - /// [`Self::intern_template_node`]. - fn flatten_template_node( - &mut self, - node: &'static TemplateNode, - nodes: &mut Vec, - ) -> u32 { - match node { - TemplateNode::Element { tag, namespace, attrs, children } => { - let tag_id = self.intern(tag); - let ns_id = self.intern_opt(*namespace); - // Dynamic template attributes are realized later through - // `set_attribute`; only static ones are part of the template. - let mut wit_attrs = Vec::new(); - for attr in *attrs { - if let TemplateAttribute::Static { name, value, namespace } = attr { - let name_id = self.intern(name); - let ns_id = self.intern_opt(*namespace); - wit_attrs.push(m::TemplateAttr { - name: name_id, - ns: ns_id, - value: value.to_string(), - }); - } - } - let index = nodes.len() as u32; - nodes.push(m::TemplateNode::Dynamic); // reserved; overwritten below - let child_indices = children - .iter() - .map(|child| self.flatten_template_node(child, nodes)) - .collect(); - nodes[index as usize] = m::TemplateNode::Element(m::TemplateElement { - tag: tag_id, - ns: ns_id, - attrs: wit_attrs, - children: child_indices, - }); - index - } - TemplateNode::Text { text } => { - let index = nodes.len() as u32; - nodes.push(m::TemplateNode::Text(text.to_string())); - index - } - // A runtime-supplied node slot; the host materializes a - // placeholder that later ops (assign-id / replace-placeholder) - // address by path. - TemplateNode::Dynamic { .. } => { - let index = nodes.len() as u32; - nodes.push(m::TemplateNode::Dynamic); - index - } - } - } - - /// Return the id for `template`, registering it on first encounter. - /// - /// Panics if more than `u16::MAX` distinct templates are registered - /// (mirrors `MutationWriter::template_id` and [`Interner::intern_raw`]'s - /// id-space guard). - fn template_id(&mut self, template: Template) -> u16 { - let key = ( - template.roots.as_ptr() as usize, - template.node_paths.as_ptr() as usize, - template.attr_paths.as_ptr() as usize, - ); - if let Some(&id) = self.templates.get(&key) { - return id; - } - let next = self.templates.len(); - assert!( - next < u16::MAX as usize, - "typed: registered more than {} distinct templates; template id \ - space (u16) exhausted", - u16::MAX - ); - let id = next as u16; - self.templates.insert(key, id); - - // Pass 1 first, so every CacheString precedes the RegisterTemplate - // referencing its id (see the module doc). - for root in template.roots.iter() { - self.intern_template_node(root); - } - let mut nodes = Vec::new(); - let roots = template - .roots - .iter() - .map(|root| self.flatten_template_node(root, &mut nodes)) - .collect(); - self.batch.push(m::Operation::RegisterTemplate(m::RegisterTemplate { id, nodes, roots })); - id - } -} - -impl WriteMutations for TypedWriter { - fn append_children(&mut self, id: ElementId, m: usize) { - self.batch.push(m::Operation::AppendChildren(m::StackOp { id: id.0 as u32, m: m as u32 })); - } - - fn assign_node_id(&mut self, path: &'static [u8], id: ElementId) { - self.batch - .push(m::Operation::AssignId(m::AssignId { path: path.to_vec(), id: id.0 as u32 })); - } - - fn create_placeholder(&mut self, id: ElementId) { - self.batch.push(m::Operation::CreatePlaceholder(id.0 as u32)); - } - - fn create_text_node(&mut self, value: &str, id: ElementId) { - self.batch.push(m::Operation::CreateTextNode(m::CreateTextNode { - id: id.0 as u32, - text: value.to_string(), - })); - } - - fn load_template(&mut self, template: Template, index: usize, id: ElementId) { - let tmpl = self.template_id(template); - self.batch.push(m::Operation::LoadTemplate(m::LoadTemplate { - id: id.0 as u32, - tmpl, - root: index as u16, - })); - } - - fn replace_node_with(&mut self, id: ElementId, m: usize) { - self.batch.push(m::Operation::ReplaceWith(m::StackOp { id: id.0 as u32, m: m as u32 })); - } - - fn replace_placeholder_with_nodes(&mut self, path: &'static [u8], m: usize) { - self.batch - .push(m::Operation::ReplacePlaceholder(m::PathOp { path: path.to_vec(), m: m as u32 })); - } - - fn insert_nodes_after(&mut self, id: ElementId, m: usize) { - self.batch.push(m::Operation::InsertAfter(m::StackOp { id: id.0 as u32, m: m as u32 })); - } - - fn insert_nodes_before(&mut self, id: ElementId, m: usize) { - self.batch.push(m::Operation::InsertBefore(m::StackOp { id: id.0 as u32, m: m as u32 })); - } - - fn set_attribute( - &mut self, - name: &'static str, - ns: Option<&'static str>, - value: &AttributeValue, - id: ElementId, - ) { - // Intern first: the CacheStrings must precede the SetAttribute that - // names their ids. - let name_id = self.intern(name); - let ns_id = self.intern_opt(ns); - let value = match value { - AttributeValue::Text(s) => m::AttrValue::Text(s.clone()), - AttributeValue::Float(f) => m::AttrValue::Float(*f), - AttributeValue::Int(n) => m::AttrValue::Int(*n), - AttributeValue::Bool(b) => m::AttrValue::Boolean(*b), - AttributeValue::None => m::AttrValue::None, - // Listener: reaches the renderer through `create_event_listener` - // instead (dioxus never asks a renderer to serialize a callback). - // Any: a renderer-opaque payload for custom (non-HTML) - // renderers; there is nothing to put on the wire. Same as - // `MutationWriter` — no operation at all. - AttributeValue::Listener(_) | AttributeValue::Any(_) => return, - }; - self.batch.push(m::Operation::SetAttribute(m::SetAttribute { - id: id.0 as u32, - name: name_id, - ns: ns_id, - value, - })); - } - - fn set_node_text(&mut self, value: &str, id: ElementId) { - self.batch - .push(m::Operation::SetText(m::SetText { id: id.0 as u32, text: value.to_string() })); - } - - fn create_event_listener(&mut self, name: &'static str, id: ElementId) { - let name_id = self.intern(name); - self.batch.push(m::Operation::NewEventListener(m::EventListener { - id: id.0 as u32, - name: name_id, - bubbles: dioxus_core_types::event_bubbles(name), - })); - } - - fn remove_event_listener(&mut self, name: &'static str, id: ElementId) { - let name_id = self.intern(name); - self.batch.push(m::Operation::RemoveEventListener(m::EventListener { - id: id.0 as u32, - name: name_id, - bubbles: dioxus_core_types::event_bubbles(name), - })); - } - - fn remove_node(&mut self, id: ElementId) { - self.batch.push(m::Operation::Remove(id.0 as u32)); - } - - fn push_root(&mut self, id: ElementId) { - self.batch.push(m::Operation::PushRoot(id.0 as u32)); - } -} diff --git a/src/writer.rs b/src/writer.rs index 111b11f..e031da9 100644 --- a/src/writer.rs +++ b/src/writer.rs @@ -1,21 +1,30 @@ -//! [`MutationWriter`]: a `dioxus_core::WriteMutations` sink that encodes -//! straight into a [`protocol::Batch`] — no intermediate `Vec`. +//! [`MutationWriter`]: the mutation channel's `dioxus_core::WriteMutations` +//! sink, pushing `mutations::Operation` values into the batch the driver +//! flushes. //! //! The mapping mirrors dioxus's own `WriteMutations`→channel renderer //! (dioxus-interpreter-js `write_native_mutations.rs`): templates are -//! registered on first encounter and thereafter referenced by a guest-assigned -//! `u16`, and listener ops carry `event_bubbles(name)` because the host -//! delegates bubbling events at the mount root. +//! registered on first encounter and thereafter referenced by a +//! guest-assigned `u16`, and listener ops carry `event_bubbles(name)` +//! because the host delegates bubbling events at the mount root. The schema +//! it writes against is the `mutations` interface in `wit/world.wit`. (This +//! module names the generated bindings, so it is `wasm32`-only and +//! `cargo test` never builds it.) //! //! # Interning invariant //! -//! [`Interner::intern`] *emits* a `cache-string` op the first time it sees a -//! name. That op must never land in the middle of another op's operands, so -//! every string a composite op needs is interned **before** the op's first -//! byte is written. This matters most for `register-template`, whose node -//! grammar is recursive and self-delimiting: `intern_template_node` walks the -//! tree interning tags/namespaces/attribute names first, then -//! `emit_template_node` writes the grammar with no further interning. +//! The host resolves a `str-ref` against `cache-string` definitions it has +//! already seen, so every `CacheString` must precede the operation +//! referencing its id in the batch. Hence the two-pass template walk +//! (`intern_template_node` then `flatten_template_node`) and the +//! intern-then-build order in `set_attribute` and the listener ops. +//! +//! # Why template registration flattens +//! +//! WIT forbids recursive type definitions, so `register-template` carries an +//! arena (`nodes`, plus `u32` indices in `roots` and each element's +//! `children`) rather than a tree. See the `mutations` interface doc in +//! `wit/world.wit`. use std::cell::RefCell; use std::rc::Rc; @@ -23,16 +32,22 @@ use std::rc::Rc; use dioxus_core::{AttributeValue, ElementId, Template, TemplateAttribute, TemplateNode, WriteMutations}; use rustc_hash::FxHashMap; -use crate::protocol::{Batch, Interner}; +use crate::bindings::polymorph::dioxus::mutations as m; +use crate::interner::Interner; -/// Encodes dioxus mutations into a [`Batch`]. +/// Encodes dioxus mutations as `mutations::Operation` values. /// /// The interner is shared (`Rc>`) with the event-dispatch path, /// which needs the reverse `u16 -> &'static str` lookup to turn a /// `handle-event` name id back into a dioxus event name. pub struct MutationWriter { - /// The batch being filled. Drained by the driver's flush. - pub batch: Batch, + /// The batch being filled. The driver drains it with `std::mem::take` + /// once per flush and writes the whole `Vec` in one `write_all`. The + /// operations are moved into the write, but the `Vec` itself comes back + /// from `write_all` emptied with its capacity intact, and the driver + /// hands that capacity back here — so a steady-state batch reuses one + /// allocation. See `driver::flush`. + pub batch: Vec, interner: Rc>, /// Guest-assigned template ids, keyed by the pointer identity of /// `template`'s `roots`/`node_paths`/`attr_paths` slices — mirroring @@ -41,35 +56,35 @@ pub struct MutationWriter { /// than only when the build merges identical statics. This makes /// `template_id` O(1) in all build modes; in unmerged-statics builds /// (e.g. debug/dev), two structurally identical templates from distinct - /// `rsx!` sites now register twice instead of once — a harmless - /// duplicate registration (a few extra wire bytes), the same tradeoff - /// `Interner`'s pointer-identity doc in protocol.rs already documents - /// for strings. + /// `rsx!` sites register twice instead of once — a harmless duplicate + /// registration, the same tradeoff [`Interner`]'s pointer-identity doc + /// records for strings. templates: FxHashMap<(usize, usize, usize), u16>, } impl MutationWriter { /// Create a writer sharing `interner` with the event-dispatch path. pub fn new(interner: Rc>) -> Self { - MutationWriter { batch: Batch::new(), interner, templates: FxHashMap::default() } - } - - /// The shared interner handle. - pub fn interner(&self) -> &Rc> { - &self.interner + MutationWriter { batch: Vec::new(), interner, templates: FxHashMap::default() } } + /// Intern `s`, pushing `Operation::CacheString` on first sight of this + /// pointer identity. fn intern(&mut self, s: &'static str) -> u16 { - self.interner.borrow_mut().intern(&mut self.batch, s) + let (id, is_new) = self.interner.borrow_mut().intern(s); + if is_new { + self.batch.push(m::Operation::CacheString(m::CacheString { id, str: s.to_string() })); + } + id } fn intern_opt(&mut self, s: Option<&'static str>) -> Option { s.map(|s| self.intern(s)) } - /// Pass 1 of template registration: intern every `&'static str` the node - /// grammar will reference, so no `cache-string` op interleaves with the - /// grammar emitted by [`Self::emit_template_node`]. + /// Pass 1 of template registration: intern every `&'static str` the + /// template references, so all their `CacheString` operations precede + /// the `RegisterTemplate` that references their ids. fn intern_template_node(&mut self, node: &'static TemplateNode) { if let TemplateNode::Element { tag, namespace, attrs, children } = node { self.intern(tag); @@ -88,44 +103,72 @@ impl MutationWriter { } } - /// Pass 2: emit the `node` grammar from `wit/world.wit`'s - /// `register-template`. Must run after [`Self::intern_template_node`]. - fn emit_template_node(&mut self, node: &'static TemplateNode) { + /// Pass 2: append `node` and its subtree to `nodes` in pre-order, + /// returning `node`'s own index. + /// + /// The node is reserved in `nodes` *before* its children are walked (a + /// `Dynamic` placeholder stands in), so that the parent's index is fixed + /// while the children — which occupy later slots — are appended. The + /// reserved slot is then overwritten with the real element carrying the + /// child indices just collected. Must run after + /// [`Self::intern_template_node`]. + fn flatten_template_node( + &mut self, + node: &'static TemplateNode, + nodes: &mut Vec, + ) -> u32 { match node { TemplateNode::Element { tag, namespace, attrs, children } => { let tag_id = self.intern(tag); let ns_id = self.intern_opt(*namespace); - let static_count = attrs - .iter() - .filter(|attr| matches!(attr, TemplateAttribute::Static { .. })) - .count() as u16; - self.batch.template_element_open(tag_id, ns_id, static_count); // Dynamic template attributes are realized later through // `set_attribute`; only static ones are part of the template. + let mut wit_attrs = Vec::new(); for attr in *attrs { if let TemplateAttribute::Static { name, value, namespace } = attr { let name_id = self.intern(name); let ns_id = self.intern_opt(*namespace); - self.batch.template_attr(name_id, ns_id, value); + wit_attrs.push(m::TemplateAttr { + name: name_id, + ns: ns_id, + value: value.to_string(), + }); } } - self.batch.template_element_children(children.len() as u16); - for child in *children { - self.emit_template_node(child); - } + let index = nodes.len() as u32; + nodes.push(m::TemplateNode::Dynamic); // reserved; overwritten below + let child_indices = children + .iter() + .map(|child| self.flatten_template_node(child, nodes)) + .collect(); + nodes[index as usize] = m::TemplateNode::Element(m::TemplateElement { + tag: tag_id, + ns: ns_id, + attrs: wit_attrs, + children: child_indices, + }); + index + } + TemplateNode::Text { text } => { + let index = nodes.len() as u32; + nodes.push(m::TemplateNode::Text(text.to_string())); + index } - TemplateNode::Text { text } => self.batch.template_text(text), // A runtime-supplied node slot; the host materializes a // placeholder that later ops (assign-id / replace-placeholder) // address by path. - TemplateNode::Dynamic { .. } => self.batch.template_dynamic(), + TemplateNode::Dynamic { .. } => { + let index = nodes.len() as u32; + nodes.push(m::TemplateNode::Dynamic); + index + } } } /// Return the id for `template`, registering it on first encounter. /// /// Panics if more than `u16::MAX` distinct templates are registered - /// (mirrors [`Interner::intern`]'s id-space guard in protocol.rs). + /// (mirrors [`Interner::intern`]'s id-space guard). fn template_id(&mut self, template: Template) -> u16 { let key = ( template.roots.as_ptr() as usize, @@ -145,54 +188,67 @@ impl MutationWriter { let id = next as u16; self.templates.insert(key, id); - + // Pass 1 first, so every CacheString precedes the RegisterTemplate + // referencing its id (see the module doc). for root in template.roots.iter() { self.intern_template_node(root); } - self.batch.register_template(id, template.roots.len() as u16); - for root in template.roots.iter() { - self.emit_template_node(root); - } + let mut nodes = Vec::new(); + let roots = template + .roots + .iter() + .map(|root| self.flatten_template_node(root, &mut nodes)) + .collect(); + self.batch.push(m::Operation::RegisterTemplate(m::RegisterTemplate { id, nodes, roots })); id } } impl WriteMutations for MutationWriter { fn append_children(&mut self, id: ElementId, m: usize) { - self.batch.append_children(id.0 as u32, m as u32); + self.batch.push(m::Operation::AppendChildren(m::StackOp { id: id.0 as u32, m: m as u32 })); } fn assign_node_id(&mut self, path: &'static [u8], id: ElementId) { - self.batch.assign_id(path, id.0 as u32); + self.batch + .push(m::Operation::AssignId(m::AssignId { path: path.to_vec(), id: id.0 as u32 })); } fn create_placeholder(&mut self, id: ElementId) { - self.batch.create_placeholder(id.0 as u32); + self.batch.push(m::Operation::CreatePlaceholder(id.0 as u32)); } fn create_text_node(&mut self, value: &str, id: ElementId) { - self.batch.create_text_node(id.0 as u32, value); + self.batch.push(m::Operation::CreateTextNode(m::CreateTextNode { + id: id.0 as u32, + text: value.to_string(), + })); } fn load_template(&mut self, template: Template, index: usize, id: ElementId) { let tmpl = self.template_id(template); - self.batch.load_template(tmpl, index as u16, id.0 as u32); + self.batch.push(m::Operation::LoadTemplate(m::LoadTemplate { + id: id.0 as u32, + tmpl, + root: index as u16, + })); } fn replace_node_with(&mut self, id: ElementId, m: usize) { - self.batch.replace_with(id.0 as u32, m as u32); + self.batch.push(m::Operation::ReplaceWith(m::StackOp { id: id.0 as u32, m: m as u32 })); } fn replace_placeholder_with_nodes(&mut self, path: &'static [u8], m: usize) { - self.batch.replace_placeholder(path, m as u32); + self.batch + .push(m::Operation::ReplacePlaceholder(m::PathOp { path: path.to_vec(), m: m as u32 })); } fn insert_nodes_after(&mut self, id: ElementId, m: usize) { - self.batch.insert_after(id.0 as u32, m as u32); + self.batch.push(m::Operation::InsertAfter(m::StackOp { id: id.0 as u32, m: m as u32 })); } fn insert_nodes_before(&mut self, id: ElementId, m: usize) { - self.batch.insert_before(id.0 as u32, m as u32); + self.batch.push(m::Operation::InsertBefore(m::StackOp { id: id.0 as u32, m: m as u32 })); } fn set_attribute( @@ -202,235 +258,59 @@ impl WriteMutations for MutationWriter { value: &AttributeValue, id: ElementId, ) { - // Intern first: `cache-string` must not split the set-attribute op. + // Intern first: the CacheStrings must precede the SetAttribute that + // names their ids. let name_id = self.intern(name); let ns_id = self.intern_opt(ns); - let id = id.0 as u32; - match value { - AttributeValue::Text(s) => self.batch.set_attribute_text(id, name_id, ns_id, s), - AttributeValue::Float(f) => self.batch.set_attribute_float(id, name_id, ns_id, *f), - AttributeValue::Int(n) => self.batch.set_attribute_int(id, name_id, ns_id, *n), - AttributeValue::Bool(b) => self.batch.set_attribute_bool(id, name_id, ns_id, *b), - AttributeValue::None => self.batch.set_attribute_none(id, name_id, ns_id), + let value = match value { + AttributeValue::Text(s) => m::AttrValue::Text(s.clone()), + AttributeValue::Float(f) => m::AttrValue::Float(*f), + AttributeValue::Int(n) => m::AttrValue::Int(*n), + AttributeValue::Bool(b) => m::AttrValue::Boolean(*b), + AttributeValue::None => m::AttrValue::None, // Listener: reaches the renderer through `create_event_listener` // instead (dioxus never asks a renderer to serialize a callback). - // Any: a renderer-opaque payload for custom (non-HTML) renderers; - // there is nothing to put on the wire. dioxus's own channel - // renderer treats both as unreachable/ignored. - AttributeValue::Listener(_) | AttributeValue::Any(_) => {} - } + // Any: a renderer-opaque payload for custom (non-HTML) + // renderers; there is nothing to put on the wire. dioxus's own + // channel renderer treats both as unreachable/ignored. + AttributeValue::Listener(_) | AttributeValue::Any(_) => return, + }; + self.batch.push(m::Operation::SetAttribute(m::SetAttribute { + id: id.0 as u32, + name: name_id, + ns: ns_id, + value, + })); } fn set_node_text(&mut self, value: &str, id: ElementId) { - self.batch.set_text(id.0 as u32, value); + self.batch + .push(m::Operation::SetText(m::SetText { id: id.0 as u32, text: value.to_string() })); } fn create_event_listener(&mut self, name: &'static str, id: ElementId) { let name_id = self.intern(name); - self.batch.new_event_listener(id.0 as u32, name_id, dioxus_core_types::event_bubbles(name)); + self.batch.push(m::Operation::NewEventListener(m::EventListener { + id: id.0 as u32, + name: name_id, + bubbles: dioxus_core_types::event_bubbles(name), + })); } fn remove_event_listener(&mut self, name: &'static str, id: ElementId) { let name_id = self.intern(name); - self.batch.remove_event_listener(id.0 as u32, name_id, dioxus_core_types::event_bubbles(name)); + self.batch.push(m::Operation::RemoveEventListener(m::EventListener { + id: id.0 as u32, + name: name_id, + bubbles: dioxus_core_types::event_bubbles(name), + })); } fn remove_node(&mut self, id: ElementId) { - self.batch.remove(id.0 as u32); + self.batch.push(m::Operation::Remove(id.0 as u32)); } fn push_root(&mut self, id: ElementId) { - self.batch.push_root(id.0 as u32); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use dioxus::prelude::*; - - fn writer() -> MutationWriter { - MutationWriter::new(Rc::new(RefCell::new(Interner::new()))) - } - - /// Extract `(ops, strings)` segments from a `Batch` via `take_frame`, - /// parsing off the frame header (the only segment-extraction API since - /// the call transport's `take_segments` was removed — see - /// `protocol.rs`'s own `unframe` test helper, duplicated here for - /// `writer.rs`'s separate test module). - fn take_segments(batch: &mut Batch) -> (Vec, String) { - let mut out = Vec::new(); - batch.take_frame(&mut out); - let strings_len = u32::from_le_bytes(out[4..8].try_into().unwrap()) as usize; - let strings = String::from_utf8(out[8..8 + strings_len].to_vec()).unwrap(); - let ops = out[8 + strings_len..].to_vec(); - (ops, strings) - } - - /// Rebuild a real VirtualDom through the writer and compare the produced - /// segments against a hand-driven `Batch`. Any drift in op order, operand - /// widths, or intern-before-emit ordering shows up as a byte mismatch. - #[test] - fn rebuild_matches_hand_driven_batch() { - fn app() -> Element { - rsx! { - div { class: "root", - button { onclick: move |_| {}, "hi" } - } - } - } - - let mut w = writer(); - let mut dom = VirtualDom::new(app); - dom.rebuild(&mut w); - let (ops, strings) = take_segments(&mut w.batch); - - // Hand-driven expectation. Template 0 has one root: - // div(class="root")[ button[ text "hi" ] ] - // and the onclick listener is a *dynamic* attribute, so it is not part - // of the static template; it arrives as new-event-listener after the - // load. Intern ids are assigned in first-touch order during the - // interning pass over the template: div, class, button. - let mut b = Batch::new(); - let mut i = Interner::new(); - let div = i.intern(&mut b, "div"); - let class = i.intern(&mut b, "class"); - let button = i.intern(&mut b, "button"); - b.register_template(0, 1); - b.template_element_open(div, None, 1); - b.template_attr(class, None, "root"); - b.template_element_children(1); - b.template_element_open(button, None, 0); - b.template_element_children(1); - b.template_text("hi"); - b.load_template(0, 0, 1); - b.assign_id(&[0], 2); - let click = i.intern(&mut b, "click"); - b.new_event_listener(2, click, true); - b.append_children(0, 1); - let (exp_ops, exp_strings) = take_segments(&mut b); - - assert_eq!(strings, exp_strings, "string segment"); - assert_eq!(ops, exp_ops, "op segment"); - } - - /// A template is registered exactly once no matter how many times it is - /// loaded: two list items sharing one `rsx!` site produce one - /// register-template and two load-template ops. - #[test] - fn template_registered_once_per_identity() { - fn app() -> Element { - rsx! { - for n in 0..3 { - span { key: "{n}", "{n}" } - } - } - } - - let mut w = writer(); - let mut dom = VirtualDom::new(app); - dom.rebuild(&mut w); - let (ops, strings) = take_segments(&mut w.batch); - - // One `rsx!` site => one `Template` identity, registered once and - // loaded three times. Asserting on bytes (rather than counting - // opcode-valued bytes, which also occur inside operands) keeps this - // honest: the expected batch below contains exactly one - // register-template. - assert_eq!(w.templates.len(), 1); - - let mut b = Batch::new(); - let mut i = Interner::new(); - let span = i.intern(&mut b, "span"); - b.register_template(0, 1); - b.template_element_open(span, None, 0); - b.template_element_children(1); - b.template_dynamic(); - for (root_id, text_id) in [(1u32, 2u32), (3, 4), (5, 6)] { - b.load_template(0, 0, root_id); - b.create_text_node(text_id, &((root_id - 1) / 2).to_string()); - b.replace_placeholder(&[0], 1); - } - b.append_children(0, 3); - let (exp_ops, exp_strings) = take_segments(&mut b); - assert_eq!(strings, exp_strings, "string segment"); - assert_eq!(ops, exp_ops, "op segment"); - - // Re-rendering the same tree must not re-register anything. - let before = w.templates.len(); - dom.render_immediate(&mut w); - assert_eq!(w.templates.len(), before); - } - - /// Wire flags bit0 is dioxus's own `event_bubbles` verdict. `click` - /// bubbles; `focus` does not — the host keys its delegation strategy off - /// this bit, so getting it backwards silently breaks event delivery. - #[test] - fn listener_bubbles_bit_follows_dioxus_table() { - assert!(dioxus_core_types::event_bubbles("click")); - assert!(!dioxus_core_types::event_bubbles("focus")); - - let mut w = writer(); - w.create_event_listener("click", ElementId(1)); - w.create_event_listener("focus", ElementId(2)); - w.remove_event_listener("focus", ElementId(2)); - let (ops, strings) = take_segments(&mut w.batch); - - let mut b = Batch::new(); - let mut i = Interner::new(); - let click = i.intern(&mut b, "click"); - b.new_event_listener(1, click, true); - let focus = i.intern(&mut b, "focus"); - b.new_event_listener(2, focus, false); - b.remove_event_listener(2, focus, false); - let (exp_ops, exp_strings) = take_segments(&mut b); - - assert_eq!(ops, exp_ops); - assert_eq!(strings, exp_strings); - } - - /// `AttributeValue::None` is a distinct wire kind (unconditional removal), - /// not an empty text value, and the float/int/bool kinds keep their - /// numeric encodings rather than being stringified. - #[test] - fn attribute_value_kinds_map_one_to_one() { - let mut w = writer(); - w.set_attribute("width", None, &AttributeValue::Float(1.5), ElementId(1)); - w.set_attribute("tabindex", None, &AttributeValue::Int(-3), ElementId(1)); - w.set_attribute("hidden", None, &AttributeValue::Bool(false), ElementId(1)); - w.set_attribute("title", None, &AttributeValue::None, ElementId(1)); - let (ops, strings) = take_segments(&mut w.batch); - - let mut b = Batch::new(); - let mut i = Interner::new(); - let width = i.intern(&mut b, "width"); - b.set_attribute_float(1, width, None, 1.5); - let tabindex = i.intern(&mut b, "tabindex"); - b.set_attribute_int(1, tabindex, None, -3); - let hidden = i.intern(&mut b, "hidden"); - b.set_attribute_bool(1, hidden, None, false); - let title = i.intern(&mut b, "title"); - b.set_attribute_none(1, title, None); - let (exp_ops, exp_strings) = take_segments(&mut b); - - assert_eq!(ops, exp_ops); - assert_eq!(strings, exp_strings); - } - - /// `AttributeValue::Any` is renderer-opaque (it exists for non-HTML - /// renderers) and produces no op at all. The `Listener` arm is covered by - /// `rebuild_matches_hand_driven_batch`: the `onclick` there reaches the - /// writer as a listener attribute and contributes a new-event-listener op - /// rather than a set-attribute one. - #[test] - fn any_attribute_value_is_skipped() { - let mut w = writer(); - w.set_attribute("data-x", None, &AttributeValue::any_value(7u32), ElementId(1)); - let (ops, _) = take_segments(&mut w.batch); - // Only the `cache-string` for "data-x" — no set-attribute op. - const CACHE_STRING: u8 = 0x01; - assert_eq!(ops[0], CACHE_STRING); - const SET_ATTRIBUTE: u8 = 0x0c; - assert!(!ops.contains(&SET_ATTRIBUTE)); + self.batch.push(m::Operation::PushRoot(id.0 as u32)); } } diff --git a/tests/vectors.rs b/tests/vectors.rs deleted file mode 100644 index 1a3ad88..0000000 --- a/tests/vectors.rs +++ /dev/null @@ -1,472 +0,0 @@ -//! Golden vector generator/verifier for the wire format in `src/protocol.rs` -//! / `wit/world.wit`. These vectors are shared with the TypeScript decoder -//! (host side), so the `.expected.json` shape is contractual — see the -//! dispatch/README for the exact schema. -//! -//! Each vector's bytes and JSON are produced by ONE function that drives -//! both `Batch`/`Interner` (for bytes) and a parallel `serde_json::Value` -//! builder (for the expected JSON), op-by-op, so the two representations -//! cannot drift apart from each other by construction. - -use polyengine_dioxus::protocol::{Batch, Interner}; -use serde_json::{json, Value}; -use std::fs; -use std::path::Path; - -/// Records one vector: drives `Batch`/`Interner` for the byte encoding and -/// builds the matching JSON op list in lockstep. Frame boundaries are -/// explicit (`take_frame`) so the `frames` vector can split one op stream -/// across two physical frames. -struct Rec { - batch: Batch, - interner: Interner, - /// Ops recorded since the last `take_frame` (or start). - current_ops: Vec, - /// One entry per completed frame (each a JSON array of ops). - frames_json: Vec, - /// Framed bytes emitted so far (stream-transport framing). - bytes: Vec, - /// Pointer-identity strings already interned, purely so this recorder - /// knows when to emit a `cache-string` JSON op (mirrors, but does not - /// read, `Interner`'s private dedup — `Interner::intern` is the actual - /// source of truth for the bytes). - seen: std::collections::HashMap<(usize, usize), u16>, -} - -impl Rec { - fn new() -> Self { - Rec { - batch: Batch::new(), - interner: Interner::new(), - current_ops: Vec::new(), - frames_json: Vec::new(), - bytes: Vec::new(), - seen: std::collections::HashMap::new(), - } - } - - fn push_op(&mut self, v: Value) { - self.current_ops.push(v); - } - - /// Intern a `&'static str`, recording a `cache-string` JSON op the - /// first time this pointer identity is seen (matching `Interner`'s - /// documented dedup behavior). - fn intern(&mut self, s: &'static str) -> u16 { - let key = (s.as_ptr() as usize, s.len()); - let was_new = !self.seen.contains_key(&key); - let id = self.interner.intern(&mut self.batch, s); - if was_new { - self.seen.insert(key, id); - self.push_op(json!({"op": "cache-string", "id": id, "s": s})); - } - id - } - - fn ns_json(ns: Option) -> Value { - match ns { - Some(n) => json!(n), - None => Value::Null, - } - } - - fn append_children(&mut self, id: u32, m: u32) { - self.batch.append_children(id, m); - self.push_op(json!({"op": "append-children", "id": id, "m": m})); - } - - fn assign_id(&mut self, path: &[u8], id: u32) { - self.batch.assign_id(path, id); - self.push_op(json!({"op": "assign-id", "path": path, "id": id})); - } - - fn create_placeholder(&mut self, id: u32) { - self.batch.create_placeholder(id); - self.push_op(json!({"op": "create-placeholder", "id": id})); - } - - fn create_text_node(&mut self, id: u32, text: &str) { - self.batch.create_text_node(id, text); - self.push_op(json!({"op": "create-text-node", "id": id, "text": text})); - } - - fn load_template(&mut self, tmpl: u16, root: u16, id: u32) { - self.batch.load_template(tmpl, root, id); - self.push_op(json!({"op": "load-template", "tmpl": tmpl, "root": root, "id": id})); - } - - fn replace_with(&mut self, id: u32, m: u32) { - self.batch.replace_with(id, m); - self.push_op(json!({"op": "replace-with", "id": id, "m": m})); - } - - fn replace_placeholder(&mut self, path: &[u8], m: u32) { - self.batch.replace_placeholder(path, m); - self.push_op(json!({"op": "replace-placeholder", "path": path, "m": m})); - } - - fn insert_after(&mut self, id: u32, m: u32) { - self.batch.insert_after(id, m); - self.push_op(json!({"op": "insert-after", "id": id, "m": m})); - } - - fn insert_before(&mut self, id: u32, m: u32) { - self.batch.insert_before(id, m); - self.push_op(json!({"op": "insert-before", "id": id, "m": m})); - } - - fn set_attribute_text(&mut self, id: u32, name: u16, ns: Option, value: &str) { - self.batch.set_attribute_text(id, name, ns, value); - self.push_op(json!({ - "op": "set-attribute", "id": id, "name": name, "ns": Self::ns_json(ns), - "value": {"kind": "text", "s": value} - })); - } - - fn set_attribute_float(&mut self, id: u32, name: u16, ns: Option, value: f64) { - self.batch.set_attribute_float(id, name, ns, value); - self.push_op(json!({ - "op": "set-attribute", "id": id, "name": name, "ns": Self::ns_json(ns), - "value": {"kind": "float", "f": value} - })); - } - - fn set_attribute_int(&mut self, id: u32, name: u16, ns: Option, value: i64) { - self.batch.set_attribute_int(id, name, ns, value); - self.push_op(json!({ - "op": "set-attribute", "id": id, "name": name, "ns": Self::ns_json(ns), - "value": {"kind": "int", "i": value.to_string()} - })); - } - - fn set_attribute_bool(&mut self, id: u32, name: u16, ns: Option, value: bool) { - self.batch.set_attribute_bool(id, name, ns, value); - self.push_op(json!({ - "op": "set-attribute", "id": id, "name": name, "ns": Self::ns_json(ns), - "value": {"kind": "bool", "b": value} - })); - } - - fn set_attribute_none(&mut self, id: u32, name: u16, ns: Option) { - self.batch.set_attribute_none(id, name, ns); - self.push_op(json!({ - "op": "set-attribute", "id": id, "name": name, "ns": Self::ns_json(ns), - "value": {"kind": "none"} - })); - } - - fn set_text(&mut self, id: u32, text: &str) { - self.batch.set_text(id, text); - self.push_op(json!({"op": "set-text", "id": id, "text": text})); - } - - fn new_event_listener(&mut self, id: u32, name: u16, bubbles: bool) { - self.batch.new_event_listener(id, name, bubbles); - self.push_op( - json!({"op": "new-event-listener", "id": id, "name": name, "bubbles": bubbles}), - ); - } - - fn remove_event_listener(&mut self, id: u32, name: u16, bubbles: bool) { - self.batch.remove_event_listener(id, name, bubbles); - self.push_op( - json!({"op": "remove-event-listener", "id": id, "name": name, "bubbles": bubbles}), - ); - } - - fn remove(&mut self, id: u32) { - self.batch.remove(id); - self.push_op(json!({"op": "remove", "id": id})); - } - - fn push_root(&mut self, id: u32) { - self.batch.push_root(id); - self.push_op(json!({"op": "push-root", "id": id})); - } - - /// Register a template with the given roots, recursing through - /// `TNode` to drive both the `Batch` node-emitting calls and the - /// matching JSON `tnode` values. - fn register_template(&mut self, tmpl: u16, roots: &[TNode]) { - self.batch.register_template(tmpl, roots.len() as u16); - let root_json: Vec = roots.iter().map(|n| self.emit_node(n)).collect(); - self.push_op(json!({"op": "register-template", "tmpl": tmpl, "roots": root_json})); - } - - fn emit_node(&mut self, node: &TNode) -> Value { - match node { - TNode::Element { tag, ns, attrs, children } => { - self.batch.template_element_open(*tag, *ns, attrs.len() as u16); - let attrs_json: Vec = attrs - .iter() - .map(|(name, ns, value)| { - self.batch.template_attr(*name, *ns, value); - json!({"name": name, "ns": Self::ns_json(*ns), "value": value}) - }) - .collect(); - self.batch.template_element_children(children.len() as u16); - let children_json: Vec = children.iter().map(|c| self.emit_node(c)).collect(); - json!({ - "kind": "element", "tag": tag, "ns": Self::ns_json(*ns), - "attrs": attrs_json, "children": children_json - }) - } - TNode::Text(value) => { - self.batch.template_text(value); - json!({"kind": "text", "value": value}) - } - TNode::Dynamic => { - self.batch.template_dynamic(); - json!({"kind": "dynamic"}) - } - } - } - - /// Close out the current frame: flush ops recorded so far into one - /// physical frame (bytes + JSON). - fn take_frame(&mut self) { - self.batch.take_frame(&mut self.bytes); - let ops = std::mem::take(&mut self.current_ops); - self.frames_json.push(Value::Array(ops)); - } - - /// Finish the vector: if anything is pending, close it as a final - /// frame, then return `(bytes, expected-json)`. - fn finish(mut self) -> (Vec, Value) { - if !self.current_ops.is_empty() || !self.batch.is_empty() { - self.take_frame(); - } - (self.bytes, json!({"frames": self.frames_json})) - } -} - -/// A template node spec used only by this test to drive `Batch`'s -/// node-emitting calls and the matching expected-JSON `tnode` value in -/// lockstep (mirrors the `node` grammar in `wit/world.wit`). -enum TNode { - Element { tag: u16, ns: Option, attrs: Vec<(u16, Option, String)>, children: Vec }, - Text(String), - Dynamic, -} - -// Interned name statics. Pointer identity is what `Interner` dedups on, so -// reusing the same static (e.g. `DIV` used twice) is what exercises the -// "cache hit" path; `DIV2` is a distinct static with equal contents to -// confirm the documented "two ids for equal-content statics is harmless" -// behavior is at least exercised. -static DIV: &str = "div"; -static SPAN: &str = "span"; -static CLASS: &str = "class"; -static STYLE: &str = "style"; -static SVG_NS: &str = "http://www.w3.org/2000/svg"; -static CLICK: &str = "click"; - -/// Every opcode at least once; interning with a cache hit (`DIV` used -/// twice); every `attrval` kind; a `none`-namespace and a `some`-namespace -/// `set-attribute`. -fn build_basic(rec: &mut Rec) { - let div = rec.intern(DIV); - let _div_again = rec.intern(DIV); // cache hit: no second cache-string op - let span = rec.intern(SPAN); - let class = rec.intern(CLASS); - let style = rec.intern(STYLE); - let svg_ns = rec.intern(SVG_NS); - let click = rec.intern(CLICK); - - rec.register_template(0, &[TNode::Dynamic]); - rec.load_template(0, 0, 1); - rec.create_placeholder(2); - rec.create_text_node(3, "hello"); - rec.append_children(1, 2); - rec.assign_id(&[0, 1], 5); - rec.replace_with(9, 1); - rec.replace_placeholder(&[0], 2); - rec.insert_after(3, 1); - rec.insert_before(3, 1); - - // Every attrval kind; one none-namespace, one some-namespace. - rec.set_attribute_text(2, class, None, "x"); - rec.set_attribute_float(2, style, Some(svg_ns), 1.5); - rec.set_attribute_int(2, div, None, -3); - rec.set_attribute_bool(2, span, None, true); - rec.set_attribute_none(2, class, None); - - rec.set_text(2, "t"); - rec.new_event_listener(2, click, true); - rec.remove_event_listener(2, click, false); - rec.remove(2); - rec.push_root(1); -} - -/// Dynamic strings covering: empty, ASCII, accented BMP, CJK, surrogate -/// pairs, and a >65600-UTF-16-unit string (exercises the `dynstr` 0xffff -/// escape). -fn build_unicode(rec: &mut Rec) { - // "ab£".repeat(N) has UTF-16 length 3*N (a,b are 1 unit each, £ is 1 - // BMP unit). Need >65600, so N must be > 21867; use 22000 for margin. - let long = "ab£".repeat(22000); - debug_assert!(long.chars().map(char::len_utf16).sum::() > 65600); - - rec.create_text_node(0, ""); - rec.create_text_node(1, "hello"); - rec.create_text_node(2, "héllo wörld"); - rec.create_text_node(3, "你好世界"); - rec.create_text_node(4, "👍🏽 emoji"); - rec.create_text_node(5, &long); -} - -/// `register-template` with two roots: (1) a 3-level element tree mixing a -/// namespaced static attr, a non-namespaced static attr, a text child, and -/// a dynamic placeholder; (2) a bare dynamic root. Followed by -/// load-template + assign-id + replace-placeholder uses. -fn build_template(rec: &mut Rec) { - let div = rec.intern(DIV); - let span = rec.intern(SPAN); - let class = rec.intern(CLASS); - let style = rec.intern(STYLE); - let svg_ns = rec.intern(SVG_NS); - - let roots = vec![ - TNode::Element { - tag: div, - ns: None, - attrs: vec![(class, None, "container".to_string())], - children: vec![ - TNode::Element { - tag: span, - ns: Some(svg_ns), - attrs: vec![(style, Some(svg_ns), "color:red".to_string())], - children: vec![TNode::Text("hello".to_string()), TNode::Dynamic], - }, - TNode::Text("world".to_string()), - ], - }, - TNode::Dynamic, - ]; - rec.register_template(0, &roots); - - rec.load_template(0, 0, 10); - rec.assign_id(&[0, 1], 11); - rec.replace_placeholder(&[0], 1); -} - -fn vector_dir() -> &'static Path { - Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/vectors")) -} - -fn write_vector(name: &str, bytes: &[u8], json: &Value) { - let dir = vector_dir(); - fs::create_dir_all(dir).expect("create vectors dir"); - fs::write(dir.join(format!("{name}.bin")), bytes).expect("write .bin"); - let pretty = serde_json::to_string_pretty(json).expect("serialize json"); - fs::write(dir.join(format!("{name}.expected.json")), pretty + "\n").expect("write .json"); -} - -fn read_vector(name: &str) -> (Vec, Value) { - let dir = vector_dir(); - let bin_path = dir.join(format!("{name}.bin")); - let json_path = dir.join(format!("{name}.expected.json")); - let bytes = fs::read(&bin_path).unwrap_or_else(|_| { - panic!( - "missing {} — run `cargo test --test vectors -- --ignored generate` to (re)generate vectors", - bin_path.display() - ) - }); - let text = fs::read_to_string(&json_path).unwrap_or_else(|_| { - panic!( - "missing {} — run `cargo test --test vectors -- --ignored generate` to (re)generate vectors", - json_path.display() - ) - }); - let json: Value = serde_json::from_str(&text).expect("parse expected.json"); - (bytes, json) -} - -/// Build all four vectors: `(name, bytes, json)`. -fn build_all() -> Vec<(&'static str, Vec, Value)> { - let mut basic_rec = Rec::new(); - build_basic(&mut basic_rec); - let (basic_bytes, basic_json) = basic_rec.finish(); - - let mut unicode_rec = Rec::new(); - build_unicode(&mut unicode_rec); - let (unicode_bytes, unicode_json) = unicode_rec.finish(); - - let mut template_rec = Rec::new(); - build_template(&mut template_rec); - let (template_bytes, template_json) = template_rec.finish(); - - // `frames`: the ops of `basic` split across two frames in one .bin — - // rerun the same driver but call `take_frame` at the halfway point. - let mut frames_rec = Rec::new(); - build_basic_split(&mut frames_rec); - let (frames_bytes, frames_json) = frames_rec.finish(); - - vec![ - ("basic", basic_bytes, basic_json), - ("unicode", unicode_bytes, unicode_json), - ("template", template_bytes, template_json), - ("frames", frames_bytes, frames_json), - ] -} - -/// Same op sequence as `build_basic`, but split into two `take_frame` calls -/// partway through (after `replace_placeholder`) so the `frames` vector -/// exercises multi-frame decoding. -fn build_basic_split(rec: &mut Rec) { - let div = rec.intern(DIV); - let _div_again = rec.intern(DIV); - let span = rec.intern(SPAN); - let class = rec.intern(CLASS); - let style = rec.intern(STYLE); - let svg_ns = rec.intern(SVG_NS); - let click = rec.intern(CLICK); - - rec.register_template(0, &[TNode::Dynamic]); - rec.load_template(0, 0, 1); - rec.create_placeholder(2); - rec.create_text_node(3, "hello"); - rec.append_children(1, 2); - rec.assign_id(&[0, 1], 5); - rec.replace_with(9, 1); - rec.replace_placeholder(&[0], 2); - - rec.take_frame(); // frame boundary mid-stream - - rec.insert_after(3, 1); - rec.insert_before(3, 1); - rec.set_attribute_text(2, class, None, "x"); - rec.set_attribute_float(2, style, Some(svg_ns), 1.5); - rec.set_attribute_int(2, div, None, -3); - rec.set_attribute_bool(2, span, None, true); - rec.set_attribute_none(2, class, None); - rec.set_text(2, "t"); - rec.new_event_listener(2, click, true); - rec.remove_event_listener(2, click, false); - rec.remove(2); - rec.push_root(1); -} - -#[test] -#[ignore = "regenerates committed vector fixtures; run explicitly"] -fn generate() { - for (name, bytes, json) in build_all() { - write_vector(name, &bytes, &json); - } -} - -#[test] -fn golden_matches_committed() { - for (name, bytes, json) in build_all() { - let (committed_bytes, committed_json) = read_vector(name); - assert_eq!( - bytes, committed_bytes, - "vector `{name}`: regenerated bytes differ from vectors/{name}.bin \ - (run `cargo test --test vectors -- --ignored generate` if this is intentional)" - ); - assert_eq!( - json, committed_json, - "vector `{name}`: regenerated JSON differs from vectors/{name}.expected.json" - ); - } -} diff --git a/vectors/basic.bin b/vectors/basic.bin deleted file mode 100644 index d2e6ba2a039f9bf49c98448d0be58108f7507400..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 273 zcmX|*%MOAt5Jm5_prYbq>qk(c3llef3qb?KMu=@d|J;qi8Hmne?w!+lG$!B(;8L}& z$?mz$oOAwVuci0R$(XL|lJ1bykHYNJG&Qa*a$DuM@@j4U^gOLWgaD)vg0!RnJ5b0@ z0R%PW^{hb1H6~gdv;e6CCB48b;v_KQ5th01Nxk@)rC^1=XFPZ8l)MEdJ{N&LudqBhMep@gHe@pUnYj!Ux{h)0wS0gX$NSV1 e@Kk-eA$megH2qi2LXHrYBK+yqpjeA;7#j zrIbJ3Z8@K_)}@r8OhbsPevRX()3w@gWV0VG+TcT+qgyuTeJCONg@6`BP@o1KsFkMh hL<1?HR^p}BUgF>52L*}Lp%HH4-5JHV2hX0^1b#bnAlCo@ diff --git a/vectors/template.expected.json b/vectors/template.expected.json deleted file mode 100644 index b4db85e..0000000 --- a/vectors/template.expected.json +++ /dev/null @@ -1,100 +0,0 @@ -{ - "frames": [ - [ - { - "id": 0, - "op": "cache-string", - "s": "div" - }, - { - "id": 1, - "op": "cache-string", - "s": "span" - }, - { - "id": 2, - "op": "cache-string", - "s": "class" - }, - { - "id": 3, - "op": "cache-string", - "s": "style" - }, - { - "id": 4, - "op": "cache-string", - "s": "http://www.w3.org/2000/svg" - }, - { - "op": "register-template", - "roots": [ - { - "attrs": [ - { - "name": 2, - "ns": null, - "value": "container" - } - ], - "children": [ - { - "attrs": [ - { - "name": 3, - "ns": 4, - "value": "color:red" - } - ], - "children": [ - { - "kind": "text", - "value": "hello" - }, - { - "kind": "dynamic" - } - ], - "kind": "element", - "ns": 4, - "tag": 1 - }, - { - "kind": "text", - "value": "world" - } - ], - "kind": "element", - "ns": null, - "tag": 0 - }, - { - "kind": "dynamic" - } - ], - "tmpl": 0 - }, - { - "id": 10, - "op": "load-template", - "root": 0, - "tmpl": 0 - }, - { - "id": 11, - "op": "assign-id", - "path": [ - 0, - 1 - ] - }, - { - "m": 1, - "op": "replace-placeholder", - "path": [ - 0 - ] - } - ] - ] -} diff --git a/vectors/unicode.bin b/vectors/unicode.bin deleted file mode 100644 index 80bb777f1bc1a22871a8ee9dc8ee5791115d055a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 88098 zcmeIwF=_%)5P;$L%=!v*hp@LYZA4)WD?*xF!Vpqus~{F$Ky0?jULc3qkjBCrY#z2= z;TxFw`{8?fj^Q$np_F=rUkAm=fphnym$B4-rRFsJW-VhsNy@OCo* diff --git a/vectors/unicode.expected.json b/vectors/unicode.expected.json deleted file mode 100644 index 037e506..0000000 --- a/vectors/unicode.expected.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "frames": [ - [ - { - "id": 0, - "op": "create-text-node", - "text": "" - }, - { - "id": 1, - "op": "create-text-node", - "text": "hello" - }, - { - "id": 2, - "op": "create-text-node", - "text": "héllo wörld" - }, - { - "id": 3, - "op": "create-text-node", - "text": "你好世界" - }, - { - "id": 4, - "op": "create-text-node", - "text": "👍🏽 emoji" - }, - { - "id": 5, - "op": "create-text-node", - "text": "ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£ab£" - } - ] - ] -} diff --git a/wit/world.wit b/wit/world.wit index e8133cd..04297c7 100644 --- a/wit/world.wit +++ b/wit/world.wit @@ -1,19 +1,18 @@ /// polymorph:dioxus — a batched DOM-mutation surface for Dioxus apps running /// as components on polyengine. /// -/// Design: the guest queues DOM mutations into a compact byte format -/// (sledgehammer-bindgen-shaped) and delivers whole batches across the -/// boundary; the host decodes with a tight switch loop and applies them to a -/// live DOM subtree. Strings cross the boundary two ways: `&'static` names -/// (tags, attribute names, event names) are interned once and referenced by -/// u16 id thereafter; dynamic text is concatenated per batch and decoded with -/// a single TextDecoder pass. +/// Design: the guest queues DOM mutations as `mutations.operation` values and +/// delivers whole batches across the boundary in one stream write; the host +/// applies them to a live DOM subtree. Strings cross the boundary two ways: +/// `&'static` names (tags, attribute names, event names) are interned once +/// and referenced by u16 id thereafter (`cache-string`); dynamic text rides +/// in the operations themselves. /// -/// The wire format below is normative and versions with this WIT package: -/// any change to opcodes, operand layouts, or framing bumps the package -/// version (instantiation is digest-checked, so a mismatch fails loudly and -/// early). -package polymorph:dioxus@0.4.0; +/// The `mutations` interface is the normative schema and versions with this +/// WIT package: any change to the operation vocabulary or its record shapes +/// bumps the package version (instantiation is digest-checked, so a mismatch +/// fails loudly and early). +package polymorph:dioxus@0.5.0; /// Typed event payloads, host-serialized at dispatch. One snapshot per /// event; fields not applicable to an event family are simply absent by @@ -296,65 +295,37 @@ interface events { } } -/// The explicit-schema alternative to the byte format documented on `run`: -/// the same op vocabulary, spelled as WIT records and one `operation` -/// variant, delivered as `stream` by `run-typed`. Exactly one of -/// the two channels is used per instance; the host picks at mount. This -/// interface exists so the two encodings can be benchmarked against each -/// other — the byte format on `run` remains the measurement baseline and is -/// unchanged. +/// The mutation protocol: the op vocabulary the guest renders through, +/// spelled as WIT records and one `operation` variant, delivered as +/// `stream` by the world's `run` export. This interface is +/// normative — there is no other encoding of a mutation. /// -/// Semantics are inherited wholesale from `run`'s doc comment: element ids -/// are Dioxus ElementIds (slab indices, id 0 = mount root), `m` is the -/// "top m nodes of the stack" count, `path` is a list of child indices, -/// stack semantics are Dioxus's mutation-stack semantics, and `bubbles` on -/// the listener ops is dioxus-html's `event_bubbles` verdict (the host keys -/// its root-delegated vs per-element strategy off it). Interning is -/// unchanged too: a `str-ref` is an id defined by a prior `cache-string` -/// operation, in this batch or any earlier one, and the guest emits each -/// definition exactly once per instance. -/// -/// Three representational differences from the byte format, all forced: +/// The vocabulary mirrors dioxus-core 0.7's `WriteMutations` trait plus two +/// protocol ops (`cache-string`, `register-template`). Element ids are +/// Dioxus ElementIds (slab indices, dense; id 0 is the mount root — the +/// host-designated element the app renders into), `m` is the "top m nodes of +/// the stack" count, `path` is a list of child indices, and stack semantics +/// are Dioxus's mutation-stack semantics, identical to its own web +/// interpreter. `bubbles` on the listener ops is dioxus-html's +/// `event_bubbles` verdict, looked up guest-side: the host delegates +/// bubbling events at the mount root and attaches non-bubbling listeners per +/// element, so it needs the same bit on removal to find the registration. /// -/// - **`option` replaces the `0xffff` sentinel** at the type level: -/// a namespace operand's optionality is expressed by the type rather than -/// by a reserved id value. Note this does NOT free the id: both channels -/// assign ids from one `Interner`, whose guard still refuses to hand out -/// `0xffff` (src/protocol.rs `intern_raw`), so the typed channel inherits -/// the byte format's 65535-string ceiling without getting anything for it. -/// - **`register-template` carries an arena, not a tree.** The natural -/// shape is recursive — `record template-element { children: -/// list }` — and WIT forbids it: recursive type -/// definitions are rejected outright (`wasm-tools`: "type `template-node` -/// depends on itself"). So `nodes` is a flat pre-order list, `roots` and -/// every element's `children` are `u32` indices into it, and the host -/// rebuilds the tree by indexing. This is a real expressiveness limit of -/// the typed schema and a cost the byte format does not pay: its `node` -/// grammar is self-delimiting and recursive at no charge. It also means -/// the typed form admits malformed arenas (out-of-range or cyclic -/// indices) that the byte grammar cannot express. -/// - **The world's `use mutations.{operation}` makes this interface an -/// import of every component built against the world**, whether or not it -/// mounts the typed channel: `wasm-tools component wit` on a guest shows -/// `import polymorph:dioxus/mutations@0.4.0` alongside `events` and `dom`. -/// Nothing needs supplying (the interface has no items — the import -/// exists only to name the types), but it is a change to the *byte* -/// channel's own component type: a host that only ever calls `run` still -/// sees a new entry in the instantiation surface. Adding a typed channel -/// is therefore not free for guests that never use it. +/// Interning: a `str-ref` is an id defined by a prior `cache-string` +/// operation, in this batch or any earlier one, and the guest emits each +/// definition exactly once per instance. Event names cross back on +/// `handle-event` as the same interned u16, so steady-state event dispatch +/// transfers no string data at all. /// -/// (Version note: this interface is added to `polymorph:dioxus@0.4.0` -/// without a package bump, deliberately, and that is a real shortcut rather -/// than a formality. `app` gained a *required* export, so `0.4.0` now -/// denotes two mutually incompatible worlds: any guest already built -/// against the old 0.4.0 no longer satisfies it, and the mismatch is not -/// detectable by version. `fixtures/surface-probe` needing a stub -/// `run-typed` arm to keep compiling is that incompatibility showing up in -/// the small. The shortcut is taken because this is a spike branch -/// evaluating the typed channel and a bump rewrites every interface id in -/// the host wiring, swamping the change under measurement — but it must not -/// be carried forward: whichever way the spike lands, the result ships with -/// a version bump.) +/// **`register-template` carries an arena, not a tree.** The natural shape +/// is recursive — `record template-element { children: list }` +/// — and WIT forbids it: recursive type definitions are rejected outright +/// (`wasm-tools`: "type `template-node` depends on itself"). So `nodes` is a +/// flat pre-order list, `roots` and every element's `children` are `u32` +/// indices into it, and the host rebuilds the tree by indexing. The arena +/// therefore admits malformed index graphs — out-of-range or cyclic indices — +/// that a recursive shape could not express, and the host must validate them +/// rather than trust them. interface mutations { /// An interned string id, defined by a prior `cache-string` operation. @@ -407,9 +378,8 @@ interface mutations { record load-template { id: element-id, tmpl: u16, root: u16 } record set-text { id: element-id, text: string } - /// The byte format's `attrval` kinds. `none` removes the attribute - /// unconditionally (it is not an empty text value); the host applies the - /// same property-reset rules `run`'s doc comment spells out. + /// An attribute's new value. `none` removes the attribute + /// unconditionally — it is not an empty text value. variant attr-value { text(string), float(f64), @@ -418,6 +388,21 @@ interface mutations { none, } + /// Set (or, with `value: none`, remove) one attribute on a live element. + /// + /// The host applies Dioxus's attribute semantics: value/checked/selected + /// as properties, the style namespace via the style object, + /// dangerous_inner_html, and boolean-attribute removal on false. + /// + /// `none` routes via removeAttribute/removeAttributeNS/ + /// style.removeProperty as the name and namespace dictate; the + /// boolean-attribute truthiness table governs only text/bool VALUES. + /// Property-backed fields get the same reset the truthy-value path uses + /// instead of a bare removeAttribute, since removeAttribute alone does not + /// clear a live element's property: value -> "" (+ removeAttribute), + /// checked -> false, selected -> false, dangerous_inner_html -> innerHTML + /// = "". (Ported from dioxus v0.7.10's `remove_attribute` sledgehammer op + /// in packages/interpreter/src/unified_bindings.rs.) record set-attribute { id: element-id, name: str-ref, @@ -427,9 +412,7 @@ interface mutations { record event-listener { id: element-id, name: str-ref, bubbles: bool } - /// One mutation. The arms are the byte format's opcodes in opcode order - /// (0x01 cache-string through 0x11 push-root); arms whose only operand is - /// an element id carry it bare. + /// One mutation. Arms whose only operand is an element id carry it bare. variant operation { cache-string(cache-string), register-template(register-template), @@ -540,171 +523,42 @@ world app { import dom; /// Start the app and return the mutation channel: the host receives the - /// read end, parks a direct-read session on it for the life of the - /// instance (polyengine amendment A21 `readDirect` — the decode callback - /// runs synchronously inside the guest's `stream.write` rendezvous over a - /// view aliasing guest linear memory; zero copies), and applies decoded - /// batches to the mount root. The app's scheduler keeps running after the - /// return as a spawned task: initial mount (rebuild → one batch), then - /// re-renders forever. That task's persistent park between renders (a - /// plain Rust future woken cross-task, no WIT waitable) is legal because - /// the host's parked direct-read session is amendment-A15 host retention, - /// making a quiescent instance the documented embedder-may-act state — and - /// it also means app failure surfaces on the channel the host actually - /// watches: a trap rejects the parked session (`PeerTrappedError`). + /// read end, reads batches of `operation`s from it for the life of the + /// instance, and applies them to the mount root. The app's scheduler keeps + /// running after the return as a spawned task: initial mount (rebuild → + /// one batch), then re-renders forever. One batch is one stream write of + /// the whole `list`. /// /// Returning the stream (rather than passing it to a host import) makes /// the channel's lifecycle structural: it exists iff the app started, - /// exactly once, tied to this instance. - /// - /// # Batch layout - /// - /// A batch has two segments: - /// - /// - **string segment**: UTF-8 bytes, the concatenation of every dynamic - /// string operand (`dynstr` below) in op order. Decoded host-side in one - /// pass (`TextDecoder("utf-8", { ignoreBOM: true })` — BOM stripping must - /// be disabled or a leading U+FEFF in the first string would misalign - /// slicing), then sliced per-operand by UTF-16 code-unit lengths carried - /// in the op segment. - /// - **op segment**: opcodes and operands, little-endian, unaligned. - /// - /// # Framing - /// - /// ```text - /// frame := frame-len:u32 strings-len:u32 strings:u8{strings-len} ops:u8{rest} - /// frame-len = byte length of everything after the frame-len field - /// = 4 + strings-len + len(ops) - /// ``` + /// exactly once, tied to this instance. It also means nothing may be + /// written before the reader is returned — such a write would park while + /// the host is still awaiting `run`'s promise for the reader it needs in + /// order to read — and that the scheduler cannot live in this export's own + /// body, since an async export's body returning is task.return followed by + /// task exit. /// - /// Frames arrive back-to-back; a delivery may contain several frames or a - /// partial one (stream writers chunk internally). The decoder consumes - /// only whole frames from a direct-read view and leaves a partial tail - /// unread (redelivered at the next rendezvous); if it cannot consume at - /// least one byte it stages the partial tail instead (a direct-read - /// callback must not acknowledge zero bytes and keep the session parked). - /// - /// # Primitive operand encodings - /// - /// ```text - /// u8/u16/u32/s64/f64 : little-endian, unaligned - /// id : u32 — Dioxus ElementId (slab index; dense). id 0 is the mount - /// root: the host-designated element the app renders into. - /// m : u32 — "top m nodes of the stack" count - /// tmpl : u16 — guest-assigned template id - /// path : u8 length, then that many u8 child indices - /// strref : u16 — interned string id, defined by a prior cache-string op - /// (in this or any earlier batch). The value 0xffff is a - /// "none" sentinel admitted ONLY by namespace operands - /// (every `ns:strref` below, including template element and - /// template attribute namespaces); tag/name operands are - /// mandatory ids and never carry the sentinel. - /// dynstr : u16 UTF-16 code-unit length, then (iff 0xffff) u32 actual - /// length. Content is the next `length` code units of the decoded - /// string segment, consumed sequentially. The encoder appends the - /// string's UTF-8 bytes to the string segment and writes its - /// UTF-16 code-unit count here (Rust: sum of char::len_utf16, - /// with an all-ASCII fast path where it equals the byte length). - /// ``` - /// - /// # Opcodes - /// - /// The vocabulary mirrors dioxus-core 0.7's `WriteMutations` trait plus - /// two protocol ops (cache-string, register-template). Stack semantics - /// ("push", "top m nodes") are Dioxus's mutation-stack semantics, - /// identical to its own web interpreter. - /// - /// ```text - /// 0x01 cache-string id:u16 s:dynstr - /// Define (or overwrite) interned slot `id`. Slots are guest-managed; - /// ids are assigned monotonically from 0 in practice. - /// 0x02 register-template tmpl:u16 nroots:u16 root:node{nroots} - /// node := kind:u8 ... - /// 0x00 element tag:strref ns:strref nattrs:u16 - /// attr{nattrs} := name:strref ns:strref value:dynstr - /// nchildren:u16 node{nchildren} - /// 0x01 text value:dynstr - /// 0x02 dynamic (placeholder for a runtime-supplied node) - /// Registers a static template; the host materializes it once - /// (detached) and later load-template clones root subtrees from it. - /// Guest sends each distinct template once, before its first use - /// (templates are 'static with unique identity, keyed guest-side by - /// pointer). - /// 0x03 append-children id m - /// 0x04 assign-id path id - /// 0x05 create-placeholder id - /// 0x06 create-text-node id text:dynstr - /// 0x07 load-template tmpl root-index:u16 id - /// 0x08 replace-with id m - /// 0x09 replace-placeholder path m - /// 0x0a insert-after id m - /// 0x0b insert-before id m - /// 0x0c set-attribute id name:strref ns:strref value:attrval - /// attrval := kind:u8 ... - /// 0x00 text s:dynstr - /// 0x01 float f64 - /// 0x02 int s64 - /// 0x03 bool u8 - /// 0x04 none (remove the attribute unconditionally — routed via - /// removeAttribute/removeAttributeNS/style.removeProperty - /// as the name/namespace dictates; the boolean-attribute - /// truthiness table governs only text/bool VALUES. - /// Property-backed fields get the same reset the - /// truthy-value path uses instead of a bare - /// removeAttribute, since removeAttribute alone does not - /// clear a live element's property: value -> "" (+ - /// removeAttribute), checked -> false, selected -> - /// false, dangerous_inner_html -> innerHTML = "". - /// Ported from dioxus v0.7.10's `remove_attribute` - /// sledgehammer op in - /// packages/interpreter/src/unified_bindings.rs.) - /// The host applies Dioxus's attribute semantics (value/checked/ - /// selected as properties, style namespace via style object, - /// dangerous_inner_html, boolean attribute removal on false). - /// 0x0d set-text id text:dynstr - /// 0x0e new-event-listener id name:strref flags:u8 - /// 0x0f remove-event-listener id name:strref flags:u8 - /// flags bit0 = the event bubbles (dioxus-html's event_bubbles table, - /// looked up guest-side): the host delegates bubbling events at the - /// mount root and attaches non-bubbling listeners per element, so it - /// needs the same bit on removal to find the registration. Bits 1..7 - /// reserved, must be 0. - /// 0x10 remove id - /// 0x11 push-root id - /// ``` - /// - /// Event names cross back on `handle-event` as the same interned u16, so - /// steady-state event dispatch transfers no string data at all. + /// The scheduler task's persistent park between renders (a plain Rust + /// future woken cross-task, no WIT waitable) is legal because the host + /// retains the lifted readable end of this stream for the instance's + /// lifetime. Under polyengine's retention rule — issue #162, + /// `.deps/polyengine/contracts/embedder-api.md` §"Streams and futures", + /// "while the host retains a way to act on a stream/future — a retained + /// end, a parked host operation, or an unfinished producer pump — a + /// stalled guest is reported as the documented embedder-may-act hang, + /// never a deadlock trap" — a retained end alone suffices; the host need + /// not also have a read parked. That is exactly what the runtime + /// implements: `HostActivity` + /// (`.deps/polyengine/runtime/src/exec/host_streams.ts`) arms on the + /// retained end and disarms only when that end is lowered back into a + /// guest. So a quiescent instance is the documented embedder-may-act + /// state, and app failure still surfaces on the channel the host watches: + /// a trap rejects the host's pending read (`PeerTrappedError`). /// /// (Historical: earlier revisions passed this stream to a host import /// `surface.open`, alongside a since-retired synchronous call transport — /// see bench/README.md for the retirement record.) - export run: async func() -> stream; - - /// The typed twin of `run`: the same channel with the explicit schema of - /// the `mutations` interface instead of the hand-rolled byte format. - /// - /// Lifecycle is identical in every respect — it returns the read end and - /// spawns the scheduler as a separate task (see `run`'s doc for why the - /// scheduler cannot live in the export's own body, and why nothing may be - /// written before the reader is returned), initial mount is one batch, and - /// each subsequent render flushes one batch. One batch is one stream - /// write of the whole `list`: writes-per-batch is the thing - /// being measured, so it stays at one on both channels. - /// - /// **Exactly one of `run` / `run-typed` is called per instance** — the - /// host picks the channel at mount. Calling both is not supported: they - /// would share one VirtualDom and one interner, and each would see half - /// the mutations. - /// - /// Unlike `stream`, a typed stream has no zero-copy direct-read path - /// (polyengine amendment A21's `readDirect` is `stream` only, since it - /// hands the callback a view aliasing guest linear memory and only a byte - /// stream *has* a memory representation to alias). The host therefore - /// reads this one with an ordinary `read()`, paying lift/lower for every - /// record — which is precisely the cost the benchmark is here to - /// quantify. - export run-typed: async func() -> stream; + export run: async func() -> stream; /// Dispatch one DOM event to the listener registered on `target` (the /// ElementId carried by new-event-listener) for the interned event name