Skip to content

feat(core): retention and ownership protocol for render-plan publications - #110

Merged
thejustinwalsh merged 36 commits into
feat/paragraph-apifrom
feat/plan-retention
Aug 25, 2026
Merged

feat(core): retention and ownership protocol for render-plan publications#110
thejustinwalsh merged 36 commits into
feat/paragraph-apifrom
feat/plan-retention

Conversation

@thejustinwalsh

Copy link
Copy Markdown
Collaborator

Audit item 11. A host that wants the render plan rather than our Object3Ds can now hold it safely, and the example renderer proves it against a real engine.

The problem

TextEnginePublication is borrowed only until the next Wasm call, and TextEngineRenderPlanView.bytes() returns a view straight into Wasm memory. Nothing said so, nothing helped, and a retained host that kept one read freed or reallocated bytes on its next frame — silently.

The protocol

No new draw-batch API. The plan already models what a host needs; what was missing was ownership, so this lives on the existing session.

Question Answer
Borrowed or copied? Borrowed by default. A copy is branded: session.retain() returns RetainedTextEnginePublication carrying a unique symbol, so an API that retains demands it in its parameter type rather than in prose
When does a borrow expire? When the session answers any later call — an update, a measure, a failed attempt that reserves capacity — or on disposal. Slot arithmetic exists in Rust and hosts are deliberately not asked to track it
Detecting an expired borrow isExpired() is two integer compares: the publicationGeneration epoch and Wasm buffer identity. assertLive() throws naming both generations, and a publication the session never issued is rejected outright
Acknowledgement retain() and acknowledge() advance session.acknowledgedGeneration, which frame requests carry. This is load-bearing rather than bookkeeping: Rust retirements carry afterPublicationGeneration and the engine already rejects a backwards acknowledgement with a revision conflict — now pinned by test
Dirty ranges or whole arrays? Ranges, surfaced as decoded records: readTextEnginePatch, readTextEngineResource, readTextEngineBuffer, readTextEngineRetirement, keyed by (id, generation)
Identity across updates Paragraph ids are caller-chosen handles, glyph identity rides the stable-id lane, and retirement is the only release signal

The brand is the point. A host cannot accidentally pass a borrowed publication where a retained one is required, because the type will not allow it.

The example renderer stops being a stub

It authors its own technique schema and policy through /core's compilers, then drives a real TextEngineHost over the published Wasm artifact through real frames. Its tests hold a retained plan across three frames and a capacity growth, watch a stale borrow die loudly, replay an old acknowledgement at the wire and observe status 12, and decode patch ranges.

The boundary test now scans tests/ as well as src/, and rejects every @pmndrs/glyph subpath except /core and the published .wasm.

A hole it found, recorded rather than worked around

A real font cannot be driven through real text frames from the published surface. RuntimeShaper.registerFont demands loader-registered state, Rust refuses registerFontBinding with fontMissing without it, and createTextRuntime is exported only from the root entry. That is audit item 12, the host font path, now confirmed with evidence instead of suspicion — and it is exactly the class of gap this package exists to surface. It was recorded as a finding rather than solved with a private import.

Verification

mise exec -- pnpm check at the repository root: exit 0.

@github-actions

github-actions Bot commented Aug 23, 2026

Copy link
Copy Markdown

size-limit report 📦

Path Size
Core JS (gzip) 22.25 KB (-0.11% 🔽)
Shaper Wasm (gzip) 431.37 KB (0%)
Three.js adapter JS (gzip) 70.37 KB (+0.4% 🔺)
Inter font · Bitmap (gzip) 545.27 KB (0%)
Inter font · MTSDF (gzip) 6.48 MB (0%)
Inter font · Slug (gzip) 604.06 KB (0%)
Font Awesome icons · Bitmap (gzip) 439.53 KB (0%)
Font Awesome icons · MTSDF (gzip) 6.89 MB (0%)
Font Awesome icons · Slug (gzip) 642.64 KB (0%)
Font validator JS (gzip) 134.24 KB (0%)
Runtime bake host JS (gzip) 5.41 KB (0%)
Runtime bake Worker JS (gzip) 142.47 KB (0%)
Font baker JS (gzip) 2.41 KB (0%)
Font baker Wasm (gzip) 379.63 KB (0%)
Bitmap baker JS (gzip) 4.66 KB (0%)
Bitmap baker Wasm (gzip) 228.34 KB (0%)
MTSDF baker JS (gzip) 5.39 KB (0%)
MTSDF baker Wasm (gzip) 209.22 KB (0%)
Slug baker JS (gzip) 4.01 KB (0%)
Slug baker Wasm (gzip) 181.26 KB (0%)

@thejustinwalsh
thejustinwalsh force-pushed the feat/plan-retention branch 2 times, most recently from a91550b to d3c1e20 Compare August 24, 2026 06:34
@thejustinwalsh
thejustinwalsh force-pushed the feat/plan-retention branch 3 times, most recently from 425a3c7 to bdeb190 Compare August 24, 2026 10:38
`Paragraph.measure()` and `.layout()` returned a result union, so every
caller wrote `if (result.ok)` -- inside a flexbox measure callback, many
times per layout -- to guard a branch that only means this package broke
its own invariant. Measurement is synchronous and takes no resource that
could be missing: the font is required at construction, the text and
spans are validated there, and a constraint that is not finite and
nonnegative already throws from the call. There was no failure left to
return.

They now return the metrics and the inspection directly. The revision is
read from `paragraph.layoutRevision`, which already carried it.
`ParagraphMeasureResult`, `ParagraphLayoutResult`, and
`ParagraphQueryError` are gone from `/core`.

This is the same mistake as the frame latch in a different place, and
both came from believing rejections were caller-reachable. A throw is the
caller's arithmetic; a persistent broken state is our defect. Neither is
a return value.

Codified as the `engine-call-contract` skill, together with the entry
point rule the surface audit produced: a type an application can
encounter lives at the root, a thing only an integrator constructs lives
in `/core`, and a renderer type never enters the shared vocabulary to
make an integration convenient.
The Three.js example imported `txt` from `/three`, which no longer
re-exports it; authoring comes from the root. TypeGPU was described as
future work when the Bitmap and Slug realizations ship today.

Adds the two things a reader could not find at all. Where to import from,
stated once as the rule the surface is built on: a type an application
can encounter lives at the root, a thing only an integrator constructs
lives in `/core`, and `/core` is additive to the root rather than
parallel to it. And measuring before rendering, which is the question
people actually arrive with -- `Paragraph` measures synchronously with no
scene, renderer, world matrix, or committed frame, in paragraph-local
values, with ink bounds beside the advance extent so the example shows
which one centres text visually.
Conformance captures, contract goldens, the uikit fixture's resolved-box
readback, and the kernel fixture all consume positioned columns, so they
call layout(). The two stale survivors of the old names (inspectLayout on
the contract runtime and the kernel fixture, measureLayout in the layout
benchmark) are re-pointed too. Summary-only sites keep measure().
…t split

The two-cache split moved +398 raw through the core subpath and the runtime
graphs; ceilings and generated evidence follow the source. The README and
the benchmarks package concept describe the settled query names.
The rename landed in source and tests but six canonical documents still
described the old split, and `uikit-integration.md` published an example
whose declared return types (`ParagraphMeasureResult`,
`ParagraphLayoutResult`) were deleted with the result union, so it no
longer type-checked.

`paragraph.ts` also kept the JSDoc for that deleted union, immediately
above the comment that correctly states a query answers or throws.

Historical entries in `docs/log.md` and D-272's account of the previous
surface keep the old names: they describe what was true when written.
…s the rest

The ~30 KB step across four runtime graphs is `unicode-segmenter`,
entering through internal/graphemes.ts so caller-side span alignment agrees
with the engine's cluster grid. One module, four importers. Both hosts
measure identically, so the ceilings are now reviewed rather than raised
around an open question.

F10-F16 record what this stack's adversarial reviews found and could not
absorb: the font-binding compiler naming every technique (which is what
actually blocks Paragraph from the root entry), digests committed per
commit, the false ascent+descent invariant, word ranges splitting astral
characters, layoutRevision not advancing, mutable cached arrays published
as results, and ParagraphOptions admitting two span authorities.
F10 described a bundling coupling. The real defect is a capability one:
loadedFontBindingBytes is a closed branch over the three first-party
techniques that throws on any other id, and defineRasterTechnique gives a
technique no way to supply a compiler -- while the pieces to build one are
all exported from /core. An integrator can build a correct binding and has
nowhere to plug it in, which contradicts /core existing so a renderer can
drive the engine itself.

Recommends carrying the compiler on the technique rather than registering
by import side effect, which would add order dependence and re-create the
late-registration hazard F6 already records.
… is not

RasterBakerModule carries behaviour -- descriptor() and bake() -- so a
third party implements it and bakes. RasterTechnique carries only
identity, so the consuming side must know every technique by name: two
closed branches that both claim something is 'registered' when nothing is,
plus a third in the plan target. A third-party technique can therefore
bake an artifact and then neither bind a font nor resolve a Three
resource from it.

The technique already owns the data shape and the binding layout; only the
compiler between them was split into core/. F10 now asks for the twins to
have the same shape rather than for one more switch.
A third party can write a RasterBakerModule and bake, then neither bind a
font nor render what they baked: RasterTechnique carries identity with no
behaviour, so three closed branches supply it by name and throw on any
other id. The pieces to build a binding are exported from /core, so an
integrator can produce correct bytes and find nowhere to put them.

Two seams, deliberately different. The portable binding compiler goes on
the technique -- every call site already holds it, so a registry would add
order dependence, a sideEffects declaration, and a late-registration
hazard to buy nothing. The renderer resource resolver goes on the Three
plan program, which is already registered per technique and already
rejects late registration. Bakers need discovery rather than registration,
since the caller passes the module.

Acceptance is a technique defined outside the package completing bake,
bind, and render in the example renderer.
…er half

The first draft claimed a third-party technique cannot render. It can,
through Text: three/engine-runtime.ts:201 checks the plan-program registry
before falling back to the first-party branch, and glyph-example-raster
registers exactly that way. The real defect is narrower and worse.

ThreeRasterPlanProgram registers three things and only createMaterial is
renderer-specific. PolicyProgram is entirely numbers and core/render-policy
imports only textShaperAbi; compileFont's helpers all come from core. So
two thirds of a technique's contract is portable by construction and is
reachable only through a Three-scoped registry.

paragraph.ts:689 calls loadedFontBindingBytes with no registry lookup, so
the same third-party technique that works through Text throws through
Paragraph -- the framework-neutral surface /core exists to provide -- and a
non-Three engine can never register anything.

Splitting the portable half into /core makes a technique cost once for
every engine plus once per engine for materials, instead of once per engine
for all of it.
…bjects

ThreeTextEngineResource reads as renderer-owned because of where it lives,
but every first-party variant is a technique data type -- BitmapStrikeData,
MsdfData, SlugPageData -- and the DataArrayTexture is built from those bytes
later, in the plan target. The baked payload carries the data, compileFont
yields handles into it, and each engine binds them its own way.

That makes compileFont portable after all, so the split does not have to
answer for resources crossing a renderer boundary; it has to rename the type
and leave the texture caches where they already are.
…tion

ThreeTextEngineResource is a closed union whose third-party escape hatch is
resource: unknown, so an external technique gets no safety and cannot add a
member. RasterTechnique already carries a phantom type map, and Data is
already an open third-party type recovered through RasterDataOf; Resource
rides the same rail, so the union collapses to one generic shape and the
type flows from the technique the caller already holds.

Module augmentation is the wrong tool here: it is global and single-instance,
so two versions of a technique package collide, and a site generic over
techniques cannot say which technique a resource belongs to.

Also corrects this document: RasterTechnique is not behaviourless. It has
descriptor(), decode(), dispose(), and an optional runtimeBaker -- it decodes
its own artifact and owns that data's lifetime. The gap is only the binding
compiler.
The README tells a reader that /core is for implementing an integration --
render policy, render plan, frame wire -- and then never names the 692-line
guide that teaches exactly that. docs/index.md lists a raster/baker plugin
guide but not this one, so renderer-integration.md was reachable only from
the planning index, which is where maintainers track work rather than where
integrators learn.

Links it from both entry points a reader actually uses.
The pipeline diagram showed mutations flowing to draws but never who owns
each stage, so a reader could not tell what they write once and what they
write again per engine. The policy is numbers, the font binding is Rust
wire bytes, and plan resources are handles into the baked payload -- only
buffer/texture binding and material realization are engine objects.

Also sends a new engine integration to the renderer integration guide
rather than to two planning documents; the guide walks the same five
responsibilities with working code.
example-raster and example-renderer are disjoint proofs. The technique
registers through /three, so it proves technique-on-Three; the engine
imports only /core but consumes no technique at all -- its one reference
is a comment. Third-party technique inside third-party engine is untested,
and it is the only cell where neither side can be special-cased.

Every other cell passes today while paragraph.ts:689 still resolves no
registry, which is how this survived.
A third-party baker is 24 lines because RasterBakerModule asks only for
what the author alone can supply. The same author needs 594 lines total,
and two of the causes are ours: the GLB container writer exists in
internal/compose-bake.ts and the validators but is exported nowhere, so
artifact.ts re-derives chunk magic and 4-byte alignment; and three.ts
conflates portable policy and font binding with Three material
realization, so a second engine would rewrite code that is already
renderer-free.

Re-measure after the split. If a technique is not close to its baker in
size, the consume-side contract is still asking for the wrong things.
… container

The previous revision asserted the GLB writer should be exported. That
skipped the question: BakeArtifact carries raw bytes with no stated
container, and first-party bakers write no GLB -- compose-bake wraps for
them. Either role 'raster' requires a container, and a third-party baker
genuinely cannot produce one without private code, or the role accepts
arbitrary bytes and example-raster chose GLB for realism.

Only the first case justifies a new export, and this audit has deleted
eight subpaths for having no consumer outside the package. Settle it
first; if a seam is warranted it is integrator-only and belongs beside the
bake contract, not at the root.
…dary

Reading is already solved and costs nothing new: the package parses the
font container once in loader.ts and a technique's decode reads bufferView
references off RegisteredFont/RegisteredRaster, never chunks or magic
numbers. Techniques are GLB so they can be packed, not because rendering
needs it.

Writing is the only gap, it is bake-time, and the tiers that keep it out of
application bundles already exist -- the root runtime graph pulls no
compose-bake, ./bake is bake-time, ./runtime-bake is deliberate. The writer
belongs behind ./bake, which is already public, so no new seam is opened.
…ce the core ceiling

The regression test added a layer below calls measure(), which this layer
renames to layout(), so the runtime gate failed with 'paragraph.measure is
not a function'. It was written after the rename and never swept.

core-subpath-js measured 299,673 against a 300,000 ceiling, which the
foreign host cleared by less than the host gap, so CI failed on 327 bytes
of headroom rather than on growth. Re-priced with room, and the three
remaining fields evened up so the next small change does not trip a
different one.
Five claims did not survive verification. Policy numbers live in a
host-specific namespace -- Three owns system buffer 15, the non-Three
example defines its own 20 -- so the descriptor cannot move to /core
unchanged; the portable half is the schema plus a policy-body factory each
engine finishes. compileFont names a Three-owned parameter and the fourth
ThreeTextEngineResource variant carries a program exposing createMaterial,
so that union is not renderer-free. The matrix marked a cell covered that
is not: example-renderer registers no binding, publishes empty draws, and
has no device. A non-Three engine is not blocked today, only made to
re-author. Baker discovery already exists through package.json#pmndrs.glyph
and runtimeBaker, so that step is removed.

Resource realization joins the renderer half, Resource infers at
registerRasterPlanProgram where retain() gives an inference site, and the
core registry is scoped to id lookup and binding composition.
@thejustinwalsh
thejustinwalsh merged commit c7df21e into main Aug 25, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant