From df865ecee7ff3dc7331e2410f0608dcd9235f49a Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 17 Jul 2026 15:35:36 -0500 Subject: [PATCH 01/21] Document registering fonts for scripts beyond bundled coverage - New Fonts page: the Options.fonts / registerCanvasFont path with a CJK anchor example, every registration error and its teaching, ownership until Runtime.deinit, how typography tokens resolve a registered face, and the honest platform + TypeScript-tier status - Cross-link the tofu guard's teaching in Native UI and the theming typography group to the new page --- docs/src/app/fonts/layout.tsx | 7 +++ docs/src/app/fonts/page.mdx | 85 +++++++++++++++++++++++++++++++++ docs/src/app/native-ui/page.mdx | 2 +- docs/src/app/theming/page.mdx | 2 +- docs/src/lib/docs-navigation.ts | 1 + docs/src/lib/page-titles.ts | 1 + 6 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 docs/src/app/fonts/layout.tsx create mode 100644 docs/src/app/fonts/page.mdx diff --git a/docs/src/app/fonts/layout.tsx b/docs/src/app/fonts/layout.tsx new file mode 100644 index 000000000..07feee6b6 --- /dev/null +++ b/docs/src/app/fonts/layout.tsx @@ -0,0 +1,7 @@ +import { pageMetadata } from "@/lib/page-metadata"; + +export const metadata = pageMetadata("fonts"); + +export default function FontsLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/docs/src/app/fonts/page.mdx b/docs/src/app/fonts/page.mdx new file mode 100644 index 000000000..2c3c4ccfb --- /dev/null +++ b/docs/src/app/fonts/page.mdx @@ -0,0 +1,85 @@ +# Fonts + +The SDK bundles two faces — Geist Regular and Geist Mono — and every built-in component renders through them by default. Bundled coverage is Latin-script: a codepoint outside it (CJK, emoji, most symbols) draws as the face's notdef box — tofu. Registering a font is how an app renders a script the bundled faces do not cover: register a face that covers the characters, point the typography tokens at it, and both renderers ink the registered outlines. Chinese and Japanese text render this way today. + +## Registering a face + +Declare fonts on `UiApp.Options.fonts` and the app registers them on the installing frame, before the first layout measures anything: + +```zig +const MyApp = native_sdk.UiApp(Model, Msg); + +/// App-chosen id, at or above canvas.min_registered_font_id (64). +/// Store the id in your theme; ids below 64 are reserved for the +/// built-in faces. +pub const cjk_font_id: canvas.FontId = canvas.min_registered_font_id; + +const app_fonts = [_]MyApp.FontRegistration{.{ + .id = cjk_font_id, + .name = "NotoSansSC-Regular.ttf", // teaching errors name this + .ttf = @embedFile("fonts/NotoSansSC-Regular.ttf"), +}}; + +fn tokensFromModel(model: *const Model) canvas.DesignTokens { + var tokens = canvas.DesignTokens.theme(.{ .color_scheme = model.scheme }); + // Every text run reads the typography tokens: point the face slot + // at the registered id and the whole app prints in that face. + tokens.typography.font_id = cjk_font_id; + return tokens; +} + +pub fn options() MyApp.Options { + return .{ + .name = "hello-cjk", + .scene = shell_scene, + .canvas_label = "canvas", + .update = update, + .view = view, // e.g. ui.text(.{}, model.greeting) with greeting = "你好,世界" + .tokens_fn = tokensFromModel, + .fonts = &app_fonts, + }; +} +``` + +The face must be a TrueType (`glyf`) file. The Google Fonts TrueType builds of the Noto CJK faces (Noto Sans SC/TC/JP/KR, OFL-licensed) register as-is; commit the file with its license next to your sources, like any other asset. Underneath `Options.fonts` sits the runtime seam the option calls, one face at a time — embedders and custom lifecycles use it directly: + +```zig +try runtime.registerCanvasFont(cjk_font_id, ttf_bytes); +``` + +Register at startup so the first layout already measures with the face. Late registration still works honestly: every open surface re-measures and repaints when a face joins the registry. + +## Validation is loud and registration-time only + +Every failure happens at registration, never at render time — a registered id always resolves when text draws. `Options.fonts` translates each error into a warning naming the font before propagating it: + +- `error.InvalidFontId` — id 0 is the "inherit run font" sentinel and can never hold a face. +- `error.ReservedFontId` — ids below `canvas.min_registered_font_id` (64) belong to the built-in faces. +- `error.FontTooLarge` — the per-font bound is 24 MiB (`canvas_limits.max_registered_canvas_font_bytes`), sized so full CJK files fit: measured Google Fonts TrueType builds run about 9.6 MB (Noto Sans JP) to 17.8 MB (Noto Sans SC). +- `error.FontParseFailed` — not a parseable TrueType `glyf` face; `canvas.font_ttf.parseFailureReason(ttf)` names what is wrong (CFF/OpenType `.otf` files fail here — use the TrueType build). +- `error.FontExceedsGlyphBudgets` — the face's `maxp` table declares glyphs denser than the renderer's outline budgets: 1024 points and 128 contours per glyph, with flattened composites held to the same numbers (at most 4 levels deep, 8 components). The budgets are sized from measured production CJK faces — the densest measured glyph (a 738-point brush kanji in Yuji Mai; Noto Sans TC's worst is 685 points / 87 contours) sits inside them with headroom — so this refuses only outliers whose densest glyphs could not render as outlines. The registration warning names the face's declared maxima against the budgets; `canvas.font_ttf.declaredGlyphMaxima(ttf)` exposes the same numbers to code. +- `error.FontIdInUse` — registration is permanent for the runtime's lifetime and there is deliberately no unregister: glyph caches key by (font id, glyph id) with no content fingerprint, so replacing an id's bytes would serve stale glyphs. Re-registering an id fails; give each face its own id. +- `error.FontRegistryFull` — at most 8 registered faces per runtime (`canvas_limits.max_registered_canvas_fonts`). +- `error.FontHostRegistrationUnsupported` — the platform measures and draws text host-side but has no font registration seam, so the face could not be honored pixel-honestly. No current platform returns this; it exists so a future host can never silently substitute the default family. + +## Ownership and lifecycle + +Registration copies the file into an exact-size heap allocation owned by the runtime, so the caller's buffer (an `@embedFile` slice, a transient read) is free the moment the call returns. A runtime with no registered fonts carries zero font bytes. The copy lives until `Runtime.deinit` — registration is permanent, so ownership is one allocation and one free. Apps that live for the process never think about this; embedders that create and destroy runtimes call `Runtime.deinit` (the SDK's own embed hosts already do). + +## How text finds the face + +There is no per-widget font attribute. Text resolves its face through the typography tokens — `typography.font_id` for every ordinary run, `typography.mono_font_id` for mono-flagged runs, and optional `typography.button_font_id` for control labels — so a registered face is a theme decision, and the id rides everywhere a `canvas.FontId` rides: token overrides, both renderers, glyph atlas keys, render fingerprints. + +Layout and ink stay in lockstep. On platforms that measure text host-side (macOS), the raw bytes reach the host at registration time, so CoreText measurement and packet text drawing resolve the exact registered face even when the family is not installed system-wide. On platforms without host-side text measurement, layout charges the parsed face's own `cmap`/`hmtx` advances, and the deterministic reference renderer inks the same outlines — CJK advances measure as the face declares them, not as the Latin estimator would guess. + +Codepoints the registered face does not cover keep the same per-glyph fallback as the built-ins: the face's own notdef box. A registered face never silently cascades into another family — missing coverage is visible, never approximated. + +One honest interaction with the [tofu guard](/native-ui#tooling): the static markup check validates literal text against the *bundled* face's coverage — it runs without your app and cannot know what you register. Text in a script outside bundled coverage reaches the view through model bindings (`{greeting}` — the guard deliberately skips binding spans, and real CJK content is almost always data) or through Zig-built views, where the same lesson is a Debug-build diagnostic rather than an error. + +## Platform truth + +The registration pipeline — TTF parsing, glyph outlines, rasterization — is the SDK's own code, identical on every platform. macOS additionally registers the face with the host so CoreText-side measurement and Metal packet text agree with it. Windows and Linux present the deterministic software renderer, which inks registered outlines directly; the suite pins pixel parity between the present path and the reference path for a registered face, and a CI receipt registers a CJK face and proves Chinese text renders as real glyphs on a native Windows runner. Mobile hosts render the same reference-renderer pixels, but no mobile test exercises registered fonts yet and the mobile hosts' text measurement has no registered-font seam — treat registered fonts on iOS and Android as unverified today. See [Platform Support](/platform-support#support-matrix) for the per-platform matrix. + +## The TypeScript tier + +Font registration is Zig-tier today: TypeScript cores have no fonts surface yet. diff --git a/docs/src/app/native-ui/page.mdx b/docs/src/app/native-ui/page.mdx index 69d023f02..711743c62 100644 --- a/docs/src/app/native-ui/page.mdx +++ b/docs/src/app/native-ui/page.mdx @@ -658,7 +658,7 @@ Zig-built views get the same discipline at tree level: `canvas.expectA11yAuditSw ## Tooling -- `native markup check src/app.native` — instant validation with `file:line:column` errors, including the font-coverage tofu guard: literal text with a codepoint outside the bundled face is a teaching error naming the character (it renders as a tofu box on the reference/screenshot and mobile paths — use a vector icon or plain words). Dynamic strings get the same lesson as a Debug-build diagnostic when the view builds. Accessibility findings ride the same pass: unnamed interactive controls and role misuse are errors, unnamed images and redundant labels are warnings (see [Accessibility](#accessibility)). +- `native markup check src/app.native` — instant validation with `file:line:column` errors, including the font-coverage tofu guard: literal text with a codepoint outside the bundled face is a teaching error naming the character (it renders as a tofu box on the reference/screenshot and mobile paths — [register a font](/fonts) that covers it and bind the text from the model, or use a vector icon or plain words; the static check knows only the bundled face's coverage). Dynamic strings get the same lesson as a Debug-build diagnostic when the view builds. Accessibility findings ride the same pass: unnamed interactive controls and role misuse are errors, unnamed images and redundant labels are warnings (see [Accessibility](#accessibility)). - `native markup lsp` — diagnostics, completion, and hover in your editor (see `editors/native-markup/` in the repo for VS Code, Helix, and Neovim setup). - The automation harness drives widgets and reads accessibility snapshots and screenshots headlessly — see [Automation](/automation). diff --git a/docs/src/app/theming/page.mdx b/docs/src/app/theming/page.mdx index eca574f75..25cc0912f 100644 --- a/docs/src/app/theming/page.mdx +++ b/docs/src/app/theming/page.mdx @@ -71,7 +71,7 @@ The register is small enough to read in one sitting; these are the groups and wh - `controls` — per-control visual tables (`button_primary`, `input`, `card`, …). Each entry states background/hover/active/pressed/disabled colors, foreground, border, radius, and stroke width; null fields fall through to the semantic palette and the state formulas. This is where a design system's per-control exceptions live — a filled error button, a 12px card corner — without touching any renderer. - `states` — the interaction formulas applied when a control table is silent: the filled hover/pressed alpha cuts, the disabled wash strength, the destructive chip's wash ladder, the static-text selection wash. - `metrics` — the control size register: the sm/default/lg height ladder, per-rung button insets, button label steps, icon extents and gaps, row extent. -- `typography` — faces and the type rungs (body/label/title/button/heading/display). +- `typography` — faces and the type rungs (body/label/title/button/heading/display). The face slots (`font_id`/`mono_font_id`) take app-registered faces too — see [Fonts](/fonts). - `radius`, `stroke`, `spacing` — the corner scale, hairline/focus stroke widths plus the focus ring's offset gap, and the spacing scale. - `shadow`, `blur`, `motion`, `scroll`, `layer`, `pixel_snap` — elevation, backdrop blur, motion durations/easing, scroll physics, z-bands, and snapping. diff --git a/docs/src/lib/docs-navigation.ts b/docs/src/lib/docs-navigation.ts index 0a9d64f59..57de0e556 100644 --- a/docs/src/lib/docs-navigation.ts +++ b/docs/src/lib/docs-navigation.ts @@ -30,6 +30,7 @@ export const navSections: NavSection[] = [ { name: "Native UI", href: "/native-ui" }, { name: "State & Data Flow", href: "/state" }, { name: "Theming", href: "/theming" }, + { name: "Fonts", href: "/fonts" }, { name: "Building Components", href: "/building-components" }, ], }, diff --git a/docs/src/lib/page-titles.ts b/docs/src/lib/page-titles.ts index 0884280e4..a5dad2021 100644 --- a/docs/src/lib/page-titles.ts +++ b/docs/src/lib/page-titles.ts @@ -13,6 +13,7 @@ export const PAGE_TITLES: Record = { "native-ui": "Native UI", state: "State & Data Flow", theming: "Theming", + fonts: "Fonts", "building-components": "Building Components", runtime: "App & Runtime", frontend: "Frontend Projects", From 72774fc19d7295decb6ade3bb139fe3975e0c060 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 17 Jul 2026 15:44:10 -0500 Subject: [PATCH 02/21] Teach the register-a-font path where the tofu guard fires - font_coverage_message, the ui builder's Debug diagnostic, the markup CLI usage text, and the native-ui skill now name registering a covering font (UiApp Options.fonts) alongside vector icons and plain words - Platform Support gains a text-and-registered-fonts matrix row: first-class on the three desktops per the code and suite, stated unverified on mobile where no test registers a font and host measurement has no registered-font seam --- docs/src/app/platform-support/page.mdx | 65 +++++++++++-------- skill-data/native-ui/SKILL.md | 2 +- src/primitives/canvas/ui.zig | 18 +++-- src/primitives/canvas/ui_markup.zig | 2 +- .../canvas/ui_markup_view_tests.zig | 3 +- tools/native-sdk/markup.zig | 3 +- 6 files changed, 54 insertions(+), 39 deletions(-) diff --git a/docs/src/app/platform-support/page.mdx b/docs/src/app/platform-support/page.mdx index bf10dfc74..b352b957d 100644 --- a/docs/src/app/platform-support/page.mdx +++ b/docs/src/app/platform-support/page.mdx @@ -38,19 +38,27 @@ macOS, Linux, and Windows run full desktop apps through their own platform hosts + + Text & registered fonts + + + + + + Pointer, keyboard & IME input - + - - + + Menus - - + + @@ -58,29 +66,29 @@ macOS, Linux, and Windows run full desktop apps through their own platform hosts System tray - + Web content (WebViews) - - - - - + + + + + Packaging - - - - - + + + + + Code signing - + @@ -89,23 +97,24 @@ macOS, Linux, and Windows run full desktop apps through their own platform hosts Automation - - - - + + + + 1. iOS and Android canvas apps run full screen inside toolkit-owned hosts, which the SDK builds ON the [embed C ABI](/embed) — `native dev` and `native package` with `--target ios|android` generate everything from app.zon, so the app project carries zero host code. Multi-window scenes are desktop-only. Embedding the runtime in a host app you control, driven over the same embed C ABI, works on both platforms and shares the mobile experimental status. 2. macOS presents through Metal and hands scrolling to OS scroll drivers. Linux and Windows present the deterministic software renderer through the platform blit path and report `backend=software`; a manifest that requests another backend falls back to software there instead of erroring. The iOS toolkit host reads the software renderer's pixels over the ABI and presents them through Metal with the macOS host's presentation discipline — presents are gated on the canvas revision (an idle app acquires no drawables and uploads nothing), the drawable is acquired only after the frame's CPU work is done, and the pump pauses in the background and re-presents the retained canvas on return; the Android toolkit host copies the same pixels into its surface's window buffer (mobile GPU rendering is a later phase); embed hosts present the pixels in their own surfaces. -3. Windows maps native IME composition onto the shared IME events, with real-hardware IME verification still pending. The iOS toolkit host forwards touch, the system keyboard, and IME composition, verified with real injected input on the simulator; the Android toolkit host forwards touch, shows and hides the soft keyboard from the runtime's focus state, and routes committed text and IME composition through the same embed IME events, verified with injected input on the emulator. -4. App menus are native on all three desktops. Hosts without a native context-menu presenter — Linux and Windows today — present the same declared context menu as an anchored canvas surface; authors declare one menu either way. -5. Tray support is implemented on macOS (`NSStatusItem`) and Windows; Linux tray calls return `UnsupportedService` until a portable status-notifier implementation is selected. -6. Web engines only apply to apps that embed web content; native-rendered apps carry none. The system WebView is the default engine on every desktop platform; bundled Chromium through CEF is available on macOS only, and Linux/Windows Chromium builds fail early instead of silently substituting an engine. The Windows system engine is WebView2: its Evergreen runtime ships with Windows 11 and current Windows 10 (older machines need the runtime installer; `native doctor` checks for it), and the build stages the vendored loader next to the executable automatically. See [Web Engines](/web-engines). The mobile shell examples embed the platform WebView as the content workspace. -7. `native package` targets all five platforms: macOS gets a `.app` bundle (plus `zig build dmg`), Linux an install tree, Windows a distributable directory with a per-user file-type registration script, iOS a complete generated Xcode project — toolkit host sources, Info.plist, asset catalog, shared scheme, and the device-slice embed library — that `xcodebuild archive` builds with zero edits (code signing stays a manual step, like notarization), and Android a complete generated host project whose debug APK assembles with zero edits, directly with the SDK's build tools (store signing keys stay a manual step). Every platform's icons generate from one square source image. See [Packaging](/packaging). -8. macOS signing supports `adhoc` and `identity` modes with entitlements; notarization is submitted manually with the platform tools after packaging. No signing tooling exists yet for the other platforms. See [Code Signing](/packaging/signing). -9. The automation server is a file-based protocol the runtime serves on every desktop platform: snapshots, assertions, synthetic input, screenshots, record/replay. Engine screenshots render through the deterministic CPU reference renderer on every platform, so they are byte-comparable across hosts. Mobile exposes accessibility snapshots and actions through the embed ABI; the iOS and Android toolkit hosts serve the same file-based protocol inside the app's data container when launched with automation enabled. +3. Text rendering is the SDK's own TrueType pipeline — parsing, outlines, rasterization — identical code on every platform, and apps can [register additional faces](/fonts) (a CJK face is the canonical case) that both renderers resolve exactly like the bundled ones. macOS additionally hands registered bytes to the host at registration, so CoreText measurement and Metal packet text resolve the same face the reference renderer inks. On Windows and Linux the software renderer inks registered outlines directly, and the test suite pins pixel parity between the present path and the reference path for a registered face; the full font-registry suite runs in CI on Linux. Mobile hosts present the same reference-rendered pixels, but no mobile test registers a font today and the mobile hosts' text measurement has no registered-font seam, so registered fonts there are stated as unverified rather than supported. +4. Windows maps native IME composition onto the shared IME events, with real-hardware IME verification still pending. The iOS toolkit host forwards touch, the system keyboard, and IME composition, verified with real injected input on the simulator; the Android toolkit host forwards touch, shows and hides the soft keyboard from the runtime's focus state, and routes committed text and IME composition through the same embed IME events, verified with injected input on the emulator. +5. App menus are native on all three desktops. Hosts without a native context-menu presenter — Linux and Windows today — present the same declared context menu as an anchored canvas surface; authors declare one menu either way. +6. Tray support is implemented on macOS (`NSStatusItem`) and Windows; Linux tray calls return `UnsupportedService` until a portable status-notifier implementation is selected. +7. Web engines only apply to apps that embed web content; native-rendered apps carry none. The system WebView is the default engine on every desktop platform; bundled Chromium through CEF is available on macOS only, and Linux/Windows Chromium builds fail early instead of silently substituting an engine. The Windows system engine is WebView2: its Evergreen runtime ships with Windows 11 and current Windows 10 (older machines need the runtime installer; `native doctor` checks for it), and the build stages the vendored loader next to the executable automatically. See [Web Engines](/web-engines). The mobile shell examples embed the platform WebView as the content workspace. +8. `native package` targets all five platforms: macOS gets a `.app` bundle (plus `zig build dmg`), Linux an install tree, Windows a distributable directory with a per-user file-type registration script, iOS a complete generated Xcode project — toolkit host sources, Info.plist, asset catalog, shared scheme, and the device-slice embed library — that `xcodebuild archive` builds with zero edits (code signing stays a manual step, like notarization), and Android a complete generated host project whose debug APK assembles with zero edits, directly with the SDK's build tools (store signing keys stay a manual step). Every platform's icons generate from one square source image. See [Packaging](/packaging). +9. macOS signing supports `adhoc` and `identity` modes with entitlements; notarization is submitted manually with the platform tools after packaging. No signing tooling exists yet for the other platforms. See [Code Signing](/packaging/signing). +10. The automation server is a file-based protocol the runtime serves on every desktop platform: snapshots, assertions, synthetic input, screenshots, record/replay. Engine screenshots render through the deterministic CPU reference renderer on every platform, so they are byte-comparable across hosts. Mobile exposes accessibility snapshots and actions through the embed ABI; the iOS and Android toolkit hosts serve the same file-based protocol inside the app's data container when launched with automation enabled. ## Desktop Hosts diff --git a/skill-data/native-ui/SKILL.md b/skill-data/native-ui/SKILL.md index f9c478181..5a628e818 100644 --- a/skill-data/native-ui/SKILL.md +++ b/skill-data/native-ui/SKILL.md @@ -1064,7 +1064,7 @@ Tests that touch files or clocks get their `Io` from `std.testing.io`. The full ## Validate without building -`native markup check src/view.native` — instant grammar/structure validation with `file:line:column` errors, including the font-coverage tofu guard: literal text with a codepoint outside the bundled face (⌘, ✓, ⑂, dingbats, CJK) is a teaching error naming the character, because it renders as a tofu box on the reference/screenshot and mobile paths — use a vector icon (`icon=` / ``) or plain words. Dynamic strings get the same lesson as a Debug-build `zero_canvas_ui` diagnostic when the view builds. The accessibility lint rides the same pass: unnamed interactive controls and role misuse are errors, unnamed images and redundant labels are warnings (`--strict` promotes). +`native markup check src/view.native` — instant grammar/structure validation with `file:line:column` errors, including the font-coverage tofu guard: literal text with a codepoint outside the bundled face (⌘, ✓, ⑂, dingbats, CJK) is a teaching error naming the character, because it renders as a tofu box on the reference/screenshot and mobile paths — register a font that covers it (`UiApp.Options.fonts`) and bind the text from the model (the guard skips `{bindings}`), or use a vector icon (`icon=` / ``) or plain words. Dynamic strings get the same lesson as a Debug-build `zero_canvas_ui` diagnostic when the view builds. The accessibility lint rides the same pass: unnamed interactive controls and role misuse are errors, unnamed images and redundant labels are warnings (`--strict` promotes). The model side checks at check time too: the model-contract step (refreshed by `native test`, or run directly as `zig build model-contract` in an app that owns its build) reflects Model/Msg into `zig-out/model-contract.zon`, and `native check` (or `markup check` run in the app directory) then verifies every binding path, iterable, `key` field, message tag, payload type, and expression type against the app's real surface — did-you-mean over your actual field names, and type errors naming the field's Zig type. It also WARNS on model state and Msg tags no view uses; opt update-only names out with `pub const view_unbound = .{ "next_id" };` on Model or Msg (`--strict` turns the warnings into failures) — state consumed only by a Zig-BUILT view needs `view_unbound` too, because the markup checker cannot see Zig view reads. A stale artifact degrades to grammar-only checking with a loud note ("model contract: not yet built - bindings checked structurally only; run `native test` to enable typed checks"); binding paths and message tags are always re-enforced when the app builds (and on hot reload). diff --git a/src/primitives/canvas/ui.zig b/src/primitives/canvas/ui.zig index 902d31d31..964bef490 100644 --- a/src/primitives/canvas/ui.zig +++ b/src/primitives/canvas/ui.zig @@ -96,12 +96,16 @@ fn warnTextSizeKind(kind: WidgetKind, size: canvas.WidgetSize) void { /// (`automate screenshot`), mobile embeds, provider-less measurement. /// Markup literals get the same lesson as a validation error; this /// diagnostic is the net for DYNAMIC strings (model-derived text -/// reaches no static check) and Zig-authored literals. Logs the first -/// uncovered codepoint per text run and keeps building — live macOS -/// rendering falls back through CoreText, so this must never fail an -/// app, and it logs at .debug (the `logAxisChildrenOverflow` -/// precedent) because a .warn inside a test-built view would fail the -/// whole suite for a rendering nit. +/// reaches no static check) and Zig-authored literals. The check +/// knows only bundled coverage — a builder has no view of the +/// runtime's font registry — so a run whose tokens point at a +/// registered face covering the codepoint renders real glyphs and +/// still logs here; that imprecision is one reason this logs at +/// .debug. Logs the first uncovered codepoint per text run and keeps +/// building — live macOS rendering falls back through CoreText, so +/// this must never fail an app, and .debug (the +/// `logAxisChildrenOverflow` precedent) because a .warn inside a +/// test-built view would fail the whole suite for a rendering nit. fn warnUncoveredText(kind: WidgetKind, text: []const u8) void { if (builtin.mode != .Debug) return; var index: usize = 0; @@ -111,7 +115,7 @@ fn warnUncoveredText(kind: WidgetKind, text: []const u8) void { const codepoint = std.unicode.utf8Decode(text[index .. index + len]) catch return; if (codepoint >= 0x20 and codepoint != 0x7F and !font_coverage.covers(codepoint)) { ui_log.debug( - "{s} text contains \"{s}\" (U+{X:0>4}), outside the bundled font's coverage - it renders as a tofu box on the reference/screenshot and mobile paths; use a vector icon (icon option, ) or plain words", + "{s} text contains \"{s}\" (U+{X:0>4}), outside the bundled font's coverage - it renders as a tofu box on the reference/screenshot and mobile paths unless a registered font covers it; register a font (UiApp Options.fonts), use a vector icon (icon option, ), or plain words", .{ @tagName(kind), text[index .. index + len], codepoint }, ); return; diff --git a/src/primitives/canvas/ui_markup.zig b/src/primitives/canvas/ui_markup.zig index ebb164587..dbb59a61c 100644 --- a/src/primitives/canvas/ui_markup.zig +++ b/src/primitives/canvas/ui_markup.zig @@ -1675,7 +1675,7 @@ fn trimBlank(text: []const u8) []const u8 { return std.mem.trim(u8, text, " \t\r\n"); } -pub const font_coverage_message = "this text contains a character outside the bundled font's coverage - it renders as a tofu box on the reference/screenshot and mobile paths; use a vector icon ( or the icon attribute) or plain words"; +pub const font_coverage_message = "this text contains a character outside the bundled font's coverage - it renders as a tofu box on the reference/screenshot and mobile paths; register a font that covers it (UiApp Options.fonts) and bind the text from the model, use a vector icon ( or the icon attribute), or plain words"; pub const UncoveredCodepoint = struct { /// Byte offset of the codepoint within the scanned literal. diff --git a/src/primitives/canvas/ui_markup_view_tests.zig b/src/primitives/canvas/ui_markup_view_tests.zig index 73a92c4e5..82a6080cc 100644 --- a/src/primitives/canvas/ui_markup_view_tests.zig +++ b/src/primitives/canvas/ui_markup_view_tests.zig @@ -3907,7 +3907,8 @@ test "reactions misuse fails the build with the pinned teaching messages" { // The pill's literal run rides the tofu guard: a codepoint outside // the bundled face renders as a tofu box on the reference path, so - // the validator teaches vector icons or plain words instead. + // the validator teaches the real paths — a registered covering + // font behind a binding, a vector icon, or plain words. const emoji = "\n hi\u{1F44D}\n"; var parser = canvas.ui_markup.Parser.init(arena, emoji); const document = try parser.parse(); diff --git a/tools/native-sdk/markup.zig b/tools/native-sdk/markup.zig index 2afdbb47c..17427fe76 100644 --- a/tools/native-sdk/markup.zig +++ b/tools/native-sdk/markup.zig @@ -632,7 +632,8 @@ fn usage() void { \\follows its closure; a file that is all templates is a \\valid component file), font coverage (literal text outside the \\bundled face renders as tofu boxes on reference paths - the error - \\names the character; use icons or plain words), and accessibility + \\names the character; register a covering font and bind the text + \\from the model, or use icons or plain words), and accessibility \\(unnamed interactive controls, icon-only controls without labels, \\and role misuse are errors - a screen reader user is blocked; \\unnamed images and redundant labels are warnings). From 58f36e69f163d362fa06e091ec8d09c836dda880 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 17 Jul 2026 15:54:34 -0500 Subject: [PATCH 03/21] Prove registered CJK faces render Chinese text on Windows in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Commit a 2.2 KB OFL-licensed fixture (Noto Sans SC instanced at wght=400, subsetted to 你好世界 plus notdef, license beside the file) and a receipt test: a scaffold-shaped UiApp registers it through Options.fonts, every ideograph resolves to its own glyph with nonzero rasterized ink, and the reference screenshot differs from the bundled-face tofu twin - Add zig build test-canvas-fonts (the font-registry suite filtered from the desktop tests) and run it natively on the Windows CI lane; the same tests keep running in zig build test via the canvas-frame shard - Note the Windows-native receipt in the platform matrix footnote --- .github/workflows/ci.yml | 10 ++ build.zig | 9 ++ docs/src/app/platform-support/page.mdx | 2 +- src/runtime/canvas_font_tests.zig | 133 ++++++++++++++++++ .../testdata/fonts/NotoSansSC-Receipt.ttf | Bin 0 -> 2196 bytes src/runtime/testdata/fonts/OFL.txt | 93 ++++++++++++ 6 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 src/runtime/testdata/fonts/NotoSansSC-Receipt.ttf create mode 100644 src/runtime/testdata/fonts/OFL.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c5009a07..6151a454d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,6 +112,16 @@ jobs: # include path, or a conformance error in the embedded layer — is a # compile failure here, not a silent WebViewNotFound at runtime. - run: zig build test-webview-system-link -Dplatform=windows + # The registered-font receipt, natively on Windows: runs the + # font-registry suite — registration validation, the glyph-budget + # gate, present/reference pixel parity, and the Chinese-receipt + # test that registers the committed subsetted Noto Sans SC through + # the app-fonts seam and proves the rendered string is real glyphs, + # not tofu — on real Windows, not under emulation. The font + # pipeline is platform-neutral Zig, so this lane pins that neutral + # truth on a Windows host (the Linux run lives in the Zig Core + # lane's `zig build test`). + - run: zig build test-canvas-fonts cef-platform-tooling: name: CEF Platform Tooling diff --git a/build.zig b/build.zig index 84ab27dd8..462668cd1 100644 --- a/build.zig +++ b/build.zig @@ -916,6 +916,15 @@ pub fn build(b: *std.Build) void { for (desktop_test_shard_specs, desktop_test_shards) |spec, shard_tests| { addTestStep(b, b.fmt("test-desktop-{s}", .{spec.name}), spec.description, shard_tests); } + // The font-registry suite as its own step so CI lanes on real + // Windows hardware can run it natively: the whole font pipeline + // (TrueType parsing, glyph rasterization, the reference renderer) + // is platform-neutral Zig, and this suite's Chinese-receipt test + // registers a committed CJK face through the app-fonts seam and + // proves the rendered string is real glyphs, not tofu — running it + // on a Windows runner makes that a Windows-native receipt. The same + // tests also run inside `zig build test` via the canvas-frame shard. + addTestStep(b, "test-canvas-fonts", "Run the runtime font-registry tests (includes the registered-CJK Chinese receipt)", filteredTestArtifact(b, desktop_mod, "canvas-fonts-tests", &.{"runtime.canvas_font_tests.test"})); addTestStep(b, "test-automation-protocol", "Run automation protocol tests", automation_protocol_tests); addTestStep(b, "test-automation-cli", "Run native automate CLI tests", automation_cli_tests); addTestStep(b, "test-markup-cli", "Run native markup CLI tests", markup_cli_tests); diff --git a/docs/src/app/platform-support/page.mdx b/docs/src/app/platform-support/page.mdx index b352b957d..15c27b5b0 100644 --- a/docs/src/app/platform-support/page.mdx +++ b/docs/src/app/platform-support/page.mdx @@ -107,7 +107,7 @@ macOS, Linux, and Windows run full desktop apps through their own platform hosts 1. iOS and Android canvas apps run full screen inside toolkit-owned hosts, which the SDK builds ON the [embed C ABI](/embed) — `native dev` and `native package` with `--target ios|android` generate everything from app.zon, so the app project carries zero host code. Multi-window scenes are desktop-only. Embedding the runtime in a host app you control, driven over the same embed C ABI, works on both platforms and shares the mobile experimental status. 2. macOS presents through Metal and hands scrolling to OS scroll drivers. Linux and Windows present the deterministic software renderer through the platform blit path and report `backend=software`; a manifest that requests another backend falls back to software there instead of erroring. The iOS toolkit host reads the software renderer's pixels over the ABI and presents them through Metal with the macOS host's presentation discipline — presents are gated on the canvas revision (an idle app acquires no drawables and uploads nothing), the drawable is acquired only after the frame's CPU work is done, and the pump pauses in the background and re-presents the retained canvas on return; the Android toolkit host copies the same pixels into its surface's window buffer (mobile GPU rendering is a later phase); embed hosts present the pixels in their own surfaces. -3. Text rendering is the SDK's own TrueType pipeline — parsing, outlines, rasterization — identical code on every platform, and apps can [register additional faces](/fonts) (a CJK face is the canonical case) that both renderers resolve exactly like the bundled ones. macOS additionally hands registered bytes to the host at registration, so CoreText measurement and Metal packet text resolve the same face the reference renderer inks. On Windows and Linux the software renderer inks registered outlines directly, and the test suite pins pixel parity between the present path and the reference path for a registered face; the full font-registry suite runs in CI on Linux. Mobile hosts present the same reference-rendered pixels, but no mobile test registers a font today and the mobile hosts' text measurement has no registered-font seam, so registered fonts there are stated as unverified rather than supported. +3. Text rendering is the SDK's own TrueType pipeline — parsing, outlines, rasterization — identical code on every platform, and apps can [register additional faces](/fonts) (a CJK face is the canonical case) that both renderers resolve exactly like the bundled ones. macOS additionally hands registered bytes to the host at registration, so CoreText measurement and Metal packet text resolve the same face the reference renderer inks. On Windows and Linux the software renderer inks registered outlines directly, and the test suite pins pixel parity between the present path and the reference path for a registered face; the full font-registry suite runs in CI on Linux, and CI additionally runs it natively on a Windows runner, including a receipt test that registers a committed subsetted CJK face and proves Chinese text renders as real glyphs, not tofu. Mobile hosts present the same reference-rendered pixels, but no mobile test registers a font today and the mobile hosts' text measurement has no registered-font seam, so registered fonts there are stated as unverified rather than supported. 4. Windows maps native IME composition onto the shared IME events, with real-hardware IME verification still pending. The iOS toolkit host forwards touch, the system keyboard, and IME composition, verified with real injected input on the simulator; the Android toolkit host forwards touch, shows and hides the soft keyboard from the runtime's focus state, and routes committed text and IME composition through the same embed IME events, verified with injected input on the emulator. 5. App menus are native on all three desktops. Hosts without a native context-menu presenter — Linux and Windows today — present the same declared context menu as an anchored canvas surface; authors declare one menu either way. 6. Tray support is implemented on macOS (`NSStatusItem`) and Windows; Linux tray calls return `UnsupportedService` until a portable status-notifier implementation is selected. diff --git a/src/runtime/canvas_font_tests.zig b/src/runtime/canvas_font_tests.zig index 47ddb28b9..8a2d8d3af 100644 --- a/src/runtime/canvas_font_tests.zig +++ b/src/runtime/canvas_font_tests.zig @@ -642,6 +642,139 @@ test "ui app declared font failures are teaching errors, not crashes or silent f try std.testing.expectEqual(first_errors, harness.runtime.dispatchErrorTotal()); } +// ---------------------------------------------- the Chinese receipt + +/// Committed CJK fixture: Noto Sans SC (OFL — the license rides beside +/// the file in testdata/fonts/OFL.txt), instanced at wght=400 from the +/// Google Fonts variable TrueType build and subsetted to exactly the +/// receipt string's four ideographs plus notdef (2.2 KB — the license +/// permits subsetting; full font binaries stay out of the repo). The +/// subset keeps the format-4 unicode cmap, real `glyf` outlines, and +/// the full font's truthful `maxp` declaration (584 points / 84 +/// contours — past the old Latin-sized budgets, inside the CJK-sized +/// ones), so registration exercises the real gate and rendering inks +/// real Han ideograph outlines, deterministically, on every CI host. +const cjk_receipt_bytes = @embedFile("testdata/fonts/NotoSansSC-Receipt.ttf"); + +/// 你好世界 — "Hello, world". +const cjk_receipt_text = "\u{4F60}\u{597D}\u{4E16}\u{754C}"; + +fn cjkAppView(ui: *FontApp.Ui, model: *const FontAppModel) FontApp.Ui.Node { + _ = model; + return ui.column(.{ .gap = 8, .padding = 12 }, .{ + ui.text(.{}, cjk_receipt_text), + }); +} + +test "the Chinese receipt: a scaffold-shaped app registers a CJK face and renders real glyphs, not tofu" { + const harness = try TestHarness().create(std.testing.allocator, .{ .size = geometry.SizeF.init(240, 200) }); + defer harness.destroy(std.testing.allocator); + harness.null_platform.gpu_surfaces = true; + + // The scaffold shape: fonts declared on Options.fonts, tokens + // pointing the typography face slot at the registered id, Chinese + // text in the view. + const fonts = [_]FontApp.FontRegistration{.{ + .id = registered_font_id, + .name = "NotoSansSC-Receipt.ttf", + .ttf = cjk_receipt_bytes, + }}; + var options = fontAppOptions(&fonts); + options.view = cjkAppView; + const app_state = try std.testing.allocator.create(FontApp); + defer std.testing.allocator.destroy(app_state); + app_state.* = FontApp.init(std.testing.allocator, .{}, options); + defer app_state.deinit(); + const app = app_state.app(); + try harness.start(app); + + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{ + .label = font_app_canvas_label, + .size = geometry.SizeF.init(240, 200), + .scale_factor = 1, + .frame_index = 1, + .timestamp_ns = 1_000_000, + .nonblank = true, + } }); + try std.testing.expect(app_state.installed); + try std.testing.expectEqual(@as(usize, 0), harness.runtime.dispatchErrors().len); + try std.testing.expectEqual(@as(usize, 1), harness.runtime.registeredCanvasFontCount()); + + // Per-glyph receipt: every ideograph resolves to its own real glyph + // in the registered face (the bundled face maps NONE of them — the + // exact gap the registration closes) and rasterizes with nonzero + // ink at body and headline sizes. + const face = harness.runtime.registeredCanvasFontFace(registered_font_id).?; + const InkCounter = struct { + covered: usize = 0, + pub fn pixel(self: *@This(), x: i32, y: i32, coverage: f32) void { + _ = x; + _ = y; + if (coverage > 0) self.covered += 1; + } + }; + var seen_glyphs: [4]u16 = undefined; + var index: usize = 0; + var glyph_count: usize = 0; + while (index < cjk_receipt_text.len) : (glyph_count += 1) { + const len = try std.unicode.utf8ByteSequenceLength(cjk_receipt_text[index]); + const codepoint = try std.unicode.utf8Decode(cjk_receipt_text[index .. index + len]); + index += len; + + try std.testing.expectEqual(@as(u16, 0), canvas.font_ttf.geist_regular.glyphIndex(codepoint)); + const glyph = face.glyphIndex(codepoint); + try std.testing.expect(glyph != 0); + // cmap distinguishes the codepoints — four ideographs, four + // distinct glyphs, never one shared fallback shape. + for (seen_glyphs[0..glyph_count]) |seen| try std.testing.expect(seen != glyph); + seen_glyphs[glyph_count] = glyph; + + for ([_]f32{ 16, 40 }) |size| { + const scale = size / face.units_per_em; + var builder = canvas.vector.PathBuilder(2048){}; + try face.glyphOutline(glyph, .{ .a = scale, .b = 0, .c = 0, .d = -scale, .tx = 4, .ty = size }, &builder); + try std.testing.expect(builder.slice().len > 0); + var counter = InkCounter{}; + try canvas.vector.fillPath( + builder.slice(), + canvas.Affine.identity(), + .nonzero, + canvas.vector.default_tolerance, + .{ .x0 = 0, .y0 = 0, .x1 = 64, .y1 = 64 }, + &counter, + ); + try std.testing.expect(counter.covered > 0); + } + } + try std.testing.expectEqual(@as(usize, 4), glyph_count); + + // Layout measures the ideographs with the registered face's own + // advances, so measured line breaking agrees with the inked glyphs. + const provider = harness.runtime.textMeasureProvider().?; + try std.testing.expect(provider.measureWidth(registered_font_id, 16.0, cjk_receipt_text) > 0); + + // End-to-end pixels through the deterministic reference renderer — + // the same path Windows and Linux present. The registered face + // draws the string as real outlines; the same view under the + // bundled face can only draw notdef boxes, so the two screenshots + // MUST differ. That difference is the receipt: the Chinese text on + // screen came from the registered face, not tofu. + const registered_shot = try fontFixtureScreenshot(harness, std.testing.allocator, registered_font_id); + defer std.testing.allocator.free(registered_shot); + const tofu_shot = try fontFixtureScreenshot(harness, std.testing.allocator, canvas.default_sans_font_id); + defer std.testing.allocator.free(tofu_shot); + var nonblank = false; + var pixel: usize = 0; + while (pixel + 4 <= registered_shot.len) : (pixel += 4) { + if (registered_shot[pixel + 3] != 0 and (registered_shot[pixel] != 0 or registered_shot[pixel + 1] != 0 or registered_shot[pixel + 2] != 0)) { + nonblank = true; + break; + } + } + try std.testing.expect(nonblank); + try std.testing.expect(!std.mem.eql(u8, registered_shot, tofu_shot)); +} + test "ui app fonts option surfaces the glyph-budget refusal as a teaching error" { const harness = try TestHarness().create(std.testing.allocator, .{ .size = geometry.SizeF.init(240, 200) }); defer harness.destroy(std.testing.allocator); diff --git a/src/runtime/testdata/fonts/NotoSansSC-Receipt.ttf b/src/runtime/testdata/fonts/NotoSansSC-Receipt.ttf new file mode 100644 index 0000000000000000000000000000000000000000..66942d152156055a4303156717329aa78a8d484c GIT binary patch literal 2196 zcma)6du&rx82`>aecV1qcj?w{V{GkS-3IGe%hthUjL|*1b*(TMhy>_1*a#bI)(xge zg35DhLI#>F(L_NIG$pxZBO((WB2LS~-PNA6HD9=iG`FDL(Kj54e*fL%FGSjU$iaa~EDD-QBtHu(4-7rF ztE|Vi4gC8={OI7$NPor6H?JaI_|*+UVOHcAO4C~J%#X4^3LOHZgb)HAGcg3rw6NQ>FD+Nch2&+Iui&2G%z zWYfsyP5jEQ^0nX$q!4vGvz=D2f|+?0@jJcz>Dhx*A?a9r3Db0552+WVP=J-kGXi{l z{4;4u{3OYgnRts|=YCX`L;2*Udh*jc>LFTVUm)q-lB-ct(p+7g71@1WB}=E*>59wB zTsDu-Q&(&C6-aitTD?BIPNzqNuk>E9-e*^e*_RzzbyS}uh*OcKk<`|o_X0s-Ky3Mh9mycT%y=QAE z*v8tqUkOhi@#w6I&VArH^^B$Y;BcibbAfinMb^?<8GpI3#AwiER#_O+8QD{|+KF*@ zu6y6(teTsA`zy?O{($q-?2YH9>J9me<(fmMuC}pw1=>b=z~n>Tjy_q*PL;HRR#AX9 z0u!-ANtc4f=K~si9wIIv;<0(Wb$|qpfDa0Qp@k1H;=qWz8aXUn&w4X5s+Fx%eHvw< zyyZlXWr9tr`>G^s{O(MbHOC%o;F)qktxGl=u2>f?&+k}QpfLwijkauk)f@4Pr&`oIZSTeZbd(rmEwfYr z4TDkoU0b^3(e;JKJUKtO&N zInJ3LcQA)asg71sBTihlv$j+Wr{XNd5lEdE*ZtOev$N7?hyxL&*pBg|lp7c0CFyef zVfJy!$gx&AOVPDWj=83r%lDmV)hbql|46jD%d&J+K)WzYZtilKUg+$+(A6b14-cQ; zv!_YjTUoQAX`w#HZnSsyt;k(e-y6y`*=(}a-}B_El5n`aKx{v^VfE^K$)jF+RXvNF zDBcjQ)J_{fQ&N(dH!YzM-D2C>M@e6a>3(ay>)LL4=nvw2M)-Ywx#TrL?;L7z&)WW) zF-7%wXK?B?Yk|w3i z&~~OM+eRMN0<9^XI2j<%i(TVy4pUReHXh{B#Bs3_bHFV2NK&En4TuAELBQY`S(t@2 zi!`|7L`HO0A}M4G?|9_kcrcM5y3fD;)8#Ygk4|3uN5wMj5?M3`#J`A<;u#e|O7Rjc zpbyeKrzZM5&1>fQ=%|31)A|fn#46ML0$MHwshs+#0=0W+8P(8o;3zl`cn7Tkx+By} zJHeOJAdTUV;w)C<-=F;56uIZ)My3%OhOL5@B4R)7hW{YA^~eyz7^Quf`=M!t=U!MH zH~|sFh!tGa4edC*`XIaTHijUNVK`ukz(1Dc)?(g(yt|4!Odlv%h641vCFv(DbFx`TmHi{6(MT4U%jY!7i!#}Twm;Bce2VMP_YY9b85%q z;QPUk!bhMYe7o_=1wq3=Rrt=&9DE>yz_TYA9fMwA=Rk+t$uWn9sO-UY=cW$Pk4+hIK_1L!cwj4xr; Date: Sat, 18 Jul 2026 13:57:34 -0500 Subject: [PATCH 04/21] Scope the fonts page's claims to what each tier delivers - The example now teaches the mono_font_id slot instead of claiming whole-app coverage, and the lockstep guarantee names the desktop paths and defers mobile to the platform notes. --- docs/src/app/fonts/page.mdx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/src/app/fonts/page.mdx b/docs/src/app/fonts/page.mdx index 2c3c4ccfb..680d84c4b 100644 --- a/docs/src/app/fonts/page.mdx +++ b/docs/src/app/fonts/page.mdx @@ -22,8 +22,10 @@ const app_fonts = [_]MyApp.FontRegistration{.{ fn tokensFromModel(model: *const Model) canvas.DesignTokens { var tokens = canvas.DesignTokens.theme(.{ .color_scheme = model.scheme }); - // Every text run reads the typography tokens: point the face slot - // at the registered id and the whole app prints in that face. + // Text resolves its face through the typography tokens: pointing + // font_id at the registered id covers every ordinary run. Mono + // spans keep the bundled mono face unless you also point + // typography.mono_font_id at a face that covers your script. tokens.typography.font_id = cjk_font_id; return tokens; } @@ -70,7 +72,7 @@ Registration copies the file into an exact-size heap allocation owned by the run There is no per-widget font attribute. Text resolves its face through the typography tokens — `typography.font_id` for every ordinary run, `typography.mono_font_id` for mono-flagged runs, and optional `typography.button_font_id` for control labels — so a registered face is a theme decision, and the id rides everywhere a `canvas.FontId` rides: token overrides, both renderers, glyph atlas keys, render fingerprints. -Layout and ink stay in lockstep. On platforms that measure text host-side (macOS), the raw bytes reach the host at registration time, so CoreText measurement and packet text drawing resolve the exact registered face even when the family is not installed system-wide. On platforms without host-side text measurement, layout charges the parsed face's own `cmap`/`hmtx` advances, and the deterministic reference renderer inks the same outlines — CJK advances measure as the face declares them, not as the Latin estimator would guess. +Layout and ink stay in lockstep on the desktop paths. On macOS, the raw bytes reach the host at registration time, so CoreText measurement and packet text drawing resolve the exact registered face even when the family is not installed system-wide. On Windows and Linux, layout charges the parsed face's own `cmap`/`hmtx` advances, and the deterministic reference renderer inks the same outlines — CJK advances measure as the face declares them, not as the Latin estimator would guess. Mobile hosts do not yet have a registered-font measurement seam, so this guarantee does not extend there — see the platform notes below. Codepoints the registered face does not cover keep the same per-glyph fallback as the built-ins: the face's own notdef box. A registered face never silently cascades into another family — missing coverage is visible, never approximated. From 810669733626c0ee2433dad47f1eb1420b6b4ae5 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 18 Jul 2026 14:32:47 -0500 Subject: [PATCH 05/21] Pin the Chinese receipt against the registered face's own tofu - The end-to-end receipt now renders a third screenshot: the same registered face showing four ideographs the fixture deliberately does not map (each pinned with glyphIndex() == 0 first), and asserts the real-CJK shot differs from that self-calibrating control. - A renderer that wrongly resolved every ideograph to the registered face's notdef glyph and inked its outline would differ from the bundled-face shot and pass nonblank, yet match this control byte-for-byte; sabotage-verified by forcing the uncovered string into both slots and watching the new assertion fail. - The scaffold model gains a show_uncovered_cjk toggle so the control renders through the identical dispatch-rebuild-screenshot pipeline as the receipt itself. --- src/runtime/canvas_font_tests.zig | 42 ++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/src/runtime/canvas_font_tests.zig b/src/runtime/canvas_font_tests.zig index 8a2d8d3af..aaf440eb0 100644 --- a/src/runtime/canvas_font_tests.zig +++ b/src/runtime/canvas_font_tests.zig @@ -510,8 +510,8 @@ test "a registered face renders pixel-identically on the present path and the re // ------------------------------------------------ UiApp Options.fonts -const FontAppModel = struct { presses: u32 = 0 }; -const FontAppMsg = union(enum) { press: void }; +const FontAppModel = struct { presses: u32 = 0, show_uncovered_cjk: bool = false }; +const FontAppMsg = union(enum) { press: void, show_uncovered_cjk: void }; const FontApp = ui_app_model.UiApp(FontAppModel, FontAppMsg); const font_app_canvas_label = "canvas"; @@ -519,6 +519,7 @@ const font_app_canvas_label = "canvas"; fn fontAppUpdate(model: *FontAppModel, msg: FontAppMsg) void { switch (msg) { .press => model.presses += 1, + .show_uncovered_cjk => model.show_uncovered_cjk = true, } } @@ -659,10 +660,17 @@ const cjk_receipt_bytes = @embedFile("testdata/fonts/NotoSansSC-Receipt.ttf"); /// 你好世界 — "Hello, world". const cjk_receipt_text = "\u{4F60}\u{597D}\u{4E16}\u{754C}"; +/// 中文字体 — "Chinese font": four ideographs the subsetted fixture +/// face deliberately does NOT map (the subset carries exactly the +/// receipt string's four glyphs plus notdef; the test pins the gap +/// with glyphIndex() == 0 before relying on it). Rendered with the +/// SAME registered face, this string can only draw four notdef boxes +/// — the self-calibrating tofu baseline the receipt compares against. +const cjk_uncovered_text = "\u{4E2D}\u{6587}\u{5B57}\u{4F53}"; + fn cjkAppView(ui: *FontApp.Ui, model: *const FontAppModel) FontApp.Ui.Node { - _ = model; return ui.column(.{ .gap = 8, .padding = 12 }, .{ - ui.text(.{}, cjk_receipt_text), + ui.text(.{}, if (model.show_uncovered_cjk) cjk_uncovered_text else cjk_receipt_text), }); } @@ -773,6 +781,32 @@ test "the Chinese receipt: a scaffold-shaped app registers a CJK face and render } try std.testing.expect(nonblank); try std.testing.expect(!std.mem.eql(u8, registered_shot, tofu_shot)); + + // Self-calibrating tofu control: the bundled-face comparison above + // proves the pixels changed with the face, not that they are real + // ideographs — a renderer that wrongly resolved every ideograph to + // the REGISTERED face's notdef glyph and inked that glyph's own + // outline would still differ from the bundled shot (a different + // face's fallback pixels) and still pass nonblank. Render the same + // view with the same registered face showing a string the fixture + // face genuinely does not cover — first pinning that gap per + // codepoint — so the control shot IS this face's + // everything-uncovered rendering through the identical pipeline. + // Had the receipt string resolved to notdef, the two shots would + // match: same face, same per-glyph fallback, same advances. + // Differing proves the receipt pixels are real ideograph outlines, + // not any face's fallback. + var uncovered_index: usize = 0; + while (uncovered_index < cjk_uncovered_text.len) { + const len = try std.unicode.utf8ByteSequenceLength(cjk_uncovered_text[uncovered_index]); + const codepoint = try std.unicode.utf8Decode(cjk_uncovered_text[uncovered_index .. uncovered_index + len]); + uncovered_index += len; + try std.testing.expectEqual(@as(u16, 0), face.glyphIndex(codepoint)); + } + try app_state.dispatch(&harness.runtime, 1, .show_uncovered_cjk); + const uncovered_shot = try fontFixtureScreenshot(harness, std.testing.allocator, registered_font_id); + defer std.testing.allocator.free(uncovered_shot); + try std.testing.expect(!std.mem.eql(u8, registered_shot, uncovered_shot)); } test "ui app fonts option surfaces the glyph-budget refusal as a teaching error" { From eaeb37d226f26b9bcc4a6071072ed204193c9943 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 18 Jul 2026 14:32:57 -0500 Subject: [PATCH 06/21] Name the one render-time refusal and add the release fragment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The fonts page's registration-time claim now carries its one honest exception: point-matched composite placement is the per-glyph refusal maxp cannot gate, surfacing at first raster as the deterministic block fallback — never a crash, never a silent skip — and no measured production face uses it. - New changelog.d fragment covers the branch's user-visible changes: the tofu-guard teachings now name font registration first, the fonts docs page, and the Windows-native CJK receipt lane. - src/runtime/testdata/fonts/OFL.txt drops one trailing space (line 21) so git diff --check runs clean; the license wording stays verbatim. --- changelog.d/registered-fonts-docs-and-receipt.md | 3 +++ docs/src/app/fonts/page.mdx | 2 +- src/runtime/testdata/fonts/OFL.txt | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 changelog.d/registered-fonts-docs-and-receipt.md diff --git a/changelog.d/registered-fonts-docs-and-receipt.md b/changelog.d/registered-fonts-docs-and-receipt.md new file mode 100644 index 000000000..a333765cf --- /dev/null +++ b/changelog.d/registered-fonts-docs-and-receipt.md @@ -0,0 +1,3 @@ +improvement: **The tofu guard teaches font registration**: the font-coverage teachings — the `native markup check` error, the Debug view-build diagnostic, and the CLI usage text — now name registering a covering face (`UiApp.Options.fonts`) behind a model binding as the first remedy for text beyond bundled coverage, alongside vector icons and plain words. +- **A fonts page in the docs**: `/fonts` documents registering faces for scripts beyond bundled coverage — the `Options.fonts` scaffold shape, every registration-time validation error by name, ownership and lifecycle, how text resolves faces through the typography tokens, and per-platform truth including the unverified mobile seam. +- **The Chinese receipt runs natively on Windows in CI**: a new `zig build test-canvas-fonts` step runs the font-registry suite on the Windows runner, including the receipt test that registers a committed subsetted Noto Sans SC (OFL, license alongside) through the app-fonts seam and proves the rendered string is real ideograph outlines — compared against both the bundled face's rendering and the same registered face's own uncovered-string fallback, so tofu from any face fails the receipt. diff --git a/docs/src/app/fonts/page.mdx b/docs/src/app/fonts/page.mdx index 680d84c4b..3258a3648 100644 --- a/docs/src/app/fonts/page.mdx +++ b/docs/src/app/fonts/page.mdx @@ -53,7 +53,7 @@ Register at startup so the first layout already measures with the face. Late reg ## Validation is loud and registration-time only -Every failure happens at registration, never at render time — a registered id always resolves when text draws. `Options.fonts` translates each error into a warning naming the font before propagating it: +Every failure happens at registration, never at render time — a registered id always resolves when text draws — with one honest exception: point-matched composite placement is the one per-glyph refusal `maxp` cannot gate at registration (no declared field describes it), so it surfaces at first raster, where the glyph draws the deterministic block fallback — never a crash, never a silent skip. No measured production face places composites that way: the bundled Geist faces and every CJK face measured for the budgets use XY offsets. `Options.fonts` translates each error into a warning naming the font before propagating it: - `error.InvalidFontId` — id 0 is the "inherit run font" sentinel and can never hold a face. - `error.ReservedFontId` — ids below `canvas.min_registered_font_id` (64) belong to the built-in faces. diff --git a/src/runtime/testdata/fonts/OFL.txt b/src/runtime/testdata/fonts/OFL.txt index 1c9f43281..79a917be1 100644 --- a/src/runtime/testdata/fonts/OFL.txt +++ b/src/runtime/testdata/fonts/OFL.txt @@ -18,7 +18,7 @@ with others. The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, +fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The From 2b5120977410e44e800d0e1abbdae1edf08565ec Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 18 Jul 2026 15:32:49 -0500 Subject: [PATCH 07/21] Evict a re-registered font id's cached NSFonts in the AppKit host - The per-(id, size) NSFont cache is per-process while font-id permanence is per-runtime, so an embedder destroying a runtime and registering a different face under the same id measured and drew the first face from stale cache entries. - native_sdk_appkit_register_font now purges the id's "id/"-prefixed cache entries under the descriptor table's @synchronized guard before installing the new descriptor, and the comment claiming cached NSFonts never go stale states the real lifetime instead. - No SDK test tier links appkit_host.m (only managed app builds compile it), so the eviction wiring is pinned by a file-contains check step like the other AppKit host contracts; the CEF host's register_font is a stateless accept with no cache, so nothing to evict there. --- build.zig | 11 +++++++++ src/platform/macos/appkit_host.m | 42 ++++++++++++++++++++++++++------ 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/build.zig b/build.zig index 462668cd1..c70b22dfa 100644 --- a/build.zig +++ b/build.zig @@ -809,6 +809,17 @@ pub fn build(b: *std.Build) void { .{ .path = "src/platform/macos/appkit_host.m", .pattern = "NativeSdkItalicSansFont(base)" }, .{ .path = "src/platform/macos/appkit_host.m", .pattern = "NativeSdkItalicSansFont(NativeSdkWeightedSansFont(@[ @\"Geist-Bold\", @\"Geist Bold\" ], base, NSFontWeightBold, YES, size))" }, }); + addFileContainsCheckStep(b, file_contains_checker, test_step, "test-appkit-registered-font-cache-eviction", "Verify the AppKit registered-font size cache evicts an id's cached NSFonts at registration (the cache is per-process while font-id permanence is per-runtime, so a new runtime re-registering an id must never resolve the previous runtime's face)", &.{ + // No SDK test tier links appkit_host.m (only managed app builds + // compile it), so the eviction wiring is pinned textually like + // the other AppKit host contracts: the shared accessor both the + // resolve and registration paths use, the prefix eviction inside + // register_font, and the honest lifetime comment. + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "static NSMutableDictionary *NativeSdkRegisteredFontSizeCache(void)" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "NSString *stalePrefix = [NSString stringWithFormat:@\"%llu/\", (unsigned long long)font_id];" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "if ([cachedKey hasPrefix:stalePrefix]) [sizeCache removeObjectForKey:cachedKey];" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "an id is only permanent within" }, + }); addFileContainsCheckStep(b, file_contains_checker, test_step, "test-appkit-gpu-widget-cursor-bridge", "Verify AppKit GPU widgets apply retained cursor intent", &.{ .{ .path = "src/platform/macos/appkit_host.m", .pattern = "native_sdk_appkit_set_view_cursor" }, .{ .path = "src/platform/macos/appkit_host.m", .pattern = "resetCursorRects" }, diff --git a/src/platform/macos/appkit_host.m b/src/platform/macos/appkit_host.m index caf97704e..46b31f851 100644 --- a/src/platform/macos/appkit_host.m +++ b/src/platform/macos/appkit_host.m @@ -1763,18 +1763,35 @@ static NSTextAlignment NativeSdkPacketTextAlignment(NSString *align) { return table; } +// The per-(id, size) NSFont cache for registered faces, keyed "id/size". +// Ids are permanent within ONE runtime (`error.FontIdInUse` guards +// re-use for a runtime's lifetime), but this cache is per-PROCESS and +// runtimes are not: an embedder that destroys its runtime +// (`Runtime.deinit`) and registers a DIFFERENT face under the same +// valid id in a new runtime is inside the documented lifecycle. Entries +// therefore go stale exactly when an id's descriptor is (re)installed, +// so `native_sdk_appkit_register_font` evicts the id's cached sizes +// before installing the new descriptor. Accessed only under the +// descriptor table's @synchronized guard, like the descriptors. +static NSMutableDictionary *NativeSdkRegisteredFontSizeCache(void) { + static NSMutableDictionary *cache = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + cache = [[NSMutableDictionary alloc] init]; + }); + return cache; +} + // The registered face for a canvas font id at `size`, or nil when the id // has no registered face. Checked BEFORE the built-in candidates and // their cache so a registered id can never be masked by a font resolved -// for that id earlier (ids are engine-validated and permanent, so cached -// NSFonts here never go stale). +// for that id earlier. Cached NSFonts here stay honest because +// registration evicts an id's entries when its descriptor changes (see +// NativeSdkRegisteredFontSizeCache) — an id is only permanent within +// one runtime, and this cache outlives runtimes. static NSFont *NativeSdkRegisteredFontForId(unsigned long long value, CGFloat size) { NSMutableDictionary *table = NativeSdkRegisteredFontDescriptors(); - static NSMutableDictionary *sizeCache = nil; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - sizeCache = [[NSMutableDictionary alloc] init]; - }); + NSMutableDictionary *sizeCache = NativeSdkRegisteredFontSizeCache(); @synchronized (table) { id descriptorObject = table[@(value)]; if (!descriptorObject) return nil; @@ -1803,6 +1820,17 @@ int native_sdk_appkit_register_font(uint64_t font_id, const uint8_t *bytes, size if (!descriptor) return 0; NSMutableDictionary *table = NativeSdkRegisteredFontDescriptors(); @synchronized (table) { + // Evict the id's cached NSFonts BEFORE installing the new + // descriptor: the process may still hold fonts a PREVIOUS + // runtime resolved for this id (ids are per-runtime + // permanent, the cache is per-process), and serving them + // would measure and draw the old face under the new id. + NSMutableDictionary *sizeCache = NativeSdkRegisteredFontSizeCache(); + NSString *stalePrefix = [NSString stringWithFormat:@"%llu/", (unsigned long long)font_id]; + NSArray *cachedKeys = sizeCache.allKeys; + for (NSString *cachedKey in cachedKeys) { + if ([cachedKey hasPrefix:stalePrefix]) [sizeCache removeObjectForKey:cachedKey]; + } table[@(font_id)] = (__bridge_transfer id)descriptor; } return 1; From d22e591581e30a27a0ecbd14d1836bea191f0841 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 18 Jul 2026 15:38:02 -0500 Subject: [PATCH 08/21] Rebuild every installed surface when a font registers late MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - noteCanvasFontsChanged forced a repaint but installed UiApps never rebuilt (the frame handler rebuilds only on install, scale change, or size change), so the repaint re-inked widget frames and baked text layouts measured before the face joined — breaking the fonts page's every-open-surface-re-measures promise. - The registered-font count is the runtime's fonts generation (registration is permanent, no unregister): UiApp adopts it at install and both frame handlers compare it per presented frame, rebuilding ALL installed surfaces — main canvas and declared windows — through rebuildAllViews when a face joined late; rebuildEmitsTokens also treats a text-measure provider change as an emit reason so static-token apps push the font-aware provider into the stored tokens. - Test: an app with no declared fonts installs a main canvas plus a declared window, registers the CJK fixture through the runtime seam, and one arriving frame must move BOTH surfaces' text frame widths (mixed CJK+Latin text — the Latin tail measures at Geist's sub-em advances before and the face's 1.0 em notdef after); sabotage-verified: with the rebuild call removed, exactly this test fails. --- src/runtime/canvas_font_tests.zig | 140 ++++++++++++++++++++++++++++++ src/runtime/canvas_fonts.zig | 7 +- src/runtime/ui_app.zig | 65 ++++++++++++-- 3 files changed, 206 insertions(+), 6 deletions(-) diff --git a/src/runtime/canvas_font_tests.zig b/src/runtime/canvas_font_tests.zig index aaf440eb0..f2677531a 100644 --- a/src/runtime/canvas_font_tests.zig +++ b/src/runtime/canvas_font_tests.zig @@ -809,6 +809,146 @@ test "the Chinese receipt: a scaffold-shaped app registers a CJK face and render try std.testing.expect(!std.mem.eql(u8, registered_shot, uncovered_shot)); } +// ------------------- late registration re-measures installed layouts + +/// 你好 SDK — mixed CJK + Latin, chosen so the measured width MUST move +/// when the fixture face joins late. Pure-CJK width would not move: the +/// bundled estimator's East Asian wide fallback charges 1.0 em and the +/// fixture's ideographs really advance 1.0 em. The Latin tail is what +/// moves — the estimator charges Geist's own sub-em ASCII advances, +/// while the fixture face (subsetted to four ideographs plus notdef) +/// answers every ASCII codepoint with its 1.0 em notdef advance, the +/// documented no-cascade fallback registered faces take for uncovered +/// codepoints. +const late_mixed_text = "\u{4F60}\u{597D} SDK"; + +const late_window_label = "late-panel"; +const late_window_canvas_label = "late-panel-canvas"; + +fn lateFontAppView(ui: *FontApp.Ui, model: *const FontAppModel) FontApp.Ui.Node { + _ = model; + // A row lays text out at its MEASURED intrinsic width (a column's + // default cross alignment stretches children to the column width), + // so this widget's frame is a direct function of what the + // measurement seam answers for the string. + return ui.row(.{ .gap = 8, .padding = 12 }, .{ + ui.text(.{}, late_mixed_text), + }); +} + +fn lateFontAppWindows(model: *const FontAppModel, scratch: *FontApp.WindowsScratch) []const FontApp.WindowDescriptor { + _ = model; + scratch.windows[0] = .{ + .label = late_window_label, + .canvas_label = late_window_canvas_label, + .title = "Late", + .width = 240, + .height = 200, + }; + return scratch.windows[0..1]; +} + +fn lateFontAppWindowView(ui: *FontApp.Ui, model: *const FontAppModel, window_label: []const u8) FontApp.Ui.Node { + std.debug.assert(std.mem.eql(u8, window_label, late_window_label)); + return lateFontAppView(ui, model); +} + +/// The laid-out frame width of the view's one text widget. +fn lateTextFrameWidth(runtime: *core.Runtime, window_id: platform.WindowId, label: []const u8) !f32 { + const layout = try runtime.canvasWidgetLayout(window_id, label); + for (layout.nodes) |node| { + if (node.widget.kind == .text and std.mem.eql(u8, node.widget.text, late_mixed_text)) return node.widget.frame.width; + } + return error.TestUnexpectedResult; +} + +test "a face registered after install re-measures every installed surface's layout" { + const harness = try TestHarness().create(std.testing.allocator, .{ .size = geometry.SizeF.init(240, 200) }); + defer harness.destroy(std.testing.allocator); + harness.null_platform.gpu_surfaces = true; + + // No declared fonts: the app installs measuring registered_font_id + // (the tokens' face slot) through the bundled estimator, the exact + // pre-registration state the fonts page's late-registration promise + // starts from. A declared secondary window pins the multi-surface + // half of the promise. + var options = fontAppOptions(&.{}); + options.view = lateFontAppView; + options.windows_fn = lateFontAppWindows; + options.window_view = lateFontAppWindowView; + const app_state = try std.testing.allocator.create(FontApp); + defer std.testing.allocator.destroy(app_state); + app_state.* = FontApp.init(std.testing.allocator, .{}, options); + defer app_state.deinit(); + const app = app_state.app(); + try harness.start(app); + + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{ + .label = font_app_canvas_label, + .size = geometry.SizeF.init(240, 200), + .scale_factor = 1, + .frame_index = 1, + .timestamp_ns = 1_000_000, + .nonblank = true, + } }); + try std.testing.expect(app_state.installed); + + // The always-declared panel window opened during the installing + // rebuild's window reconcile; its own first frame installs its tree. + const panel_id = blk: { + var buffer: [platform.max_windows]platform.WindowInfo = undefined; + for (harness.runtime.listWindows(&buffer)) |info| { + if (std.mem.eql(u8, info.label, late_window_label)) break :blk info.id; + } + return error.TestUnexpectedResult; + }; + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{ + .window_id = panel_id, + .label = late_window_canvas_label, + .size = geometry.SizeF.init(240, 200), + .scale_factor = 1, + .frame_index = 1, + .timestamp_ns = 2_000_000, + .nonblank = true, + } }); + + const main_before = try lateTextFrameWidth(&harness.runtime, 1, font_app_canvas_label); + const panel_before = try lateTextFrameWidth(&harness.runtime, panel_id, late_window_canvas_label); + try std.testing.expectEqual(main_before, panel_before); + + // Late registration through the runtime seam — the embedder path the + // fonts page documents under the `Options.fonts` sugar. + try harness.runtime.registerCanvasFont(registered_font_id, cjk_receipt_bytes); + + // The registration requested a frame for every open surface; ONE + // arriving frame (the main canvas's here — arrival order is the + // platform's) must re-measure every installed surface, not just the + // surface whose frame landed. + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{ + .label = font_app_canvas_label, + .size = geometry.SizeF.init(240, 200), + .scale_factor = 1, + .frame_index = 2, + .timestamp_ns = 3_000_000, + .nonblank = true, + } }); + try std.testing.expectEqual(@as(usize, 0), harness.runtime.dispatchErrors().len); + + const main_after = try lateTextFrameWidth(&harness.runtime, 1, font_app_canvas_label); + const panel_after = try lateTextFrameWidth(&harness.runtime, panel_id, late_window_canvas_label); + // Both surfaces now hold frames measured with the registered face's + // own advances — the widths moved, and moved identically. + try std.testing.expect(@abs(main_after - main_before) > 1); + try std.testing.expect(@abs(panel_after - panel_before) > 1); + try std.testing.expectEqual(main_after, panel_after); + + // The re-measured width IS the face-aware seam's answer scaled into + // the frame, not merely a different guess: the face measures the + // string wider than the estimator did (1.0 em notdef per ASCII + // codepoint versus Geist's sub-em advances), so the frame grew. + try std.testing.expect(main_after > main_before); +} + test "ui app fonts option surfaces the glyph-budget refusal as a teaching error" { const harness = try TestHarness().create(std.testing.allocator, .{ .size = geometry.SizeF.init(240, 200) }); defer harness.destroy(std.testing.allocator); diff --git a/src/runtime/canvas_fonts.zig b/src/runtime/canvas_fonts.zig index 51af7377c..8d262d12e 100644 --- a/src/runtime/canvas_fonts.zig +++ b/src/runtime/canvas_fonts.zig @@ -250,7 +250,12 @@ pub fn RuntimeCanvasFonts(comptime Runtime: type) type { /// A face joined the registry: force every gpu_surface view to /// re-render its next frame (text referencing the id may already /// be retained) and request frames so the repaint is not gated on - /// other input — the image-registry choreography. + /// other input — the image-registry choreography. Re-rendering + /// alone re-inks RETAINED geometry; installed UiApps complete the + /// late-registration story by comparing the registered count on + /// each presented frame and rebuilding every surface so layout + /// re-measures with the new face (ui_app.zig, + /// `rebuildForRegisteredFonts`). fn noteCanvasFontsChanged(self: *Runtime) void { // A new face changes what the measurement seam answers for // its id (host providers just learned the face; the engine diff --git a/src/runtime/ui_app.zig b/src/runtime/ui_app.zig index 1831808d5..4a5f7c86a 100644 --- a/src/runtime/ui_app.zig +++ b/src/runtime/ui_app.zig @@ -739,6 +739,16 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe /// register would then fail `FontIdInUse` and bury the real /// error). fonts_registered: bool = false, + /// The runtime's registered-font count the installed trees last + /// measured against. Registration is permanent with no + /// unregister, so the count IS the runtime's fonts generation: + /// a mismatch on a presented frame means a face joined the + /// registry AFTER the trees were built (late registration + /// through `runtime.registerCanvasFont` — `Options.fonts` lands + /// before the installing build), and every installed surface + /// must rebuild so layout re-measures with the new face + /// (`rebuildForRegisteredFonts`). + fonts_built_count: usize = 0, /// Exactly-once guard for `Options.init_fx`, independent of /// `installed` so a failed install rebuild cannot rerun it. init_fx_ran: bool = false, @@ -1250,14 +1260,20 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe /// Whether a rebuild must push its tokens into the runtime's /// stored copy. Derived tokens can change with any model or /// appearance input, so they always re-emit. Static tokens are - /// fixed by the app, but the runtime stamps the surface scale - /// onto them (`effectiveTokens`), so a stored copy holding a - /// stale scale re-emits too — hairlines re-snap after a move - /// between monitors — while ordinary rebuilds keep skipping the - /// redundant emission. + /// fixed by the app, but the runtime stamps two live values onto + /// them: the surface scale (`effectiveTokens` — a stored copy + /// holding a stale scale re-emits so hairlines re-snap after a + /// move between monitors) and the text-measure provider + /// (`tokensWithTextMeasure` — the FIRST font registration binds + /// the runtime's font-aware provider on platforms without host + /// measurement, and a stored copy still measuring with the + /// estimator would keep display-list text layout on + /// pre-registration metrics). Ordinary rebuilds keep skipping + /// the redundant emission. fn rebuildEmitsTokens(self: *const Self, runtime: *Runtime, window_id: platform.WindowId, canvas_label: []const u8, tokens: canvas.DesignTokens) bool { if (self.derivesTokens()) return true; const stored = runtime.canvasWidgetDesignTokens(window_id, canvas_label) catch return true; + if (!std.meta.eql(stored.text_measure, tokens.text_measure)) return true; return stored.pixel_snap.scale != tokens.pixel_snap.scale; } @@ -2949,6 +2965,12 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe _ = try runtime.emitCanvasWidgetDisplayList(frame_event.window_id, self.options.canvas_label, runtime.tokensWithTextMeasure(self.effectiveTokens())); } self.installed = true; + // The installing rebuild measured with everything the + // registry holds right now (declared fonts registered + // above, embedder registrations before install): adopt + // the count so only faces joining AFTER this build + // trigger the late-registration rebuild. + self.fonts_built_count = runtime.registeredCanvasFontCount(); self.startMarkupWatch(runtime); self.installStatusItem(runtime); } else if (@abs(self.pixel_snap_scale - scale) > 0.001) { @@ -2977,6 +2999,17 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe // job. self.canvas_size = frame_event.size; try self.rebuild(runtime, frame_event.window_id); + } else if (runtime.registeredCanvasFontCount() != self.fonts_built_count) { + // A face joined the registry after install (late + // registration through the runtime seam). The runtime + // already invalidated measurement caches and requested + // frames for every open surface (noteCanvasFontsChanged), + // but repainting retained geometry only re-inks text + // measured with the OLD seam answers: honoring the fonts + // doc's promise — every open surface re-measures — means + // rebuilding so layout and the re-emitted display lists + // charge the registered face's advances. + try self.rebuildForRegisteredFonts(runtime); } else if (self.options.web_panes != null) { // Re-snap the webview panes each presented frame: a shell // relayout that stomped a pane frame also invalidated the @@ -3031,10 +3064,32 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe // stale bounds. slot.canvas_size = frame_event.size; try self.rebuildWindowSlot(runtime, slot); + } else if (runtime.registeredCanvasFontCount() != self.fonts_built_count) { + // Late registration, first observed on a secondary + // window's frame (registration requests frames for every + // open surface, and arrival order is the platform's): + // the same all-surfaces rebuild as the main canvas — + // the count is app-level, so whichever surface's frame + // lands first re-measures all of them. + try self.rebuildForRegisteredFonts(runtime); } try self.presentFrame(runtime, frame_event, slot.canvasLabel(), installing); } + /// A face joined the runtime's font registry after this app's + /// trees were built: rebuild EVERY installed surface — main + /// canvas and declared secondary windows — so widget frames and + /// baked text runs re-measure against the registered face, then + /// adopt the count. Rebuild re-emits each surface's display list + /// (`rebuildEmitsTokens` treats a text-measure provider change + /// as an emit reason), so the repaint the runtime already + /// requested draws re-measured geometry, not re-inked stale + /// frames. + fn rebuildForRegisteredFonts(self: *Self, runtime: *Runtime) anyerror!void { + self.fonts_built_count = runtime.registeredCanvasFontCount(); + try self.rebuildAllViews(runtime); + } + /// Present the planned canvas frame: GPU packet when the platform /// has a packet presenter (macOS/Metal — unchanged), otherwise the /// CPU reference-rendered pixel path (`presentGpuSurfacePixels`, From a6b77c7ba3332ec6f8f0501904cc7739256c90f8 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 18 Jul 2026 15:39:05 -0500 Subject: [PATCH 09/21] Split the matrix's text row so mobile text is not denied - The single "Text & registered fonts" row rendered "Not available today" for iOS/Android, false for bundled text: mobile hosts present the same reference-renderer pixels the desktop software hosts ink. - Now two rows: "Text" is full on all five platforms, and "Registered fonts" keeps full desktop with the honest none-unverified mobile note; footnote 3 already narrates both halves, so fn={3} stays on every cell and nothing renumbers. --- docs/src/app/platform-support/page.mdx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/src/app/platform-support/page.mdx b/docs/src/app/platform-support/page.mdx index 15c27b5b0..a2324d863 100644 --- a/docs/src/app/platform-support/page.mdx +++ b/docs/src/app/platform-support/page.mdx @@ -39,10 +39,18 @@ macOS, Linux, and Windows run full desktop apps through their own platform hosts - Text & registered fonts - - - + Text + + + + + + + + Registered fonts + + + From b58c9515834a2022f925247b33eb9eee5fc852a0 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 18 Jul 2026 16:17:55 -0500 Subject: [PATCH 10/21] Invalidate the AppKit measured-width cache when a font id re-registers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The width NSCache in native_sdk_appkit_measure_text survived re-registration: the round-3 eviction covered only the NSFont size cache, so a new runtime reusing an id kept measuring the previous face's widths while drawing the new one. - NSCache cannot enumerate keys, so prefix eviction is impossible there; instead registration bumps a per-id generation (stored beside the descriptor table, under the same @synchronized) and the width-cache key includes it, so the old generation's entries become unreachable and age out under the cache's own count limit — keeping NSCache's bounding and memory-pressure purging, which a dictionary conversion would forfeit for a 16384-entry text-keyed cache. - The test-appkit-registered-font-cache-eviction pin step now covers the width-cache half (generation table, bump inside register_font, generation-carrying key), same textual tier as round 3 since no SDK test tier compiles the ObjC host. --- build.zig | 11 ++++++- src/platform/macos/appkit_host.m | 50 ++++++++++++++++++++++++++++++-- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/build.zig b/build.zig index c70b22dfa..f897d34e6 100644 --- a/build.zig +++ b/build.zig @@ -809,7 +809,7 @@ pub fn build(b: *std.Build) void { .{ .path = "src/platform/macos/appkit_host.m", .pattern = "NativeSdkItalicSansFont(base)" }, .{ .path = "src/platform/macos/appkit_host.m", .pattern = "NativeSdkItalicSansFont(NativeSdkWeightedSansFont(@[ @\"Geist-Bold\", @\"Geist Bold\" ], base, NSFontWeightBold, YES, size))" }, }); - addFileContainsCheckStep(b, file_contains_checker, test_step, "test-appkit-registered-font-cache-eviction", "Verify the AppKit registered-font size cache evicts an id's cached NSFonts at registration (the cache is per-process while font-id permanence is per-runtime, so a new runtime re-registering an id must never resolve the previous runtime's face)", &.{ + addFileContainsCheckStep(b, file_contains_checker, test_step, "test-appkit-registered-font-cache-eviction", "Verify the AppKit host invalidates BOTH per-process registered-font caches at registration (the caches are per-process while font-id permanence is per-runtime, so a new runtime re-registering an id must never resolve the previous runtime's face or its measured widths): the NSFont size cache by prefix eviction and the measured-width NSCache by a per-id generation in its key", &.{ // No SDK test tier links appkit_host.m (only managed app builds // compile it), so the eviction wiring is pinned textually like // the other AppKit host contracts: the shared accessor both the @@ -819,6 +819,15 @@ pub fn build(b: *std.Build) void { .{ .path = "src/platform/macos/appkit_host.m", .pattern = "NSString *stalePrefix = [NSString stringWithFormat:@\"%llu/\", (unsigned long long)font_id];" }, .{ .path = "src/platform/macos/appkit_host.m", .pattern = "if ([cachedKey hasPrefix:stalePrefix]) [sizeCache removeObjectForKey:cachedKey];" }, .{ .path = "src/platform/macos/appkit_host.m", .pattern = "an id is only permanent within" }, + // The width-cache half: NSCache cannot enumerate keys, so the + // measured-width cache is invalidated by a per-id generation + // that registration bumps and the cache key includes — the + // generation table, the bump inside register_font, and the + // generation-carrying key inside measure_text. + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "static NSMutableDictionary *NativeSdkRegisteredFontGenerations(void)" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "generations[@(font_id)] = @((previous ? previous.unsignedLongLongValue : 0) + 1);" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "unsigned long long generation = NativeSdkRegisteredFontGeneration((unsigned long long)font_id);" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "[NSString stringWithFormat:@\"%llu/%llu/%.3f/%@\", (unsigned long long)font_id, generation, (double)clamped, value]" }, }); addFileContainsCheckStep(b, file_contains_checker, test_step, "test-appkit-gpu-widget-cursor-bridge", "Verify AppKit GPU widgets apply retained cursor intent", &.{ .{ .path = "src/platform/macos/appkit_host.m", .pattern = "native_sdk_appkit_set_view_cursor" }, diff --git a/src/platform/macos/appkit_host.m b/src/platform/macos/appkit_host.m index 46b31f851..18b1bbfa2 100644 --- a/src/platform/macos/appkit_host.m +++ b/src/platform/macos/appkit_host.m @@ -1771,7 +1771,11 @@ static NSTextAlignment NativeSdkPacketTextAlignment(NSString *align) { // valid id in a new runtime is inside the documented lifecycle. Entries // therefore go stale exactly when an id's descriptor is (re)installed, // so `native_sdk_appkit_register_font` evicts the id's cached sizes -// before installing the new descriptor. Accessed only under the +// before installing the new descriptor. The measured-width NSCache in +// `native_sdk_appkit_measure_text` goes stale at the same moment but +// cannot be prefix-evicted (NSCache does not enumerate keys), so it is +// invalidated by generation instead — see +// NativeSdkRegisteredFontGenerations below. Accessed only under the // descriptor table's @synchronized guard, like the descriptors. static NSMutableDictionary *NativeSdkRegisteredFontSizeCache(void) { static NSMutableDictionary *cache = nil; @@ -1782,6 +1786,35 @@ static NSTextAlignment NativeSdkPacketTextAlignment(NSString *align) { return cache; } +// Per-id registration generations, the invalidation handle for the +// measured-width NSCache in `native_sdk_appkit_measure_text`. That +// cache has the same lifetime hazard as the size cache above (per- +// process cache, per-runtime id permanence) but no way to evict an +// id's entries — NSCache cannot enumerate keys — so every registration +// bumps the id's generation here and the width-cache key includes it: +// a previous runtime's widths become unreachable and age out under the +// cache's own count limit. Ids never registered (the built-in faces) +// stay at generation 0 forever. Accessed only under the descriptor +// table's @synchronized guard, like the descriptors. +static NSMutableDictionary *NativeSdkRegisteredFontGenerations(void) { + static NSMutableDictionary *table = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + table = [[NSMutableDictionary alloc] init]; + }); + return table; +} + +// The current registration generation for a font id: 0 until the id +// first registers, bumped by every (re)registration. +static unsigned long long NativeSdkRegisteredFontGeneration(unsigned long long value) { + NSMutableDictionary *table = NativeSdkRegisteredFontDescriptors(); + @synchronized (table) { + NSNumber *generation = NativeSdkRegisteredFontGenerations()[@(value)]; + return generation ? generation.unsignedLongLongValue : 0; + } +} + // The registered face for a canvas font id at `size`, or nil when the id // has no registered face. Checked BEFORE the built-in candidates and // their cache so a registered id can never be masked by a font resolved @@ -1831,6 +1864,13 @@ int native_sdk_appkit_register_font(uint64_t font_id, const uint8_t *bytes, size for (NSString *cachedKey in cachedKeys) { if ([cachedKey hasPrefix:stalePrefix]) [sizeCache removeObjectForKey:cachedKey]; } + // The measured-width cache holds the same stale state but + // cannot be enumerated for eviction, so bump the id's + // generation instead: width-cache keys include it, and the + // old generation's entries become unreachable. + NSMutableDictionary *generations = NativeSdkRegisteredFontGenerations(); + NSNumber *previous = generations[@(font_id)]; + generations[@(font_id)] = @((previous ? previous.unsignedLongLongValue : 0) + 1); table[@(font_id)] = (__bridge_transfer id)descriptor; } return 1; @@ -1917,7 +1957,13 @@ int native_sdk_appkit_register_font(uint64_t font_id, const uint8_t *bytes, size widthCache = [[NSCache alloc] init]; widthCache.countLimit = 16384; }); - NSString *key = [NSString stringWithFormat:@"%llu/%.3f/%@", (unsigned long long)font_id, (double)clamped, value]; + // The key carries the id's registration generation so a face + // re-registered under the same id (a new runtime in this + // process) never serves the previous face's widths — see + // NativeSdkRegisteredFontGenerations for why generation, not + // eviction, invalidates this cache. + unsigned long long generation = NativeSdkRegisteredFontGeneration((unsigned long long)font_id); + NSString *key = [NSString stringWithFormat:@"%llu/%llu/%.3f/%@", (unsigned long long)font_id, generation, (double)clamped, value]; NSNumber *cached = [widthCache objectForKey:key]; if (cached) return cached.doubleValue; NSFont *font = NativeSdkFontForFontId(font_id, clamped); From 368f18087fdfd7572b8ed9c01b245d1a445bcc40 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 18 Jul 2026 16:18:05 -0500 Subject: [PATCH 11/21] Scope the fonts page's late-registration and no-cascade promises to the truth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Late registration: automatic re-measure and rebuild is UiApp wiring, so the sentence now says UiApp surfaces rebuild automatically while callers driving the runtime directly re-emit their display lists after registering. - No-cascade: true for the engine paths (reference renderer, glyph atlas, SDK-rasterized packet text), but macOS host-drawn text follows platform shaping including system-font fallback, so uncovered codepoints there may render from a substituted family instead of notdef — the paragraph now states both halves. --- docs/src/app/fonts/page.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/app/fonts/page.mdx b/docs/src/app/fonts/page.mdx index 3258a3648..db7ee174d 100644 --- a/docs/src/app/fonts/page.mdx +++ b/docs/src/app/fonts/page.mdx @@ -49,7 +49,7 @@ The face must be a TrueType (`glyf`) file. The Google Fonts TrueType builds of t try runtime.registerCanvasFont(cjk_font_id, ttf_bytes); ``` -Register at startup so the first layout already measures with the face. Late registration still works honestly: every open surface re-measures and repaints when a face joins the registry. +Register at startup so the first layout already measures with the face. Late registration still works honestly: `UiApp` surfaces re-measure and rebuild automatically when a face joins the registry, and callers driving the runtime directly re-emit their display lists after registering. ## Validation is loud and registration-time only @@ -74,7 +74,7 @@ There is no per-widget font attribute. Text resolves its face through the typogr Layout and ink stay in lockstep on the desktop paths. On macOS, the raw bytes reach the host at registration time, so CoreText measurement and packet text drawing resolve the exact registered face even when the family is not installed system-wide. On Windows and Linux, layout charges the parsed face's own `cmap`/`hmtx` advances, and the deterministic reference renderer inks the same outlines — CJK advances measure as the face declares them, not as the Latin estimator would guess. Mobile hosts do not yet have a registered-font measurement seam, so this guarantee does not extend there — see the platform notes below. -Codepoints the registered face does not cover keep the same per-glyph fallback as the built-ins: the face's own notdef box. A registered face never silently cascades into another family — missing coverage is visible, never approximated. +Codepoints the registered face does not cover keep the same per-glyph fallback as the built-ins: the face's own notdef box. The engine never silently cascades into another family — the reference renderer, the glyph atlas, and packet text through the SDK's rasterizer show missing coverage as visible notdef, never an approximation. Text the macOS host draws follows platform shaping, including system-font fallback, so uncovered codepoints there may render from a substituted family instead of notdef. One honest interaction with the [tofu guard](/native-ui#tooling): the static markup check validates literal text against the *bundled* face's coverage — it runs without your app and cannot know what you register. Text in a script outside bundled coverage reaches the view through model bindings (`{greeting}` — the guard deliberately skips binding spans, and real CJK content is almost always data) or through Zig-built views, where the same lesson is a Debug-build diagnostic rather than an error. From 4482dcad3e98935d18cc6f0a8106c254fe31d3dd Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 18 Jul 2026 19:18:05 -0500 Subject: [PATCH 12/21] Return the host-side font registration at Runtime.deinit - New optional platform service unregisterGpuSurfaceFont(id): Runtime.deinit calls it per registered id before freeing the bytes, so the macOS host's per-process font state (CoreText descriptor, size cache, width-cache generation) no longer outlives the runtime that registered it; the CEF host accepts statelessly and platforms without the register seam keep answering UnsupportedService. - The null platform records the teardown call, the embed cycle/idempotence tests assert deinit makes it exactly once, and the AppKit eviction pin step now holds the ObjC removal and the deinit call site. - Fonts page: deinit returns the host-side registration too, and host font state is one face per id per process (concurrent runtimes sharing an id see the last registration - a deliberate current constraint). --- build.zig | 11 +++++++++++ docs/src/app/fonts/page.mdx | 2 +- src/embed/tests.zig | 16 +++++++++++++++- src/platform/macos/appkit_host.h | 6 ++++++ src/platform/macos/appkit_host.m | 31 +++++++++++++++++++++++++++++++ src/platform/macos/cef_host.mm | 8 ++++++++ src/platform/macos/root.zig | 13 +++++++++++++ src/platform/null_platform.zig | 19 +++++++++++++++++++ src/platform/types.zig | 16 ++++++++++++++++ src/runtime/core.zig | 20 +++++++++++++++++--- 10 files changed, 137 insertions(+), 5 deletions(-) diff --git a/build.zig b/build.zig index f897d34e6..f085fb55f 100644 --- a/build.zig +++ b/build.zig @@ -828,6 +828,17 @@ pub fn build(b: *std.Build) void { .{ .path = "src/platform/macos/appkit_host.m", .pattern = "generations[@(font_id)] = @((previous ? previous.unsignedLongLongValue : 0) + 1);" }, .{ .path = "src/platform/macos/appkit_host.m", .pattern = "unsigned long long generation = NativeSdkRegisteredFontGeneration((unsigned long long)font_id);" }, .{ .path = "src/platform/macos/appkit_host.m", .pattern = "[NSString stringWithFormat:@\"%llu/%llu/%.3f/%@\", (unsigned long long)font_id, generation, (double)clamped, value]" }, + // The teardown half: Runtime.deinit returns the host-side + // registration through the platform unregister seam, and the + // ObjC removal runs the SAME eviction machinery before dropping + // the descriptor entry. Pinned like the registration half + // (appkit_host.m has no SDK test tier); the deinit call site is + // pinned too so the seam can never silently lose its one caller, + // and the embed cycle test asserts that call behaviorally + // against the null platform's recorder. + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "int native_sdk_appkit_unregister_font(uint64_t font_id) {" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "[table removeObjectForKey:@(font_id)];" }, + .{ .path = "src/runtime/core.zig", .pattern = "self.options.platform.services.unregisterGpuSurfaceFont(entry.id) catch {};" }, }); addFileContainsCheckStep(b, file_contains_checker, test_step, "test-appkit-gpu-widget-cursor-bridge", "Verify AppKit GPU widgets apply retained cursor intent", &.{ .{ .path = "src/platform/macos/appkit_host.m", .pattern = "native_sdk_appkit_set_view_cursor" }, diff --git a/docs/src/app/fonts/page.mdx b/docs/src/app/fonts/page.mdx index db7ee174d..aa2d359da 100644 --- a/docs/src/app/fonts/page.mdx +++ b/docs/src/app/fonts/page.mdx @@ -66,7 +66,7 @@ Every failure happens at registration, never at render time — a registered id ## Ownership and lifecycle -Registration copies the file into an exact-size heap allocation owned by the runtime, so the caller's buffer (an `@embedFile` slice, a transient read) is free the moment the call returns. A runtime with no registered fonts carries zero font bytes. The copy lives until `Runtime.deinit` — registration is permanent, so ownership is one allocation and one free. Apps that live for the process never think about this; embedders that create and destroy runtimes call `Runtime.deinit` (the SDK's own embed hosts already do). +Registration copies the file into an exact-size heap allocation owned by the runtime, so the caller's buffer (an `@embedFile` slice, a transient read) is free the moment the call returns. A runtime with no registered fonts carries zero font bytes. The copy lives until `Runtime.deinit` — registration is permanent, so ownership is one allocation and one free. Apps that live for the process never think about this; embedders that create and destroy runtimes call `Runtime.deinit` (the SDK's own embed hosts already do). `Runtime.deinit` returns the host-side registration too: on macOS, each registered id's host font state (the CoreText descriptor and its measurement caches) is dropped at deinit, so runtime cycles do not accumulate host entries. Host font state is one face per id per process — concurrent runtimes in one process that share an id resolve the most recent registration; that is a deliberate current constraint, so give concurrently live runtimes disjoint ids. ## How text finds the face diff --git a/src/embed/tests.zig b/src/embed/tests.zig index 62370d2ec..561690e3c 100644 --- a/src/embed/tests.zig +++ b/src/embed/tests.zig @@ -109,6 +109,17 @@ test "embedded app deinit returns registered font bytes across create-destroy cy try embedded.runtime.registerCanvasFont(canvas.min_registered_font_id, canvas.font_ttf.geist_mono_bytes); try std.testing.expectEqual(@as(usize, 1), embedded.runtime.registeredCanvasFontCount()); try embedded.stop(); + + // Deinit returns BOTH sides of the registration: the Zig-side + // bytes (the leak-checking allocator above) and the host-side + // registration — the runtime must call the platform's + // font-unregister seam per registered id, the call a macOS host + // answers by dropping its CoreText descriptor and caches. The + // null platform records that call; the deferred second deinit + // stays the idempotence backstop. + embedded.deinit(); + try std.testing.expectEqual(@as(usize, 1), null_platform.gpu_surface_font_unregister_count); + try std.testing.expectEqual(@as(u64, canvas.min_registered_font_id), null_platform.gpu_surface_font_unregister_id); } } @@ -127,10 +138,13 @@ test "embedded app deinit is idempotent" { // The documented idiom is `defer embedded.deinit()`, which must // compose with an explicit early teardown: the second call is a - // no-op, never a double free. + // no-op, never a double free — and never a double host unregister + // (the first deinit already returned the host-side registration). embedded.deinit(); try std.testing.expectEqual(@as(usize, 0), embedded.runtime.registeredCanvasFontCount()); + try std.testing.expectEqual(@as(usize, 1), null_platform.gpu_surface_font_unregister_count); embedded.deinit(); + try std.testing.expectEqual(@as(usize, 1), null_platform.gpu_surface_font_unregister_count); } test "mobile C ABI destroy returns registered font bytes through the embedded deinit" { diff --git a/src/platform/macos/appkit_host.h b/src/platform/macos/appkit_host.h index 5a512cf52..ccbf9d981 100644 --- a/src/platform/macos/appkit_host.h +++ b/src/platform/macos/appkit_host.h @@ -488,6 +488,12 @@ int native_sdk_appkit_measure_text_advances(uint64_t font_id, double size, const // measurement and packet text drawing resolve the id to this exact face. // Returns 1 on success, 0 when CoreText rejects the data. int native_sdk_appkit_register_font(uint64_t font_id, const uint8_t *bytes, size_t bytes_len); +// Drop the per-id state a registration installed (the descriptor and its +// caches) — the teardown twin Runtime.deinit calls, because font ids are +// per-runtime while this host's font state is per-process. Unregistering +// an id with no installed descriptor is a no-op accept. Returns 1 on +// accept, 0 only for the invalid id 0. +int native_sdk_appkit_unregister_font(uint64_t font_id); /* Decode encoded image bytes (PNG, JPEG, ... — whatever ImageIO supports) * through CGImageSource into tightly packed, row-major, straight-alpha * (non-premultiplied) RGBA8 written into `pixels`. Returns 1 on success diff --git a/src/platform/macos/appkit_host.m b/src/platform/macos/appkit_host.m index 18b1bbfa2..4cc9895c5 100644 --- a/src/platform/macos/appkit_host.m +++ b/src/platform/macos/appkit_host.m @@ -1877,6 +1877,37 @@ int native_sdk_appkit_register_font(uint64_t font_id, const uint8_t *bytes, size } } +// Teardown twin of the registration above, called by Runtime.deinit for +// each id the dying runtime registered: font ids are per-runtime but +// every table here is per-process, so without this removal an embedder +// cycling runtimes with fresh ids grows the descriptor table (and its +// caches) for the process lifetime. Removal runs the SAME eviction the +// re-registration path runs — prefix-evict the id's cached NSFonts and +// bump its generation so measured widths keyed under the old generation +// become unreachable — then drops the descriptor itself. An id with no +// installed descriptor (never registered host-side, or already returned) +// is a no-op accept. +int native_sdk_appkit_unregister_font(uint64_t font_id) { + if (font_id == 0) return 0; + @autoreleasepool { + NSMutableDictionary *table = NativeSdkRegisteredFontDescriptors(); + @synchronized (table) { + if (!table[@(font_id)]) return 1; + NSMutableDictionary *sizeCache = NativeSdkRegisteredFontSizeCache(); + NSString *stalePrefix = [NSString stringWithFormat:@"%llu/", (unsigned long long)font_id]; + NSArray *cachedKeys = sizeCache.allKeys; + for (NSString *cachedKey in cachedKeys) { + if ([cachedKey hasPrefix:stalePrefix]) [sizeCache removeObjectForKey:cachedKey]; + } + NSMutableDictionary *generations = NativeSdkRegisteredFontGenerations(); + NSNumber *previous = generations[@(font_id)]; + generations[@(font_id)] = @((previous ? previous.unsignedLongLongValue : 0) + 1); + [table removeObjectForKey:@(font_id)]; + } + return 1; + } +} + // Resolves a canvas font id to the NSFont presentation draws with. Both // packet text drawing and native_sdk_appkit_measure_text go through this // single function so measured layout and drawn glyphs share font diff --git a/src/platform/macos/cef_host.mm b/src/platform/macos/cef_host.mm index ad133716f..070798807 100644 --- a/src/platform/macos/cef_host.mm +++ b/src/platform/macos/cef_host.mm @@ -2466,6 +2466,14 @@ int native_sdk_appkit_register_font(uint64_t font_id, const uint8_t *bytes, size return 1; } +/* Teardown twin of the stateless accept above: registration retained no + * host state here, so Runtime.deinit's per-id unregister has nothing to + * return — accept it for the same start-identically reason. */ +int native_sdk_appkit_unregister_font(uint64_t font_id) { + if (font_id == 0) return 0; + return 1; +} + /* The Chromium host has no packet text renderer, so there are no host * metrics to match: return the documented negative sentinel and the canvas * provider uses its estimator (the same fallback the AppKit host takes for diff --git a/src/platform/macos/root.zig b/src/platform/macos/root.zig index 32969fbe2..9861f1d03 100644 --- a/src/platform/macos/root.zig +++ b/src/platform/macos/root.zig @@ -188,6 +188,7 @@ extern fn native_sdk_appkit_clipboard_read(host: *AppKitHost, buffer: [*]u8, buf extern fn native_sdk_appkit_measure_text(font_id: u64, size: f64, text: [*]const u8, text_len: usize) f64; extern fn native_sdk_appkit_measure_text_advances(font_id: u64, size: f64, text: [*]const u8, text_len: usize, advances: [*]f32) c_int; extern fn native_sdk_appkit_register_font(font_id: u64, bytes: [*]const u8, bytes_len: usize) c_int; +extern fn native_sdk_appkit_unregister_font(font_id: u64) c_int; extern fn native_sdk_appkit_register_bundled_fonts() void; extern fn native_sdk_appkit_decode_image(bytes: [*]const u8, bytes_len: usize, pixels: [*]u8, pixels_len: usize, out_width: *usize, out_height: *usize) c_int; extern fn native_sdk_appkit_clipboard_write(host: *AppKitHost, text: [*]const u8, text_len: usize) void; @@ -640,6 +641,7 @@ pub const MacPlatform = struct { .upload_gpu_surface_image_fn = uploadGpuSurfaceImage, .remove_gpu_surface_image_fn = removeGpuSurfaceImage, .register_gpu_surface_font_fn = registerGpuSurfaceFont, + .unregister_gpu_surface_font_fn = unregisterGpuSurfaceFont, .update_widget_accessibility_fn = updateWidgetAccessibility, .measure_text_fn = measureText, .measure_text_advances_fn = measureTextAdvances, @@ -932,6 +934,7 @@ pub fn installHeadlessTextServices(services: *platform_mod.PlatformServices) voi services.measure_text_fn = measureText; services.measure_text_advances_fn = measureTextAdvances; services.register_gpu_surface_font_fn = registerGpuSurfaceFont; + services.unregister_gpu_surface_font_fn = unregisterGpuSurfaceFont; } fn measureText(context: ?*anyopaque, font_id: u64, size: f32, text: []const u8) f32 { @@ -1486,6 +1489,16 @@ fn registerGpuSurfaceFont(context: ?*anyopaque, font: platform_mod.GpuSurfaceFon if (native_sdk_appkit_register_font(font.id, font.ttf.ptr, font.ttf.len) == 0) return error.InvalidGpuSurfaceFont; } +/// Teardown twin of the registration above: drop the host's per-id font +/// state (the CoreText descriptor and its caches) when the runtime that +/// registered the id deinits. Same seam, so deliberately not gated on +/// `web_engine` either; unregistering an id the host never saw is a +/// no-op accept. +fn unregisterGpuSurfaceFont(context: ?*anyopaque, id: u64) anyerror!void { + _ = context; + if (native_sdk_appkit_unregister_font(id) == 0) return error.InvalidGpuSurfaceFont; +} + fn updateWidgetAccessibility(context: ?*anyopaque, snapshot: platform_mod.WidgetAccessibilitySnapshot) anyerror!void { const self: *MacPlatform = @ptrCast(@alignCast(context.?)); if (self.web_engine != .system) return error.UnsupportedViewKind; diff --git a/src/platform/null_platform.zig b/src/platform/null_platform.zig index 7491a3fe6..c664fee9b 100644 --- a/src/platform/null_platform.zig +++ b/src/platform/null_platform.zig @@ -433,6 +433,14 @@ pub const NullPlatform = struct { gpu_surface_image_upload_byte_len: usize = 0, gpu_surface_image_upload_sample_rgba: [4]u8 = .{ 0, 0, 0, 0 }, gpu_surface_image_remove_id: u64 = 0, + /// Font-unregister recorder for the host-teardown seam Runtime.deinit + /// drives. The null platform has no register seam (no host-side text + /// — engine-side registration is the whole story here), but it does + /// accept and record the teardown call, so embed tests can pin that + /// deinit returns the host-side registration on platforms that do + /// retain per-id font state (macOS's CoreText descriptor and caches). + gpu_surface_font_unregister_count: usize = 0, + gpu_surface_font_unregister_id: u64 = 0, timers: [max_null_timers]NullTimer = [_]NullTimer{.{}} ** max_null_timers, timer_count: usize = 0, timer_start_count: usize = 0, @@ -619,6 +627,7 @@ pub const NullPlatform = struct { .present_gpu_surface_packet_binary_fn = presentGpuSurfacePacketBinary, .upload_gpu_surface_image_fn = uploadGpuSurfaceImage, .remove_gpu_surface_image_fn = removeGpuSurfaceImage, + .unregister_gpu_surface_font_fn = unregisterGpuSurfaceFont, .decode_image_fn = decodeImage, .set_gpu_surface_scroll_drivers_fn = setGpuSurfaceScrollDrivers, .show_context_menu_fn = if (self.context_menus) showContextMenu else null, @@ -1761,6 +1770,16 @@ pub const NullPlatform = struct { self.gpu_surface_image_count = last; } + /// Recording no-op (see the recorder fields): there is no host font + /// state to drop, and unlike the image seam this one is not gated on + /// `gpu_surfaces` — Runtime.deinit unregisters every registered font + /// regardless of what the surface renders through. + fn unregisterGpuSurfaceFont(context: ?*anyopaque, id: u64) anyerror!void { + const self: *NullPlatform = @ptrCast(@alignCast(context.?)); + self.gpu_surface_font_unregister_id = id; + self.gpu_surface_font_unregister_count += 1; + } + /// The recorded side-channel entry for `id`, or null when the id was /// never uploaded (or has been removed) — the store a packet host /// would consult on an upload cache action. diff --git a/src/platform/types.zig b/src/platform/types.zig index 21ab1b133..fffea83c8 100644 --- a/src/platform/types.zig +++ b/src/platform/types.zig @@ -2205,6 +2205,16 @@ pub const PlatformServices = struct { /// measures with the parsed face and inks it through the reference /// renderer. register_gpu_surface_font_fn: ?*const fn (context: ?*anyopaque, font: GpuSurfaceFontData) anyerror!void = null, + /// Teardown twin of the registration seam above: return whatever + /// per-id state the host retained for a registered face (descriptor + /// tables, size/measurement caches) when the runtime that registered + /// the id deinits. Fonts are per-runtime but host font state is + /// per-process, so without this seam an embedder cycling runtimes + /// with fresh ids grows host state for the process lifetime. + /// Unregistering an id the host never saw is a no-op, not an error. + /// Null on platforms whose register seam is itself null (GTK/Win32 + /// default) — with no host font state there is nothing to return. + unregister_gpu_surface_font_fn: ?*const fn (context: ?*anyopaque, id: u64) anyerror!void = null, update_widget_accessibility_fn: ?*const fn (context: ?*anyopaque, snapshot: WidgetAccessibilitySnapshot) anyerror!void = null, /// Reconcile the native scroll drivers for a gpu-surface view against /// the full desired set: create missing drivers, update frames / @@ -2702,6 +2712,12 @@ pub const PlatformServices = struct { return register_fn(self.context, font); } + pub fn unregisterGpuSurfaceFont(self: PlatformServices, id: u64) anyerror!void { + if (id == 0) return error.InvalidGpuSurfaceFont; + const unregister_fn = self.unregister_gpu_surface_font_fn orelse return error.UnsupportedService; + return unregister_fn(self.context, id); + } + pub fn updateWidgetAccessibility(self: PlatformServices, snapshot: WidgetAccessibilitySnapshot) anyerror!void { if (snapshot.view_label.len == 0 or snapshot.view_label.len > max_view_label_bytes) return error.InvalidViewOptions; if (snapshot.nodes.len > max_widget_accessibility_nodes) return error.InvalidViewOptions; diff --git a/src/runtime/core.zig b/src/runtime/core.zig index 30f69e6c0..9d60d1a8e 100644 --- a/src/runtime/core.zig +++ b/src/runtime/core.zig @@ -461,9 +461,12 @@ pub const Runtime = struct { /// Release the runtime's heap-owned on-demand storage (registered /// canvas font bytes, registered canvas image slot buffers, and /// adopted media-surface texture buffers, allocated from the - /// init-frozen `owned_allocator`) and disarm the - /// media-surface wake bindings (a producer thread must never wake a - /// dead host). Ends the runtime's life: parsed faces, atlas keys, + /// init-frozen `owned_allocator`), return each registered font's + /// host-side registration (the platform `unregisterGpuSurfaceFont` + /// seam — host font state is per-process, this runtime's ids are + /// not), and disarm the media-surface wake bindings (a producer + /// thread must never wake a dead host). Ends the runtime's life: + /// parsed faces, atlas keys, /// measure providers, adopted textures, and the resource slices /// built over them are invalid past this point. Process-lifetime /// embeddings (the app runner) may skip it — the default @@ -474,6 +477,17 @@ pub const Runtime = struct { pub fn deinit(self: *Runtime) void { self.disarmMediaSurfaceWakes(); for (self.canvas_font_entries[0..self.canvas_font_count]) |entry| { + // Return the HOST side of the registration before the bytes: + // registration pushed the face to platforms with host-side + // text (macOS installs a CoreText descriptor plus caches, + // keyed by an id that is only permanent per-RUNTIME while + // that host state is per-process), so a deinit that freed + // only the Zig-side bytes would leave an embedder cycling + // runtimes growing host font state for the process lifetime. + // Best-effort by design — deinit cannot fail, and platforms + // without the seam answer `UnsupportedService` because they + // retained nothing to return. + self.options.platform.services.unregisterGpuSurfaceFont(entry.id) catch {}; // Through the frozen identity, never `options.allocator`: // an embedder may have swapped the public option since these // bytes were made, and a free must hit the allocator that From cc1b210bf21fbbed52484655a4039942be0a3095 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 18 Jul 2026 19:23:43 -0500 Subject: [PATCH 13/21] Adopt the late-font rebuild count only after the rebuild succeeds - rebuildForRegisteredFonts assigned fonts_built_count before rebuildAllViews, so under production's degrade policy a failed rebuild (widget budget, allocator pressure, a secondary window's emit) marked stale layouts as font-current and never retried. - New test drives a late registration into a budget-failing rebuild, asserts the count stays unadopted, and proves the next healthy frame retries, re-measures with the registered face, and only then adopts. --- src/runtime/canvas_font_tests.zig | 130 ++++++++++++++++++++++++++++++ src/runtime/ui_app.zig | 9 ++- 2 files changed, 138 insertions(+), 1 deletion(-) diff --git a/src/runtime/canvas_font_tests.zig b/src/runtime/canvas_font_tests.zig index f2677531a..e964db9c1 100644 --- a/src/runtime/canvas_font_tests.zig +++ b/src/runtime/canvas_font_tests.zig @@ -949,6 +949,136 @@ test "a face registered after install re-measures every installed surface's layo try std.testing.expect(main_after > main_before); } +// ------------------------ late registration adopts only on success + +/// Model for the failed-rebuild retry test below: `row_count` extra rows +/// join the measured text, so the test can push one rebuild past the +/// per-view widget budget and then heal it. +const RetryFontModel = struct { + row_count: usize = 0, + + pub fn rows(model: *const RetryFontModel, arena: std.mem.Allocator) []const usize { + const out = arena.alloc(usize, model.row_count) catch return &.{}; + for (out, 0..) |*slot, index| slot.* = index; + return out; + } +}; +const RetryFontMsg = union(enum) { noop }; +const RetryFontApp = ui_app_model.UiApp(RetryFontModel, RetryFontMsg); + +fn retryFontUpdate(model: *RetryFontModel, msg: RetryFontMsg) void { + _ = model; + _ = msg; +} + +fn retryFontTokens(model: *const RetryFontModel) canvas.DesignTokens { + _ = model; + var tokens = canvas.DesignTokens{}; + tokens.typography.font_id = registered_font_id; + return tokens; +} + +fn retryRowKey(index: *const usize) canvas.UiKey { + return canvas.uiKey(@as(u64, index.*)); +} + +fn retryRow(ui: *RetryFontApp.Ui, index: *const usize) RetryFontApp.Ui.Node { + return ui.text(.{}, ui.fmt("Row {d}", .{index.*})); +} + +fn retryFontView(ui: *RetryFontApp.Ui, model: *const RetryFontModel) RetryFontApp.Ui.Node { + // The measured text rides in a row (intrinsic width, like + // lateFontAppView above) so its frame moves when the registered + // face's advances replace the estimator's. + return ui.column(.{ .gap = 2 }, .{ + ui.row(.{ .gap = 8, .padding = 12 }, .{ ui.text(.{}, late_mixed_text) }), + ui.each(model.rows(ui.arena), retryRowKey, retryRow), + }); +} + +test "a failed late-font rebuild leaves the count unadopted so the next healthy frame retries" { + // The failing rebuild teaches through std.log; without lowering the + // level the warning would fail the build runner's stderr check. + const saved_log_level = std.testing.log_level; + std.testing.log_level = .err; + defer std.testing.log_level = saved_log_level; + + const harness = try TestHarness().create(std.testing.allocator, .{ .size = geometry.SizeF.init(240, 200) }); + defer harness.destroy(std.testing.allocator); + harness.null_platform.gpu_surfaces = true; + + const app_state = try std.testing.allocator.create(RetryFontApp); + defer std.testing.allocator.destroy(app_state); + app_state.* = RetryFontApp.init(std.testing.allocator, .{}, .{ + .name = "ui-app-font-retry", + .scene = font_app_scene, + .canvas_label = font_app_canvas_label, + .tokens_fn = retryFontTokens, + .update = retryFontUpdate, + .view = retryFontView, + }); + defer app_state.deinit(); + const app = app_state.app(); + try harness.start(app); + + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{ + .label = font_app_canvas_label, + .size = geometry.SizeF.init(240, 200), + .scale_factor = 1, + .frame_index = 1, + .timestamp_ns = 1_000_000, + .nonblank = true, + } }); + try std.testing.expect(app_state.installed); + const width_before = try lateTextFrameWidth(&harness.runtime, 1, font_app_canvas_label); + + // Production policy: dispatch degrades errors instead of dying — + // the policy under which a rebuild that adopted the font count + // BEFORE succeeding would strand stale layouts marked font-current, + // never retried. + harness.runtime.dispatch_error_policy = .degrade; + + // Grow the model past the per-view widget budget (mutated directly + // — no dispatch, so no rebuild yet), then register a face late so + // the next frame's late-registration rebuild is the failing one. + app_state.model.row_count = core.max_canvas_widget_nodes_per_view + 40; + try harness.runtime.registerCanvasFont(registered_font_id, cjk_receipt_bytes); + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{ + .label = font_app_canvas_label, + .size = geometry.SizeF.init(240, 200), + .scale_factor = 1, + .frame_index = 2, + .timestamp_ns = 2_000_000, + .nonblank = true, + } }); + try std.testing.expectEqual(@as(usize, 1), harness.runtime.dispatchErrors().len); + // The failed rebuild must NOT adopt the count: the mismatch it + // leaves behind is exactly what the next presented frame reads as + // its retry trigger. + try std.testing.expect(app_state.fonts_built_count != harness.runtime.registeredCanvasFontCount()); + + // Heal the model; the next presented frame observes the mismatch, + // rebuilds every surface, and only then adopts. + app_state.model.row_count = 0; + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{ + .label = font_app_canvas_label, + .size = geometry.SizeF.init(240, 200), + .scale_factor = 1, + .frame_index = 3, + .timestamp_ns = 3_000_000, + .nonblank = true, + } }); + try std.testing.expectEqual(@as(usize, 1), harness.runtime.dispatchErrors().len); + try std.testing.expectEqual(harness.runtime.registeredCanvasFontCount(), app_state.fonts_built_count); + + // The retried rebuild re-measured with the registered face, not + // merely repainted: the face answers the mixed string wider than + // the estimator did (1.0 em notdef per ASCII codepoint), so the + // text widget's frame grew. + const width_after = try lateTextFrameWidth(&harness.runtime, 1, font_app_canvas_label); + try std.testing.expect(width_after > width_before); +} + test "ui app fonts option surfaces the glyph-budget refusal as a teaching error" { const harness = try TestHarness().create(std.testing.allocator, .{ .size = geometry.SizeF.init(240, 200) }); defer harness.destroy(std.testing.allocator); diff --git a/src/runtime/ui_app.zig b/src/runtime/ui_app.zig index 4a5f7c86a..7ef2779ae 100644 --- a/src/runtime/ui_app.zig +++ b/src/runtime/ui_app.zig @@ -3086,8 +3086,15 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe /// requested draws re-measured geometry, not re-inked stale /// frames. fn rebuildForRegisteredFonts(self: *Self, runtime: *Runtime) anyerror!void { - self.fonts_built_count = runtime.registeredCanvasFontCount(); try self.rebuildAllViews(runtime); + // Adopt the count only AFTER the rebuild succeeded: + // production dispatch degrades errors, so a failed rebuild + // (widget budget, allocator pressure, a secondary window's + // emit) that had already adopted the count would mark stale + // layouts as font-current and never retry. Left unadopted, + // the error leaves the count mismatched and the next + // presented frame retries the rebuild. + self.fonts_built_count = runtime.registeredCanvasFontCount(); } /// Present the planned canvas frame: GPU packet when the platform From 34757202d8b317b2853b1bcba21d298a0e4aef11 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 18 Jul 2026 19:24:46 -0500 Subject: [PATCH 14/21] Scope the matrix footnote's text-parity claim to the reference path - Footnote 3 opened with "identical code on every platform" while its own next sentence described macOS resolving packet text through CoreText; the opening now names the SDK's TrueType pipeline as the shared reference path (goldens, screenshots, software presents), matching the distinction the fonts page draws. - Every other fact in the footnote is unchanged. --- docs/src/app/platform-support/page.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/app/platform-support/page.mdx b/docs/src/app/platform-support/page.mdx index a2324d863..989dece02 100644 --- a/docs/src/app/platform-support/page.mdx +++ b/docs/src/app/platform-support/page.mdx @@ -115,7 +115,7 @@ macOS, Linux, and Windows run full desktop apps through their own platform hosts 1. iOS and Android canvas apps run full screen inside toolkit-owned hosts, which the SDK builds ON the [embed C ABI](/embed) — `native dev` and `native package` with `--target ios|android` generate everything from app.zon, so the app project carries zero host code. Multi-window scenes are desktop-only. Embedding the runtime in a host app you control, driven over the same embed C ABI, works on both platforms and shares the mobile experimental status. 2. macOS presents through Metal and hands scrolling to OS scroll drivers. Linux and Windows present the deterministic software renderer through the platform blit path and report `backend=software`; a manifest that requests another backend falls back to software there instead of erroring. The iOS toolkit host reads the software renderer's pixels over the ABI and presents them through Metal with the macOS host's presentation discipline — presents are gated on the canvas revision (an idle app acquires no drawables and uploads nothing), the drawable is acquired only after the frame's CPU work is done, and the pump pauses in the background and re-presents the retained canvas on return; the Android toolkit host copies the same pixels into its surface's window buffer (mobile GPU rendering is a later phase); embed hosts present the pixels in their own surfaces. -3. Text rendering is the SDK's own TrueType pipeline — parsing, outlines, rasterization — identical code on every platform, and apps can [register additional faces](/fonts) (a CJK face is the canonical case) that both renderers resolve exactly like the bundled ones. macOS additionally hands registered bytes to the host at registration, so CoreText measurement and Metal packet text resolve the same face the reference renderer inks. On Windows and Linux the software renderer inks registered outlines directly, and the test suite pins pixel parity between the present path and the reference path for a registered face; the full font-registry suite runs in CI on Linux, and CI additionally runs it natively on a Windows runner, including a receipt test that registers a committed subsetted CJK face and proves Chinese text renders as real glyphs, not tofu. Mobile hosts present the same reference-rendered pixels, but no mobile test registers a font today and the mobile hosts' text measurement has no registered-font seam, so registered fonts there are stated as unverified rather than supported. +3. The SDK's own TrueType pipeline — parsing, outlines, rasterization — is text rendering's shared reference path on every platform (goldens, screenshots, and software presents all ink through it), and apps can [register additional faces](/fonts) (a CJK face is the canonical case) that both renderers resolve exactly like the bundled ones. macOS additionally hands registered bytes to the host at registration, so CoreText measurement and Metal packet text presentation resolve the same face the reference renderer inks. On Windows and Linux the software renderer inks registered outlines directly, and the test suite pins pixel parity between the present path and the reference path for a registered face; the full font-registry suite runs in CI on Linux, and CI additionally runs it natively on a Windows runner, including a receipt test that registers a committed subsetted CJK face and proves Chinese text renders as real glyphs, not tofu. Mobile hosts present the same reference-rendered pixels, but no mobile test registers a font today and the mobile hosts' text measurement has no registered-font seam, so registered fonts there are stated as unverified rather than supported. 4. Windows maps native IME composition onto the shared IME events, with real-hardware IME verification still pending. The iOS toolkit host forwards touch, the system keyboard, and IME composition, verified with real injected input on the simulator; the Android toolkit host forwards touch, shows and hides the soft keyboard from the runtime's focus state, and routes committed text and IME composition through the same embed IME events, verified with injected input on the emulator. 5. App menus are native on all three desktops. Hosts without a native context-menu presenter — Linux and Windows today — present the same declared context menu as an anchored canvas surface; authors declare one menu either way. 6. Tray support is implemented on macOS (`NSStatusItem`) and Windows; Linux tray calls return `UnsupportedService` until a portable status-notifier implementation is selected. From 47be31dc1b1fbefd9be59123ec1fca42cb0de8dd Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 18 Jul 2026 19:58:25 -0500 Subject: [PATCH 15/21] Key AppKit width-cache invalidation on a process-global font token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace the per-id registration generations with one monotonic process-global token counter: registration stamps the id with a fresh never-repeated token and the measured-width NSCache key carries it, so no registration can ever reach a previous life's cached widths. - Because tokens never repeat, unregister now deletes the id's token record outright instead of retaining a bumped entry per retired id — descriptor, size-cache entries, and token record all drop, leaving zero host state per retired id, which is what the fonts page's runtime-cycle sentence promises. - Align the file-contains pins with the token names and additionally pin the token-record removal inside native_sdk_appkit_unregister_font so the zero-retained-state property cannot silently regress. --- build.zig | 34 ++++++------ src/platform/macos/appkit_host.m | 88 +++++++++++++++++++------------- 2 files changed, 72 insertions(+), 50 deletions(-) diff --git a/build.zig b/build.zig index f085fb55f..25db6569d 100644 --- a/build.zig +++ b/build.zig @@ -809,7 +809,7 @@ pub fn build(b: *std.Build) void { .{ .path = "src/platform/macos/appkit_host.m", .pattern = "NativeSdkItalicSansFont(base)" }, .{ .path = "src/platform/macos/appkit_host.m", .pattern = "NativeSdkItalicSansFont(NativeSdkWeightedSansFont(@[ @\"Geist-Bold\", @\"Geist Bold\" ], base, NSFontWeightBold, YES, size))" }, }); - addFileContainsCheckStep(b, file_contains_checker, test_step, "test-appkit-registered-font-cache-eviction", "Verify the AppKit host invalidates BOTH per-process registered-font caches at registration (the caches are per-process while font-id permanence is per-runtime, so a new runtime re-registering an id must never resolve the previous runtime's face or its measured widths): the NSFont size cache by prefix eviction and the measured-width NSCache by a per-id generation in its key", &.{ + addFileContainsCheckStep(b, file_contains_checker, test_step, "test-appkit-registered-font-cache-eviction", "Verify the AppKit host invalidates BOTH per-process registered-font caches at registration (the caches are per-process while font-id permanence is per-runtime, so a new runtime re-registering an id must never resolve the previous runtime's face or its measured widths): the NSFont size cache by prefix eviction and the measured-width NSCache by a process-global registration token in its key", &.{ // No SDK test tier links appkit_host.m (only managed app builds // compile it), so the eviction wiring is pinned textually like // the other AppKit host contracts: the shared accessor both the @@ -820,23 +820,27 @@ pub fn build(b: *std.Build) void { .{ .path = "src/platform/macos/appkit_host.m", .pattern = "if ([cachedKey hasPrefix:stalePrefix]) [sizeCache removeObjectForKey:cachedKey];" }, .{ .path = "src/platform/macos/appkit_host.m", .pattern = "an id is only permanent within" }, // The width-cache half: NSCache cannot enumerate keys, so the - // measured-width cache is invalidated by a per-id generation - // that registration bumps and the cache key includes — the - // generation table, the bump inside register_font, and the - // generation-carrying key inside measure_text. - .{ .path = "src/platform/macos/appkit_host.m", .pattern = "static NSMutableDictionary *NativeSdkRegisteredFontGenerations(void)" }, - .{ .path = "src/platform/macos/appkit_host.m", .pattern = "generations[@(font_id)] = @((previous ? previous.unsignedLongLongValue : 0) + 1);" }, - .{ .path = "src/platform/macos/appkit_host.m", .pattern = "unsigned long long generation = NativeSdkRegisteredFontGeneration((unsigned long long)font_id);" }, - .{ .path = "src/platform/macos/appkit_host.m", .pattern = "[NSString stringWithFormat:@\"%llu/%llu/%.3f/%@\", (unsigned long long)font_id, generation, (double)clamped, value]" }, + // measured-width cache is invalidated by a registration token + // drawn from one process-global monotonic counter (tokens never + // repeat, which is what lets unregister DELETE an id's record + // instead of retaining a bumped one per retired id) — the token + // table, the fresh stamp inside register_font, and the + // token-carrying key inside measure_text. + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "static NSMutableDictionary *NativeSdkRegisteredFontTokens(void)" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "NativeSdkRegisteredFontTokens()[@(font_id)] = @(++NativeSdkRegisteredFontTokenCounter);" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "unsigned long long token = NativeSdkRegisteredFontToken((unsigned long long)font_id);" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "[NSString stringWithFormat:@\"%llu/%llu/%.3f/%@\", (unsigned long long)font_id, token, (double)clamped, value]" }, // The teardown half: Runtime.deinit returns the host-side // registration through the platform unregister seam, and the - // ObjC removal runs the SAME eviction machinery before dropping - // the descriptor entry. Pinned like the registration half - // (appkit_host.m has no SDK test tier); the deinit call site is - // pinned too so the seam can never silently lose its one caller, - // and the embed cycle test asserts that call behaviorally - // against the null platform's recorder. + // ObjC removal drops the descriptor, the size-cache entries, AND + // the token record — zero retained state per retired id. Pinned + // like the registration half (appkit_host.m has no SDK test + // tier); the deinit call site is pinned too so the seam can + // never silently lose its one caller, and the embed cycle test + // asserts that call behaviorally against the null platform's + // recorder. .{ .path = "src/platform/macos/appkit_host.m", .pattern = "int native_sdk_appkit_unregister_font(uint64_t font_id) {" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "[NativeSdkRegisteredFontTokens() removeObjectForKey:@(font_id)];" }, .{ .path = "src/platform/macos/appkit_host.m", .pattern = "[table removeObjectForKey:@(font_id)];" }, .{ .path = "src/runtime/core.zig", .pattern = "self.options.platform.services.unregisterGpuSurfaceFont(entry.id) catch {};" }, }); diff --git a/src/platform/macos/appkit_host.m b/src/platform/macos/appkit_host.m index 4cc9895c5..bd151f47c 100644 --- a/src/platform/macos/appkit_host.m +++ b/src/platform/macos/appkit_host.m @@ -1774,8 +1774,8 @@ static NSTextAlignment NativeSdkPacketTextAlignment(NSString *align) { // before installing the new descriptor. The measured-width NSCache in // `native_sdk_appkit_measure_text` goes stale at the same moment but // cannot be prefix-evicted (NSCache does not enumerate keys), so it is -// invalidated by generation instead — see -// NativeSdkRegisteredFontGenerations below. Accessed only under the +// invalidated by registration token instead — see +// NativeSdkRegisteredFontTokens below. Accessed only under the // descriptor table's @synchronized guard, like the descriptors. static NSMutableDictionary *NativeSdkRegisteredFontSizeCache(void) { static NSMutableDictionary *cache = nil; @@ -1786,17 +1786,26 @@ static NSTextAlignment NativeSdkPacketTextAlignment(NSString *align) { return cache; } -// Per-id registration generations, the invalidation handle for the +// Per-id registration tokens, the invalidation handle for the // measured-width NSCache in `native_sdk_appkit_measure_text`. That // cache has the same lifetime hazard as the size cache above (per- // process cache, per-runtime id permanence) but no way to evict an // id's entries — NSCache cannot enumerate keys — so every registration -// bumps the id's generation here and the width-cache key includes it: -// a previous runtime's widths become unreachable and age out under the -// cache's own count limit. Ids never registered (the built-in faces) -// stay at generation 0 forever. Accessed only under the descriptor -// table's @synchronized guard, like the descriptors. -static NSMutableDictionary *NativeSdkRegisteredFontGenerations(void) { +// stamps its id with the next value of ONE process-global monotonic +// counter and the width-cache key includes the stamp. Tokens never +// repeat, so no registration can ever reach widths a previous life of +// its id cached (those entries become unreachable and age out under +// the cache's own count limit) — which is exactly what makes an id's +// record REMOVABLE at unregister, retaining zero state per retired id. +// A per-id bump counter could not offer that: deleting a per-id count +// would let a future registration of the id restart at 1 and collide +// with the retired life's still-cached widths, so retired ids would +// each pin a record for the process lifetime. Ids never registered +// (the built-in faces) have no record and measure under token 0 — the +// same value an unregistered id returns to, honest because both states +// resolve through the same built-in candidates. Accessed only under +// the descriptor table's @synchronized guard, like the descriptors. +static NSMutableDictionary *NativeSdkRegisteredFontTokens(void) { static NSMutableDictionary *table = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ @@ -1805,13 +1814,19 @@ static NSTextAlignment NativeSdkPacketTextAlignment(NSString *align) { return table; } -// The current registration generation for a font id: 0 until the id -// first registers, bumped by every (re)registration. -static unsigned long long NativeSdkRegisteredFontGeneration(unsigned long long value) { +// The process-global registration token counter: pre-incremented at +// every registration, so 0 is never a live token and stays the honest +// "no registered face" value. Mutated only under the descriptor +// table's @synchronized guard, like every table here. +static unsigned long long NativeSdkRegisteredFontTokenCounter = 0; + +// The current registration token for a font id: 0 unless the id holds +// a live registration. +static unsigned long long NativeSdkRegisteredFontToken(unsigned long long value) { NSMutableDictionary *table = NativeSdkRegisteredFontDescriptors(); @synchronized (table) { - NSNumber *generation = NativeSdkRegisteredFontGenerations()[@(value)]; - return generation ? generation.unsignedLongLongValue : 0; + NSNumber *token = NativeSdkRegisteredFontTokens()[@(value)]; + return token ? token.unsignedLongLongValue : 0; } } @@ -1865,12 +1880,11 @@ int native_sdk_appkit_register_font(uint64_t font_id, const uint8_t *bytes, size if ([cachedKey hasPrefix:stalePrefix]) [sizeCache removeObjectForKey:cachedKey]; } // The measured-width cache holds the same stale state but - // cannot be enumerated for eviction, so bump the id's - // generation instead: width-cache keys include it, and the - // old generation's entries become unreachable. - NSMutableDictionary *generations = NativeSdkRegisteredFontGenerations(); - NSNumber *previous = generations[@(font_id)]; - generations[@(font_id)] = @((previous ? previous.unsignedLongLongValue : 0) + 1); + // cannot be enumerated for eviction, so stamp the id with a + // fresh process-global token instead: width-cache keys + // include the token, tokens never repeat, and everything + // cached under any previous stamp becomes unreachable. + NativeSdkRegisteredFontTokens()[@(font_id)] = @(++NativeSdkRegisteredFontTokenCounter); table[@(font_id)] = (__bridge_transfer id)descriptor; } return 1; @@ -1881,12 +1895,18 @@ int native_sdk_appkit_register_font(uint64_t font_id, const uint8_t *bytes, size // each id the dying runtime registered: font ids are per-runtime but // every table here is per-process, so without this removal an embedder // cycling runtimes with fresh ids grows the descriptor table (and its -// caches) for the process lifetime. Removal runs the SAME eviction the -// re-registration path runs — prefix-evict the id's cached NSFonts and -// bump its generation so measured widths keyed under the old generation -// become unreachable — then drops the descriptor itself. An id with no -// installed descriptor (never registered host-side, or already returned) -// is a no-op accept. +// caches) for the process lifetime. Removal drops EVERYTHING the +// registration installed: the id's cached NSFonts (the same prefix +// eviction re-registration runs), the id's registration token record, +// and the descriptor itself — zero retained state per retired id, so +// runtime cycles truly do not accumulate host entries. Deleting the +// token record (rather than bumping it) is safe precisely because +// tokens come from one process-global monotonic counter: a future +// registration of this id takes a fresh, never-repeated token, so the +// widths this life cached in the measured-width NSCache stay +// unreachable without any per-id record surviving to say so. An id +// with no installed descriptor (never registered host-side, or already +// returned) is a no-op accept. int native_sdk_appkit_unregister_font(uint64_t font_id) { if (font_id == 0) return 0; @autoreleasepool { @@ -1899,9 +1919,7 @@ int native_sdk_appkit_unregister_font(uint64_t font_id) { for (NSString *cachedKey in cachedKeys) { if ([cachedKey hasPrefix:stalePrefix]) [sizeCache removeObjectForKey:cachedKey]; } - NSMutableDictionary *generations = NativeSdkRegisteredFontGenerations(); - NSNumber *previous = generations[@(font_id)]; - generations[@(font_id)] = @((previous ? previous.unsignedLongLongValue : 0) + 1); + [NativeSdkRegisteredFontTokens() removeObjectForKey:@(font_id)]; [table removeObjectForKey:@(font_id)]; } return 1; @@ -1988,13 +2006,13 @@ int native_sdk_appkit_unregister_font(uint64_t font_id) { widthCache = [[NSCache alloc] init]; widthCache.countLimit = 16384; }); - // The key carries the id's registration generation so a face + // The key carries the id's registration token so a face // re-registered under the same id (a new runtime in this - // process) never serves the previous face's widths — see - // NativeSdkRegisteredFontGenerations for why generation, not - // eviction, invalidates this cache. - unsigned long long generation = NativeSdkRegisteredFontGeneration((unsigned long long)font_id); - NSString *key = [NSString stringWithFormat:@"%llu/%llu/%.3f/%@", (unsigned long long)font_id, generation, (double)clamped, value]; + // process) never serves a previous face's widths — see + // NativeSdkRegisteredFontTokens for why token, not eviction, + // invalidates this cache. + unsigned long long token = NativeSdkRegisteredFontToken((unsigned long long)font_id); + NSString *key = [NSString stringWithFormat:@"%llu/%llu/%.3f/%@", (unsigned long long)font_id, token, (double)clamped, value]; NSNumber *cached = [widthCache objectForKey:key]; if (cached) return cached.doubleValue; NSFont *font = NativeSdkFontForFontId(font_id, clamped); From b2e9f51ea9dcc2dd0bbc59ea43e8e989057261b7 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 18 Jul 2026 20:19:02 -0500 Subject: [PATCH 16/21] Return host font registrations through the owner captured at registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Each CanvasFontEntry now captures the platform's unregister fn and context when the registration is pushed to the host, and Runtime.deinit returns the registration through that captured pair — never live options.platform, which is publicly mutable and may name a different host (or a null seam) by teardown time, the owned_allocator identity-freeze doctrine applied to the host seam. - Pin the capture and the deinit call site in the file-contains step, and add an embed test that registers through platform A, swaps options.platform to platform B, and asserts the deinit unregister lands on A (count 1) and never on B (count 0); reverting to the live-options read fails both the test and the pin. - One services read in registerCanvasFont now serves both the host sync and the captured return path, so the host that hears the registration is exactly the owner the entry names. --- build.zig | 21 ++++++++++--------- src/embed/tests.zig | 33 ++++++++++++++++++++++++++++++ src/runtime/canvas_fonts.zig | 39 ++++++++++++++++++++++++++++++++---- src/runtime/core.zig | 24 +++++++++++++++------- 4 files changed, 97 insertions(+), 20 deletions(-) diff --git a/build.zig b/build.zig index 25db6569d..b6ea480df 100644 --- a/build.zig +++ b/build.zig @@ -831,18 +831,21 @@ pub fn build(b: *std.Build) void { .{ .path = "src/platform/macos/appkit_host.m", .pattern = "unsigned long long token = NativeSdkRegisteredFontToken((unsigned long long)font_id);" }, .{ .path = "src/platform/macos/appkit_host.m", .pattern = "[NSString stringWithFormat:@\"%llu/%llu/%.3f/%@\", (unsigned long long)font_id, token, (double)clamped, value]" }, // The teardown half: Runtime.deinit returns the host-side - // registration through the platform unregister seam, and the - // ObjC removal drops the descriptor, the size-cache entries, AND - // the token record — zero retained state per retired id. Pinned - // like the registration half (appkit_host.m has no SDK test - // tier); the deinit call site is pinned too so the seam can - // never silently lose its one caller, and the embed cycle test - // asserts that call behaviorally against the null platform's - // recorder. + // registration through the unregister owner each font entry + // captured at registration time (never live `options.platform`, + // which is publicly mutable), and the ObjC removal drops the + // descriptor, the size-cache entries, AND the token record — + // zero retained state per retired id. Pinned like the + // registration half (appkit_host.m has no SDK test tier); the + // capture and the deinit call site are pinned too so the seam + // can never silently lose its one caller or regress to the live + // read, and the embed cycle and platform-swap tests assert both + // behaviorally against null platform recorders. .{ .path = "src/platform/macos/appkit_host.m", .pattern = "int native_sdk_appkit_unregister_font(uint64_t font_id) {" }, .{ .path = "src/platform/macos/appkit_host.m", .pattern = "[NativeSdkRegisteredFontTokens() removeObjectForKey:@(font_id)];" }, .{ .path = "src/platform/macos/appkit_host.m", .pattern = "[table removeObjectForKey:@(font_id)];" }, - .{ .path = "src/runtime/core.zig", .pattern = "self.options.platform.services.unregisterGpuSurfaceFont(entry.id) catch {};" }, + .{ .path = "src/runtime/canvas_fonts.zig", .pattern = ".host_unregister_fn = services.unregister_gpu_surface_font_fn," }, + .{ .path = "src/runtime/core.zig", .pattern = "host_unregister_fn(entry.host_unregister_context, entry.id) catch {};" }, }); addFileContainsCheckStep(b, file_contains_checker, test_step, "test-appkit-gpu-widget-cursor-bridge", "Verify AppKit GPU widgets apply retained cursor intent", &.{ .{ .path = "src/platform/macos/appkit_host.m", .pattern = "native_sdk_appkit_set_view_cursor" }, diff --git a/src/embed/tests.zig b/src/embed/tests.zig index 561690e3c..b554dbc31 100644 --- a/src/embed/tests.zig +++ b/src/embed/tests.zig @@ -147,6 +147,39 @@ test "embedded app deinit is idempotent" { try std.testing.expectEqual(@as(usize, 1), null_platform.gpu_surface_font_unregister_count); } +test "embedded app deinit returns the font registration to the platform that received it" { + // `Runtime.options` is public and mutable, and embedders genuinely + // swap the platform on a live runtime (tests in this repo do it + // constantly). The host-side registration must still come back to + // the host that RECEIVED it: each font entry captures its + // unregistration owner at registration time (the allocator-identity + // freeze, applied to the host seam), so a swap between registration + // and teardown retargets nothing. A deinit reading the live options + // instead would unregister against the swapped-in platform — the + // wrong host, or a null seam — and strand the original host's + // descriptor and caches for the process lifetime. + var platform_a = platform.NullPlatform.init(.{}); + var platform_b = platform.NullPlatform.init(.{}); + var state: u8 = 0; + const embedded = try std.testing.allocator.create(EmbeddedApp); + defer std.testing.allocator.destroy(embedded); + embedded.initInPlace(.{ + .context = &state, + .name = "embedded-font-platform-swap", + .source = platform.WebViewSource.html("

Fonts

"), + }, platform_a.platform()); + embedded.runtime.owned_allocator = std.testing.allocator; + try embedded.runtime.registerCanvasFont(canvas.min_registered_font_id, canvas.font_ttf.geist_mono_bytes); + + // The swap under test: platform B holds the option by teardown time, + // but it never saw the registration. + embedded.runtime.options.platform = platform_b.platform(); + embedded.deinit(); + try std.testing.expectEqual(@as(usize, 1), platform_a.gpu_surface_font_unregister_count); + try std.testing.expectEqual(@as(u64, canvas.min_registered_font_id), platform_a.gpu_surface_font_unregister_id); + try std.testing.expectEqual(@as(usize, 0), platform_b.gpu_surface_font_unregister_count); +} + test "mobile C ABI destroy returns registered font bytes through the embedded deinit" { const app = native_sdk_app_create() orelse return error.TestUnexpectedResult; errdefer native_sdk_app_destroy(app); diff --git a/src/runtime/canvas_fonts.zig b/src/runtime/canvas_fonts.zig index 8d262d12e..1139eaf2f 100644 --- a/src/runtime/canvas_fonts.zig +++ b/src/runtime/canvas_fonts.zig @@ -75,6 +75,22 @@ pub const max_registered_canvas_font_bytes = canvas_limits.max_registered_canvas pub const CanvasFontEntry = struct { id: canvas.FontId = 0, bytes: []const u8 = &.{}, + /// The host-side unregistration owner — the platform's + /// `unregister_gpu_surface_font_fn` and its context, captured from + /// the services in effect when THIS registration was pushed to the + /// host. Captured precisely because `Runtime.options` is public and + /// mutable while registration and teardown can be a whole runtime + /// lifetime apart: the registration must be returned to the host + /// that received it, so `Runtime.deinit` unregisters through this + /// captured pair and mutating `options.platform` on a live runtime + /// retargets nothing (the `owned_allocator` identity-freeze + /// doctrine, applied to the host seam — a deinit that read the live + /// options would unregister against whatever platform the option + /// points at by teardown time, stranding the original host's + /// descriptor and caches). Null when the platform at registration + /// time had no unregister seam: it retained nothing to return. + host_unregister_fn: ?*const fn (context: ?*anyopaque, id: u64) anyerror!void = null, + host_unregister_context: ?*anyopaque = null, }; /// Placeholder measure fn for the runtime's font-aware provider field @@ -151,16 +167,31 @@ pub fn RuntimeCanvasFonts(comptime Runtime: type) type { // exact silent fallback this seam forbids. Platforms without // host-side text may lack the seam (`UnsupportedService`): // the engine measures with the parsed face and inks it - // through the reference renderer, so nothing is lost. - self.options.platform.services.registerGpuSurfaceFont(.{ .id = id, .ttf = pooled }) catch |err| switch (err) { + // through the reference renderer, so nothing is lost. ONE + // services read serves both the sync and the captured return + // path below, so the host that hears the registration is the + // host the entry's unregister owner names. + const services = self.options.platform.services; + services.registerGpuSurfaceFont(.{ .id = id, .ttf = pooled }) catch |err| switch (err) { error.UnsupportedService => { - if (self.options.platform.services.measure_text_fn != null) return error.FontHostRegistrationUnsupported; + if (services.measure_text_fn != null) return error.FontHostRegistrationUnsupported; }, else => return err, }; self.canvas_font_faces[index] = face; - self.canvas_font_entries[index] = .{ .id = id, .bytes = pooled }; + // The entry carries its own host unregistration owner, + // captured now (see `CanvasFontEntry.host_unregister_fn`): + // `options.platform` is publicly mutable, and the teardown + // return must land on the host that received this + // registration, never on whatever platform the option holds + // by `Runtime.deinit` time. + self.canvas_font_entries[index] = .{ + .id = id, + .bytes = pooled, + .host_unregister_fn = services.unregister_gpu_surface_font_fn, + .host_unregister_context = services.context, + }; self.canvas_font_count = index + 1; // Bind the font-aware measure provider on first registration. // The runtime address is stable from here on (registration diff --git a/src/runtime/core.zig b/src/runtime/core.zig index 9d60d1a8e..4550b1d2f 100644 --- a/src/runtime/core.zig +++ b/src/runtime/core.zig @@ -462,9 +462,11 @@ pub const Runtime = struct { /// canvas font bytes, registered canvas image slot buffers, and /// adopted media-surface texture buffers, allocated from the /// init-frozen `owned_allocator`), return each registered font's - /// host-side registration (the platform `unregisterGpuSurfaceFont` - /// seam — host font state is per-process, this runtime's ids are - /// not), and disarm the media-surface wake bindings (a producer + /// host-side registration (through the unregister owner captured + /// into the entry at registration time — host font state is + /// per-process, this runtime's ids are not, and live `options` may + /// no longer name the host that received the registration), and + /// disarm the media-surface wake bindings (a producer /// thread must never wake a dead host). Ends the runtime's life: /// parsed faces, atlas keys, /// measure providers, adopted textures, and the resource slices @@ -484,10 +486,18 @@ pub const Runtime = struct { // that host state is per-process), so a deinit that freed // only the Zig-side bytes would leave an embedder cycling // runtimes growing host font state for the process lifetime. - // Best-effort by design — deinit cannot fail, and platforms - // without the seam answer `UnsupportedService` because they - // retained nothing to return. - self.options.platform.services.unregisterGpuSurfaceFont(entry.id) catch {}; + // Through the entry's owner captured at registration time, + // never live `options`: the option is publicly mutable, and + // an embedder that swapped the platform after registering + // must see this land on the host that holds the + // registration, not on the current platform or a null seam + // (the `owned_allocator` identity discipline two lines + // down, applied to the host). Best-effort by design — + // deinit cannot fail, and a null owner means the platform + // at registration time retained nothing to return. + if (entry.host_unregister_fn) |host_unregister_fn| { + host_unregister_fn(entry.host_unregister_context, entry.id) catch {}; + } // Through the frozen identity, never `options.allocator`: // an embedder may have swapped the public option since these // bytes were made, and a free must hit the allocator that From f182b3f1d3337c03a4f075c6e26e12389a0962b7 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 18 Jul 2026 21:39:29 -0500 Subject: [PATCH 17/21] Guard host font unregistration with a per-registration ownership token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - registerGpuSurfaceFont now returns the host's ownership token for the registration (0 from stateless hosts); the runtime stores it in the font entry beside the captured owner and Runtime.deinit presents it at unregister, so the AppKit host removes an id's descriptor and caches only while the id's current registration still carries that token — an older runtime's deinit can no longer tear down a newer runtime's live face under a shared id, and a stale token is a no-op accept. - The null platform gains an opt-in host-font mirror (id-keyed, last-wins, monotonic tokens — the AppKit table shape without CoreText) plus a token field on its unregister recorder; a new embed test registers one id through two runtimes and proves the newer registration survives the older runtime's deinit. - The AppKit pin step now pins the two-argument unregister signature, the token-match guard line, the reported out-token, and the three-argument deinit call, so reverting the host to id-keyed removal fails the pins. --- build.zig | 22 +++++- src/embed/tests.zig | 71 +++++++++++++++++++ src/platform/macos/appkit_host.h | 22 ++++-- src/platform/macos/appkit_host.m | 36 ++++++++-- src/platform/macos/cef_host.mm | 14 ++-- src/platform/macos/root.zig | 30 +++++--- src/platform/null_platform.zig | 115 ++++++++++++++++++++++++++++--- src/platform/types.zig | 46 ++++++++++--- src/runtime/canvas_fonts.zig | 25 ++++++- src/runtime/core.zig | 9 ++- 10 files changed, 336 insertions(+), 54 deletions(-) diff --git a/build.zig b/build.zig index b6ea480df..e07b0fa60 100644 --- a/build.zig +++ b/build.zig @@ -827,7 +827,16 @@ pub fn build(b: *std.Build) void { // table, the fresh stamp inside register_font, and the // token-carrying key inside measure_text. .{ .path = "src/platform/macos/appkit_host.m", .pattern = "static NSMutableDictionary *NativeSdkRegisteredFontTokens(void)" }, - .{ .path = "src/platform/macos/appkit_host.m", .pattern = "NativeSdkRegisteredFontTokens()[@(font_id)] = @(++NativeSdkRegisteredFontTokenCounter);" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "unsigned long long token = ++NativeSdkRegisteredFontTokenCounter;" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "NativeSdkRegisteredFontTokens()[@(font_id)] = @(token);" }, + // The stamp is also the registration's OWNERSHIP token: register + // reports it to the caller (`*out_token`), the runtime stores it + // beside the captured unregister owner, and unregister removes + // the id's state only while the id's current registration still + // carries it — an older runtime's deinit must never tear down a + // newer runtime's live face under a shared id (ids are + // per-runtime, host font state is per-process, last wins). + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "*out_token = token;" }, .{ .path = "src/platform/macos/appkit_host.m", .pattern = "unsigned long long token = NativeSdkRegisteredFontToken((unsigned long long)font_id);" }, .{ .path = "src/platform/macos/appkit_host.m", .pattern = "[NSString stringWithFormat:@\"%llu/%llu/%.3f/%@\", (unsigned long long)font_id, token, (double)clamped, value]" }, // The teardown half: Runtime.deinit returns the host-side @@ -841,11 +850,18 @@ pub fn build(b: *std.Build) void { // can never silently lose its one caller or regress to the live // read, and the embed cycle and platform-swap tests assert both // behaviorally against null platform recorders. - .{ .path = "src/platform/macos/appkit_host.m", .pattern = "int native_sdk_appkit_unregister_font(uint64_t font_id) {" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "int native_sdk_appkit_unregister_font(uint64_t font_id, uint64_t token) {" }, + // The token-match guard itself: reverting removal to id-keyed + // (deleting whatever the id currently holds, whoever registered + // it) must fail here — the embed suite's survives-teardown test + // pins the same guard behaviorally against the null platform's + // host-font mirror. + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "if (!current || current.unsignedLongLongValue != token) return 1;" }, .{ .path = "src/platform/macos/appkit_host.m", .pattern = "[NativeSdkRegisteredFontTokens() removeObjectForKey:@(font_id)];" }, .{ .path = "src/platform/macos/appkit_host.m", .pattern = "[table removeObjectForKey:@(font_id)];" }, .{ .path = "src/runtime/canvas_fonts.zig", .pattern = ".host_unregister_fn = services.unregister_gpu_surface_font_fn," }, - .{ .path = "src/runtime/core.zig", .pattern = "host_unregister_fn(entry.host_unregister_context, entry.id) catch {};" }, + .{ .path = "src/runtime/canvas_fonts.zig", .pattern = ".host_registration_token = host_token," }, + .{ .path = "src/runtime/core.zig", .pattern = "host_unregister_fn(entry.host_unregister_context, entry.id, entry.host_registration_token) catch {};" }, }); addFileContainsCheckStep(b, file_contains_checker, test_step, "test-appkit-gpu-widget-cursor-bridge", "Verify AppKit GPU widgets apply retained cursor intent", &.{ .{ .path = "src/platform/macos/appkit_host.m", .pattern = "native_sdk_appkit_set_view_cursor" }, diff --git a/src/embed/tests.zig b/src/embed/tests.zig index b554dbc31..b5f25a497 100644 --- a/src/embed/tests.zig +++ b/src/embed/tests.zig @@ -120,9 +120,80 @@ test "embedded app deinit returns registered font bytes across create-destroy cy embedded.deinit(); try std.testing.expectEqual(@as(usize, 1), null_platform.gpu_surface_font_unregister_count); try std.testing.expectEqual(@as(u64, canvas.min_registered_font_id), null_platform.gpu_surface_font_unregister_id); + // The null platform's register seam is off by default, so the + // runtime captured ownership token 0 (nothing installed + // host-side) and the teardown call must carry exactly that. + try std.testing.expectEqual(@as(u64, 0), null_platform.gpu_surface_font_unregister_token); } } +test "embedded app deinit leaves a newer runtime's re-registration of the same font id intact" { + // Host font state is per-process while font ids are only permanent + // per-runtime, so two live runtimes can pass the same id through one + // host: the later registration wins (the documented lifecycle), and + // the OLDER runtime's deinit must return only its own already- + // replaced registration — never the newer runtime's live face, + // which that runtime's engine still holds and draws through. The + // null platform's host-font mirror models the stateful host (the + // shape of macOS's descriptor and token tables): one instance + // stands in for the process, both runtimes register through it, and + // the per-registration ownership token is what the unregister guard + // matches. An id-keyed removal is exactly the regression this test + // catches — it would empty the mirror at A's deinit and fail the + // survives assertion below. + var host_platform = platform.NullPlatform.init(.{}); + host_platform.gpu_surface_font_registrations = true; + var state_a: u8 = 0; + var state_b: u8 = 0; + const embedded_a = try std.testing.allocator.create(EmbeddedApp); + defer std.testing.allocator.destroy(embedded_a); + const embedded_b = try std.testing.allocator.create(EmbeddedApp); + defer std.testing.allocator.destroy(embedded_b); + embedded_a.initInPlace(.{ + .context = &state_a, + .name = "embedded-font-owner-a", + .source = platform.WebViewSource.html("

A

"), + }, host_platform.platform()); + defer embedded_a.deinit(); + embedded_b.initInPlace(.{ + .context = &state_b, + .name = "embedded-font-owner-b", + .source = platform.WebViewSource.html("

B

"), + }, host_platform.platform()); + defer embedded_b.deinit(); + embedded_a.runtime.owned_allocator = std.testing.allocator; + embedded_b.runtime.owned_allocator = std.testing.allocator; + + // A registers first, B re-registers the same id: last wins, each + // runtime captured its own never-repeating ownership token. + try embedded_a.runtime.registerCanvasFont(canvas.min_registered_font_id, canvas.font_ttf.geist_mono_bytes); + const token_a = embedded_a.runtime.canvas_font_entries[0].host_registration_token; + try embedded_b.runtime.registerCanvasFont(canvas.min_registered_font_id, canvas.font_ttf.geist_mono_bytes); + const token_b = embedded_b.runtime.canvas_font_entries[0].host_registration_token; + try std.testing.expect(token_a != 0); + try std.testing.expect(token_b != 0); + try std.testing.expect(token_a != token_b); + const live = host_platform.gpuSurfaceFont(canvas.min_registered_font_id) orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(token_b, live.token); + + // A's deinit presents its stale token: recorded and accepted (the + // registration it owned is already gone), and provably removing + // nothing — B's registration is still the one the host resolves. + embedded_a.deinit(); + try std.testing.expectEqual(@as(usize, 1), host_platform.gpu_surface_font_unregister_count); + try std.testing.expectEqual(@as(u64, canvas.min_registered_font_id), host_platform.gpu_surface_font_unregister_id); + try std.testing.expectEqual(token_a, host_platform.gpu_surface_font_unregister_token); + const survivor = host_platform.gpuSurfaceFont(canvas.min_registered_font_id) orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(token_b, survivor.token); + + // B's deinit presents the live token and the host state comes out — + // the guard blocks the wrong owner, not teardown itself. + embedded_b.deinit(); + try std.testing.expectEqual(@as(usize, 2), host_platform.gpu_surface_font_unregister_count); + try std.testing.expectEqual(token_b, host_platform.gpu_surface_font_unregister_token); + try std.testing.expect(host_platform.gpuSurfaceFont(canvas.min_registered_font_id) == null); +} + test "embedded app deinit is idempotent" { var null_platform = platform.NullPlatform.init(.{}); var state: u8 = 0; diff --git a/src/platform/macos/appkit_host.h b/src/platform/macos/appkit_host.h index ccbf9d981..61cc7935d 100644 --- a/src/platform/macos/appkit_host.h +++ b/src/platform/macos/appkit_host.h @@ -486,14 +486,26 @@ int native_sdk_appkit_measure_text_advances(uint64_t font_id, double size, const // Register engine-validated TrueType bytes under a canvas font id so // measurement and packet text drawing resolve the id to this exact face. -// Returns 1 on success, 0 when CoreText rejects the data. -int native_sdk_appkit_register_font(uint64_t font_id, const uint8_t *bytes, size_t bytes_len); +// Returns 1 on success, 0 when CoreText rejects the data. On success +// `*out_token` is the registration's ownership token — the handle the +// caller must present to native_sdk_appkit_unregister_font, so teardown +// removes exactly this registration (font ids are per-runtime while +// host font state is per-process: a later runtime may re-register the +// id, and its live face must survive the older owner's teardown). Hosts +// that retain no font state (the Chromium engine's stateless accept) +// write 0: nothing installed, nothing a token could own. +int native_sdk_appkit_register_font(uint64_t font_id, const uint8_t *bytes, size_t bytes_len, uint64_t *out_token); // Drop the per-id state a registration installed (the descriptor and its // caches) — the teardown twin Runtime.deinit calls, because font ids are -// per-runtime while this host's font state is per-process. Unregistering -// an id with no installed descriptor is a no-op accept. Returns 1 on +// per-runtime while this host's font state is per-process. `token` must +// be the value the matching register call reported: state is removed +// only while the id's current registration still carries it, so an +// older runtime's deinit never tears down a newer runtime's live face +// under a shared id. A stale token — like an id with no installed +// descriptor — is a no-op accept: the registration the token owned is +// already gone, which is the state the caller asked for. Returns 1 on // accept, 0 only for the invalid id 0. -int native_sdk_appkit_unregister_font(uint64_t font_id); +int native_sdk_appkit_unregister_font(uint64_t font_id, uint64_t token); /* Decode encoded image bytes (PNG, JPEG, ... — whatever ImageIO supports) * through CGImageSource into tightly packed, row-major, straight-alpha * (non-premultiplied) RGBA8 written into `pixels`. Returns 1 on success diff --git a/src/platform/macos/appkit_host.m b/src/platform/macos/appkit_host.m index bd151f47c..8513f2fcb 100644 --- a/src/platform/macos/appkit_host.m +++ b/src/platform/macos/appkit_host.m @@ -1859,9 +1859,13 @@ static unsigned long long NativeSdkRegisteredFontToken(unsigned long long value) // packet text drawing resolve the id to this exact face. Returns 1 on // success, 0 when CoreText rejects the data — the engine already parsed // the face, so a rejection here is surfaced as a loud registration -// error engine-side, never a silent fallback at draw time. -int native_sdk_appkit_register_font(uint64_t font_id, const uint8_t *bytes, size_t bytes_len) { - if (font_id == 0 || !bytes || bytes_len == 0) return 0; +// error engine-side, never a silent fallback at draw time. On success +// `*out_token` reports the registration token minted below — the +// ownership handle unregister_font matches against, so a teardown can +// only remove the registration it owns. +int native_sdk_appkit_register_font(uint64_t font_id, const uint8_t *bytes, size_t bytes_len, uint64_t *out_token) { + if (out_token) *out_token = 0; + if (font_id == 0 || !bytes || bytes_len == 0 || !out_token) return 0; @autoreleasepool { NSData *data = [NSData dataWithBytes:bytes length:bytes_len]; CTFontDescriptorRef descriptor = CTFontManagerCreateFontDescriptorFromData((__bridge CFDataRef)data); @@ -1883,9 +1887,14 @@ int native_sdk_appkit_register_font(uint64_t font_id, const uint8_t *bytes, size // cannot be enumerated for eviction, so stamp the id with a // fresh process-global token instead: width-cache keys // include the token, tokens never repeat, and everything - // cached under any previous stamp becomes unreachable. - NativeSdkRegisteredFontTokens()[@(font_id)] = @(++NativeSdkRegisteredFontTokenCounter); + // cached under any previous stamp becomes unreachable. The + // stamp doubles as the registration's ownership token, + // reported to the caller so its teardown can name exactly + // this registration. + unsigned long long token = ++NativeSdkRegisteredFontTokenCounter; + NativeSdkRegisteredFontTokens()[@(font_id)] = @(token); table[@(font_id)] = (__bridge_transfer id)descriptor; + *out_token = token; } return 1; } @@ -1907,12 +1916,25 @@ int native_sdk_appkit_register_font(uint64_t font_id, const uint8_t *bytes, size // unreachable without any per-id record surviving to say so. An id // with no installed descriptor (never registered host-side, or already // returned) is a no-op accept. -int native_sdk_appkit_unregister_font(uint64_t font_id) { +// +// Removal is token-guarded: `token` is the ownership handle the id's +// register call reported, and state comes out ONLY while the id's +// current registration still carries it. Font ids are per-runtime while +// this state is per-process, so a later runtime may have re-registered +// the id (last wins — its face is the live one measurement and drawing +// resolve); an id-keyed removal here would let the OLDER runtime's +// deinit delete the newer runtime's descriptor and caches, dropping its +// host text to the default family while its engine still holds the +// face. A stale token is a no-op accept, not an error: the registration +// it owned is already gone, which is exactly the state its owner asked +// this call to establish. +int native_sdk_appkit_unregister_font(uint64_t font_id, uint64_t token) { if (font_id == 0) return 0; @autoreleasepool { NSMutableDictionary *table = NativeSdkRegisteredFontDescriptors(); @synchronized (table) { - if (!table[@(font_id)]) return 1; + NSNumber *current = NativeSdkRegisteredFontTokens()[@(font_id)]; + if (!current || current.unsignedLongLongValue != token) return 1; NSMutableDictionary *sizeCache = NativeSdkRegisteredFontSizeCache(); NSString *stalePrefix = [NSString stringWithFormat:@"%llu/", (unsigned long long)font_id]; NSArray *cachedKeys = sizeCache.allKeys; diff --git a/src/platform/macos/cef_host.mm b/src/platform/macos/cef_host.mm index 070798807..c629c4c69 100644 --- a/src/platform/macos/cef_host.mm +++ b/src/platform/macos/cef_host.mm @@ -2461,15 +2461,21 @@ int native_sdk_appkit_update_widget_accessibility(native_sdk_appkit_host_t *host * registration so `Options.fonts` apps start identically under both * hosts; refusing here would fail startup for a face this host never * resolves. */ -int native_sdk_appkit_register_font(uint64_t font_id, const uint8_t *bytes, size_t bytes_len) { - if (font_id == 0 || !bytes || bytes_len == 0) return 0; +int native_sdk_appkit_register_font(uint64_t font_id, const uint8_t *bytes, size_t bytes_len, uint64_t *out_token) { + /* Token 0 is the honest report for a stateless accept: nothing was + * installed, so there is no registration a token could own and + * nothing a later unregister could remove. */ + if (out_token) *out_token = 0; + if (font_id == 0 || !bytes || bytes_len == 0 || !out_token) return 0; return 1; } /* Teardown twin of the stateless accept above: registration retained no * host state here, so Runtime.deinit's per-id unregister has nothing to - * return — accept it for the same start-identically reason. */ -int native_sdk_appkit_unregister_font(uint64_t font_id) { + * return — accept it (whatever token rides it) for the same + * start-identically reason. */ +int native_sdk_appkit_unregister_font(uint64_t font_id, uint64_t token) { + (void)token; if (font_id == 0) return 0; return 1; } diff --git a/src/platform/macos/root.zig b/src/platform/macos/root.zig index 9861f1d03..22a8e1a55 100644 --- a/src/platform/macos/root.zig +++ b/src/platform/macos/root.zig @@ -187,8 +187,8 @@ extern fn native_sdk_appkit_close_webview(host: *AppKitHost, window_id: u64, lab extern fn native_sdk_appkit_clipboard_read(host: *AppKitHost, buffer: [*]u8, buffer_len: usize) usize; extern fn native_sdk_appkit_measure_text(font_id: u64, size: f64, text: [*]const u8, text_len: usize) f64; extern fn native_sdk_appkit_measure_text_advances(font_id: u64, size: f64, text: [*]const u8, text_len: usize, advances: [*]f32) c_int; -extern fn native_sdk_appkit_register_font(font_id: u64, bytes: [*]const u8, bytes_len: usize) c_int; -extern fn native_sdk_appkit_unregister_font(font_id: u64) c_int; +extern fn native_sdk_appkit_register_font(font_id: u64, bytes: [*]const u8, bytes_len: usize, out_token: *u64) c_int; +extern fn native_sdk_appkit_unregister_font(font_id: u64, token: u64) c_int; extern fn native_sdk_appkit_register_bundled_fonts() void; extern fn native_sdk_appkit_decode_image(bytes: [*]const u8, bytes_len: usize, pixels: [*]u8, pixels_len: usize, out_width: *usize, out_height: *usize) c_int; extern fn native_sdk_appkit_clipboard_write(host: *AppKitHost, text: [*]const u8, text_len: usize) void; @@ -1483,20 +1483,30 @@ fn removeGpuSurfaceImage(context: ?*anyopaque, id: u64) anyerror!void { /// Registered canvas fonts feed the host's single font-resolution seam /// (measurement AND packet text drawing), which exists on both web -/// engines, so this is deliberately not gated on `web_engine`. -fn registerGpuSurfaceFont(context: ?*anyopaque, font: platform_mod.GpuSurfaceFontData) anyerror!void { +/// engines, so this is deliberately not gated on `web_engine`. Returns +/// the registration's ownership token: the AppKit host mints one per +/// registration (font state there is per-process, so the token is what +/// lets teardown remove exactly this registration under a shared id); +/// the Chromium host retains no font state and reports 0 — nothing to +/// own, nothing for an unregister to remove. +fn registerGpuSurfaceFont(context: ?*anyopaque, font: platform_mod.GpuSurfaceFontData) anyerror!u64 { _ = context; - if (native_sdk_appkit_register_font(font.id, font.ttf.ptr, font.ttf.len) == 0) return error.InvalidGpuSurfaceFont; + var token: u64 = 0; + if (native_sdk_appkit_register_font(font.id, font.ttf.ptr, font.ttf.len, &token) == 0) return error.InvalidGpuSurfaceFont; + return token; } /// Teardown twin of the registration above: drop the host's per-id font /// state (the CoreText descriptor and its caches) when the runtime that -/// registered the id deinits. Same seam, so deliberately not gated on -/// `web_engine` either; unregistering an id the host never saw is a -/// no-op accept. -fn unregisterGpuSurfaceFont(context: ?*anyopaque, id: u64) anyerror!void { +/// registered the id deinits — but only while the id's current +/// registration still carries `token`, so an older runtime's deinit +/// never removes a face a newer runtime re-registered under the same +/// id. Same seam as registration, so deliberately not gated on +/// `web_engine` either; unregistering an id the host never saw, or with +/// a stale token, is a no-op accept. +fn unregisterGpuSurfaceFont(context: ?*anyopaque, id: u64, token: u64) anyerror!void { _ = context; - if (native_sdk_appkit_unregister_font(id) == 0) return error.InvalidGpuSurfaceFont; + if (native_sdk_appkit_unregister_font(id, token) == 0) return error.InvalidGpuSurfaceFont; } fn updateWidgetAccessibility(context: ?*anyopaque, snapshot: platform_mod.WidgetAccessibilitySnapshot) anyerror!void { diff --git a/src/platform/null_platform.zig b/src/platform/null_platform.zig index c664fee9b..dbc36e5e3 100644 --- a/src/platform/null_platform.zig +++ b/src/platform/null_platform.zig @@ -125,6 +125,7 @@ const GpuSurfaceInputEvent = types.GpuSurfaceInputEvent; const GpuSurfacePixels = types.GpuSurfacePixels; const GpuSurfacePacket = types.GpuSurfacePacket; const GpuSurfaceImagePixels = types.GpuSurfaceImagePixels; +const GpuSurfaceFontData = types.GpuSurfaceFontData; const max_gpu_surface_scroll_drivers = types.max_gpu_surface_scroll_drivers; const GpuSurfaceScrollDriver = types.GpuSurfaceScrollDriver; const max_context_menu_items = types.max_context_menu_items; @@ -161,6 +162,20 @@ pub const NullGpuSurfaceImage = struct { sample_rgba: [4]u8 = .{ 0, 0, 0, 0 }, }; +/// Matches the runtime font registry's slot count +/// (`canvas_limits.max_registered_canvas_fonts`). +pub const max_gpu_surface_fonts: usize = 16; + +/// One mirrored host font registration (see `gpu_surface_fonts`): the +/// id-keyed record a stateful host retains per registered face, plus the +/// ownership token the register call reported for it — the shape of the +/// AppKit host's descriptor-and-token tables, minus the CoreText. +pub const NullGpuSurfaceFont = struct { + id: u64 = 0, + token: u64 = 0, + byte_len: usize = 0, +}; + pub const NullTimer = struct { id: u64 = 0, interval_ns: u64 = 0, @@ -433,14 +448,40 @@ pub const NullPlatform = struct { gpu_surface_image_upload_byte_len: usize = 0, gpu_surface_image_upload_sample_rgba: [4]u8 = .{ 0, 0, 0, 0 }, gpu_surface_image_remove_id: u64 = 0, + /// Host-font mirror for the registration seam, default OFF: the null + /// platform models a platform without host-side text, whose register + /// seam is null (engine-side registration is the whole story there, + /// and the runtime records ownership token 0 — nothing installed). + /// Tests modelling a stateful host (macOS's per-process descriptor + /// and token tables) enable this before taking `platform()`: the + /// mirror then accepts registrations id-keyed and last-wins, mints a + /// monotonic ownership token per registration exactly like the + /// AppKit host, and honors the token guard at unregister — so the + /// shared-id lifecycle (an older runtime's deinit must leave a newer + /// runtime's re-registration intact) is assertable without CoreText. + /// Per-instance where the real host tables are per-process: tests + /// model "one process" by pointing every runtime at ONE instance. + gpu_surface_font_registrations: bool = false, + gpu_surface_fonts: [max_gpu_surface_fonts]NullGpuSurfaceFont = [_]NullGpuSurfaceFont{.{}} ** max_gpu_surface_fonts, + gpu_surface_font_count: usize = 0, + gpu_surface_font_register_count: usize = 0, + /// The mirror's monotonic token counter, pre-incremented per + /// registration so 0 is never a live token — the AppKit host's + /// counter discipline, which is what makes a stale (or 0) token + /// provably match nothing. + gpu_surface_font_token_counter: u64 = 0, /// Font-unregister recorder for the host-teardown seam Runtime.deinit - /// drives. The null platform has no register seam (no host-side text - /// — engine-side registration is the whole story here), but it does - /// accept and record the teardown call, so embed tests can pin that - /// deinit returns the host-side registration on platforms that do - /// retain per-id font state (macOS's CoreText descriptor and caches). + /// drives. Present even with the mirror off: the null platform then + /// has no register seam (no host-side text — engine-side registration + /// is the whole story), but it still accepts and records the teardown + /// call, so embed tests can pin that deinit returns the host-side + /// registration on platforms that do retain per-id font state + /// (macOS's CoreText descriptor and caches). The recorded token is + /// exactly what the call carried — 0 when the runtime captured no + /// register-time token, the minted stamp when the mirror is on. gpu_surface_font_unregister_count: usize = 0, gpu_surface_font_unregister_id: u64 = 0, + gpu_surface_font_unregister_token: u64 = 0, timers: [max_null_timers]NullTimer = [_]NullTimer{.{}} ** max_null_timers, timer_count: usize = 0, timer_start_count: usize = 0, @@ -627,6 +668,7 @@ pub const NullPlatform = struct { .present_gpu_surface_packet_binary_fn = presentGpuSurfacePacketBinary, .upload_gpu_surface_image_fn = uploadGpuSurfaceImage, .remove_gpu_surface_image_fn = removeGpuSurfaceImage, + .register_gpu_surface_font_fn = if (self.gpu_surface_font_registrations) registerGpuSurfaceFont else null, .unregister_gpu_surface_font_fn = unregisterGpuSurfaceFont, .decode_image_fn = decodeImage, .set_gpu_surface_scroll_drivers_fn = setGpuSurfaceScrollDrivers, @@ -1770,14 +1812,67 @@ pub const NullPlatform = struct { self.gpu_surface_image_count = last; } - /// Recording no-op (see the recorder fields): there is no host font - /// state to drop, and unlike the image seam this one is not gated on - /// `gpu_surfaces` — Runtime.deinit unregisters every registered font - /// regardless of what the surface renders through. - fn unregisterGpuSurfaceFont(context: ?*anyopaque, id: u64) anyerror!void { + /// The host-font mirror's register half (installed only when + /// `gpu_surface_font_registrations` is on — see that flag): id-keyed + /// and last-wins like the AppKit host's descriptor table, minting a + /// pre-incremented monotonic ownership token per registration and + /// returning it, so tests can assert the token lifecycle the real + /// host runs. + fn registerGpuSurfaceFont(context: ?*anyopaque, font: GpuSurfaceFontData) anyerror!u64 { + const self: *NullPlatform = @ptrCast(@alignCast(context.?)); + self.gpu_surface_font_register_count += 1; + self.gpu_surface_font_token_counter += 1; + const token = self.gpu_surface_font_token_counter; + const index = self.findGpuSurfaceFontIndex(font.id) orelse blk: { + if (self.gpu_surface_font_count >= max_gpu_surface_fonts) return error.InvalidGpuSurfaceFont; + const index = self.gpu_surface_font_count; + self.gpu_surface_font_count += 1; + break :blk index; + }; + self.gpu_surface_fonts[index] = .{ + .id = font.id, + .token = token, + .byte_len = font.ttf.len, + }; + return token; + } + + /// Records every teardown call (see the recorder fields; unlike the + /// image seam this one is not gated on `gpu_surfaces` — Runtime.deinit + /// unregisters every registered font regardless of what the surface + /// renders through), then applies the mirror's token guard: the id's + /// entry comes out only while it still carries `token`, so a stale + /// owner (the id was re-registered since, last wins) removes nothing + /// — the guard an id-keyed removal would violate by letting an older + /// runtime's deinit delete a newer runtime's live registration. With + /// the mirror off the table is empty and this is the recording no-op + /// it always was: there is no host font state to drop. + fn unregisterGpuSurfaceFont(context: ?*anyopaque, id: u64, token: u64) anyerror!void { const self: *NullPlatform = @ptrCast(@alignCast(context.?)); self.gpu_surface_font_unregister_id = id; + self.gpu_surface_font_unregister_token = token; self.gpu_surface_font_unregister_count += 1; + const index = self.findGpuSurfaceFontIndex(id) orelse return; + if (self.gpu_surface_fonts[index].token != token) return; + const last = self.gpu_surface_font_count - 1; + if (index != last) self.gpu_surface_fonts[index] = self.gpu_surface_fonts[last]; + self.gpu_surface_fonts[last] = .{}; + self.gpu_surface_font_count = last; + } + + /// The mirrored host font registration for `id`, or null when the id + /// holds none (never registered, or returned at teardown) — what a + /// stateful host would resolve measurement and drawing through. + pub fn gpuSurfaceFont(self: *const NullPlatform, id: u64) ?NullGpuSurfaceFont { + const index = self.findGpuSurfaceFontIndex(id) orelse return null; + return self.gpu_surface_fonts[index]; + } + + fn findGpuSurfaceFontIndex(self: *const NullPlatform, id: u64) ?usize { + for (self.gpu_surface_fonts[0..self.gpu_surface_font_count], 0..) |font, index| { + if (font.id == id) return index; + } + return null; } /// The recorded side-channel entry for `id`, or null when the id was diff --git a/src/platform/types.zig b/src/platform/types.zig index fffea83c8..42e95d619 100644 --- a/src/platform/types.zig +++ b/src/platform/types.zig @@ -1783,8 +1783,11 @@ pub const GpuSurfaceImagePixels = struct { /// keyed by the canvas font id host-wide. The engine validates the bytes /// (a parseable TrueType face under the registry bounds) before this /// call, so hosts may treat a decode failure as a hard error rather than -/// a fallback. Ids are permanent for the process — the engine never -/// re-registers an id with different bytes. +/// a fallback. Ids are permanent per RUNTIME (one runtime never +/// re-registers an id with different bytes), but host font state is +/// per-process and runtimes are not: a later runtime may re-register an +/// id a still-live earlier runtime also holds, and the host resolves the +/// most recent registration (last wins). pub const GpuSurfaceFontData = struct { id: u64, ttf: []const u8, @@ -2204,17 +2207,34 @@ pub const PlatformServices = struct { /// without host-side text (GTK/Win32/null default), where the engine /// measures with the parsed face and inks it through the reference /// renderer. - register_gpu_surface_font_fn: ?*const fn (context: ?*anyopaque, font: GpuSurfaceFontData) anyerror!void = null, + /// + /// Returns the host's ownership token for THIS registration of the + /// id. Host font state is per-process while ids are only permanent + /// per-runtime, so two live runtimes can register the same id (last + /// wins); the token names which registration a later unregister may + /// remove — the runtime stores it beside the captured unregister + /// owner and passes it back at teardown. Platforms that retain no + /// per-id host state (a stateless accept) return 0: there was + /// nothing installed, so there is nothing a token could own, and an + /// unregister carrying 0 removes nothing. + register_gpu_surface_font_fn: ?*const fn (context: ?*anyopaque, font: GpuSurfaceFontData) anyerror!u64 = null, /// Teardown twin of the registration seam above: return whatever /// per-id state the host retained for a registered face (descriptor /// tables, size/measurement caches) when the runtime that registered /// the id deinits. Fonts are per-runtime but host font state is /// per-process, so without this seam an embedder cycling runtimes /// with fresh ids grows host state for the process lifetime. - /// Unregistering an id the host never saw is a no-op, not an error. - /// Null on platforms whose register seam is itself null (GTK/Win32 - /// default) — with no host font state there is nothing to return. - unregister_gpu_surface_font_fn: ?*const fn (context: ?*anyopaque, id: u64) anyerror!void = null, + /// `token` is the ownership token the matching register call + /// returned: the host removes the id's state ONLY while the id's + /// current registration still carries that token, so an older + /// runtime's deinit can never tear down a newer runtime's live face + /// under a shared id. A stale token (the id was re-registered since) + /// is a no-op accept — the registration it owned is already gone, + /// which is exactly the state its owner asked for. Unregistering an + /// id the host never saw is likewise a no-op, not an error. Null on + /// platforms whose register seam is itself null (GTK/Win32 default) + /// — with no host font state there is nothing to return. + unregister_gpu_surface_font_fn: ?*const fn (context: ?*anyopaque, id: u64, token: u64) anyerror!void = null, update_widget_accessibility_fn: ?*const fn (context: ?*anyopaque, snapshot: WidgetAccessibilitySnapshot) anyerror!void = null, /// Reconcile the native scroll drivers for a gpu-surface view against /// the full desired set: create missing drivers, update frames / @@ -2705,17 +2725,23 @@ pub const PlatformServices = struct { return remove_fn(self.context, id); } - pub fn registerGpuSurfaceFont(self: PlatformServices, font: GpuSurfaceFontData) anyerror!void { + /// Returns the host's ownership token for this registration (0 from + /// hosts that retain no per-id state) — see + /// `register_gpu_surface_font_fn`. + pub fn registerGpuSurfaceFont(self: PlatformServices, font: GpuSurfaceFontData) anyerror!u64 { if (font.id == 0) return error.InvalidGpuSurfaceFont; if (font.ttf.len == 0 or font.ttf.len > max_gpu_surface_font_bytes) return error.InvalidGpuSurfaceFont; const register_fn = self.register_gpu_surface_font_fn orelse return error.UnsupportedService; return register_fn(self.context, font); } - pub fn unregisterGpuSurfaceFont(self: PlatformServices, id: u64) anyerror!void { + /// `token` must be the value the matching `registerGpuSurfaceFont` + /// returned; a stale token is a no-op accept — see + /// `unregister_gpu_surface_font_fn`. + pub fn unregisterGpuSurfaceFont(self: PlatformServices, id: u64, token: u64) anyerror!void { if (id == 0) return error.InvalidGpuSurfaceFont; const unregister_fn = self.unregister_gpu_surface_font_fn orelse return error.UnsupportedService; - return unregister_fn(self.context, id); + return unregister_fn(self.context, id, token); } pub fn updateWidgetAccessibility(self: PlatformServices, snapshot: WidgetAccessibilitySnapshot) anyerror!void { diff --git a/src/runtime/canvas_fonts.zig b/src/runtime/canvas_fonts.zig index 1139eaf2f..59994c6b3 100644 --- a/src/runtime/canvas_fonts.zig +++ b/src/runtime/canvas_fonts.zig @@ -89,8 +89,20 @@ pub const CanvasFontEntry = struct { /// points at by teardown time, stranding the original host's /// descriptor and caches). Null when the platform at registration /// time had no unregister seam: it retained nothing to return. - host_unregister_fn: ?*const fn (context: ?*anyopaque, id: u64) anyerror!void = null, + host_unregister_fn: ?*const fn (context: ?*anyopaque, id: u64, token: u64) anyerror!void = null, host_unregister_context: ?*anyopaque = null, + /// The host's ownership token for THIS registration, returned by + /// `registerGpuSurfaceFont` and passed back through the owner above + /// at deinit. Host font state is per-process while ids are only + /// permanent per-runtime, so a later runtime may re-register this + /// entry's id (last wins, the documented lifecycle); the host + /// removes an id's state only while its current registration still + /// carries the presented token, which keeps an older runtime's + /// deinit from tearing down a newer runtime's live face. 0 when the + /// platform returned no token (a stateless accept, or no register + /// seam at all): nothing was installed for this registration, and + /// an unregister carrying 0 removes nothing. + host_registration_token: u64 = 0, }; /// Placeholder measure fn for the runtime's font-aware provider field @@ -172,9 +184,15 @@ pub fn RuntimeCanvasFonts(comptime Runtime: type) type { // path below, so the host that hears the registration is the // host the entry's unregister owner names. const services = self.options.platform.services; - services.registerGpuSurfaceFont(.{ .id = id, .ttf = pooled }) catch |err| switch (err) { - error.UnsupportedService => { + // The returned token is the host's ownership handle for THIS + // registration (0 when the host retained nothing); it rides + // the entry beside the captured owner so deinit can tell the + // host which registration to remove — see + // `CanvasFontEntry.host_registration_token`. + const host_token: u64 = services.registerGpuSurfaceFont(.{ .id = id, .ttf = pooled }) catch |err| switch (err) { + error.UnsupportedService => blk: { if (services.measure_text_fn != null) return error.FontHostRegistrationUnsupported; + break :blk 0; }, else => return err, }; @@ -191,6 +209,7 @@ pub fn RuntimeCanvasFonts(comptime Runtime: type) type { .bytes = pooled, .host_unregister_fn = services.unregister_gpu_surface_font_fn, .host_unregister_context = services.context, + .host_registration_token = host_token, }; self.canvas_font_count = index + 1; // Bind the font-aware measure provider on first registration. diff --git a/src/runtime/core.zig b/src/runtime/core.zig index 4550b1d2f..49978fa49 100644 --- a/src/runtime/core.zig +++ b/src/runtime/core.zig @@ -494,9 +494,14 @@ pub const Runtime = struct { // (the `owned_allocator` identity discipline two lines // down, applied to the host). Best-effort by design — // deinit cannot fail, and a null owner means the platform - // at registration time retained nothing to return. + // at registration time retained nothing to return. The + // entry's ownership token rides along so the host removes + // ONLY this runtime's registration: a later runtime may + // have re-registered the id (last wins), and presenting a + // stale token is a no-op accept — the newer runtime's live + // face survives this deinit. if (entry.host_unregister_fn) |host_unregister_fn| { - host_unregister_fn(entry.host_unregister_context, entry.id) catch {}; + host_unregister_fn(entry.host_unregister_context, entry.id, entry.host_registration_token) catch {}; } // Through the frozen identity, never `options.allocator`: // an embedder may have swapped the public option since these From b85727f9c5c8ef6484c396ebccfa962623dcd385 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 18 Jul 2026 21:42:25 -0500 Subject: [PATCH 18/21] Snapshot font token and face in one AppKit critical section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - native_sdk_appkit_measure_text read the registration token and resolved the font under separate lock acquisitions, so a registration landing between them paired token 0 with the new registered face and cached registered widths under the reusable token-0 key — served as stale registered widths after teardown, when token 0 means built-in resolution. - NativeSdkRegisteredFontSnapshot now reads the id's token and resolves its registered face inside one @synchronized section (shaping stays outside the lock: a stale-but-consistent pair keys a retired token no lookup reaches); built-in resolution is split into NativeSdkBuiltInFontForFontId so token 0 only ever pairs with built-in widths. - measure_text_advances audited: it memoizes nothing host-side (the engine caches the batch under its own measure generation), so it has no (token, face) pairing to tear; the pin step now pins the snapshot signature and its measure_text call site. --- build.zig | 12 +++- src/platform/macos/appkit_host.m | 110 +++++++++++++++++++++++-------- 2 files changed, 92 insertions(+), 30 deletions(-) diff --git a/build.zig b/build.zig index e07b0fa60..30ad15b63 100644 --- a/build.zig +++ b/build.zig @@ -837,7 +837,17 @@ pub fn build(b: *std.Build) void { // newer runtime's live face under a shared id (ids are // per-runtime, host font state is per-process, last wins). .{ .path = "src/platform/macos/appkit_host.m", .pattern = "*out_token = token;" }, - .{ .path = "src/platform/macos/appkit_host.m", .pattern = "unsigned long long token = NativeSdkRegisteredFontToken((unsigned long long)font_id);" }, + // The token and the registered face reach measure_text from ONE + // critical section (the snapshot helper): separate acquisitions + // let a registration land between the token read and the face + // resolution, pairing token 0 with the new registered face and + // caching registered widths under the reusable token-0 key — + // stale registered widths served after teardown. The snapshot + // signature and its measure_text call site are pinned so the + // two-acquisition shape cannot quietly return. + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "static NSFont *NativeSdkRegisteredFontSnapshot(unsigned long long value, CGFloat size, unsigned long long *out_token)" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "NSFont *registered = NativeSdkRegisteredFontSnapshot((unsigned long long)font_id, clamped, &token);" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "NSFont *font = registered ?: NativeSdkBuiltInFontForFontId(font_id, clamped);" }, .{ .path = "src/platform/macos/appkit_host.m", .pattern = "[NSString stringWithFormat:@\"%llu/%llu/%.3f/%@\", (unsigned long long)font_id, token, (double)clamped, value]" }, // The teardown half: Runtime.deinit returns the host-side // registration through the unregister owner each font entry diff --git a/src/platform/macos/appkit_host.m b/src/platform/macos/appkit_host.m index 8513f2fcb..59aa0a5a9 100644 --- a/src/platform/macos/appkit_host.m +++ b/src/platform/macos/appkit_host.m @@ -1820,27 +1820,39 @@ static NSTextAlignment NativeSdkPacketTextAlignment(NSString *align) { // table's @synchronized guard, like every table here. static unsigned long long NativeSdkRegisteredFontTokenCounter = 0; -// The current registration token for a font id: 0 unless the id holds -// a live registration. -static unsigned long long NativeSdkRegisteredFontToken(unsigned long long value) { - NSMutableDictionary *table = NativeSdkRegisteredFontDescriptors(); - @synchronized (table) { - NSNumber *token = NativeSdkRegisteredFontTokens()[@(value)]; - return token ? token.unsignedLongLongValue : 0; - } -} - -// The registered face for a canvas font id at `size`, or nil when the id -// has no registered face. Checked BEFORE the built-in candidates and -// their cache so a registered id can never be masked by a font resolved -// for that id earlier. Cached NSFonts here stay honest because +// The id's current registration token AND the registered face that +// token resolves to, snapshotted under ONE acquisition of the +// descriptor table's @synchronized guard. The pair must be atomic +// because measure_text keys its width memoization by (id, token): with +// the token read and the face resolved under SEPARATE acquisitions, a +// registration landing between them pairs token 0 with the NEW +// registered face and caches a registered-face width under the +// reusable token-0 key — served again after the registration is torn +// down, when token 0 honestly means built-in resolution. One critical +// section makes a torn pair unrepresentable; the width computation +// itself stays outside the lock (see measure_text), because a +// stale-but-consistent pair is harmless — its widths are keyed by a +// retired token no future lookup can reach. Returns nil with +// *out_token = 0 when the id holds no registration, so token 0 only +// ever pairs with built-in resolution. (A registered descriptor whose +// CTFont creation fails also answers nil, under its live token: the +// built-in fallback then caches under that token — still one +// consistent resolution per registration life, since the failure is a +// property of the installed descriptor.) +// +// The registered-face NSFonts cached here stay honest because // registration evicts an id's entries when its descriptor changes (see // NativeSdkRegisteredFontSizeCache) — an id is only permanent within -// one runtime, and this cache outlives runtimes. -static NSFont *NativeSdkRegisteredFontForId(unsigned long long value, CGFloat size) { +// one runtime, and this cache outlives runtimes. Registered faces are +// checked BEFORE the built-in candidates and their cache so a +// registered id can never be masked by a font resolved for that id +// earlier. +static NSFont *NativeSdkRegisteredFontSnapshot(unsigned long long value, CGFloat size, unsigned long long *out_token) { NSMutableDictionary *table = NativeSdkRegisteredFontDescriptors(); NSMutableDictionary *sizeCache = NativeSdkRegisteredFontSizeCache(); @synchronized (table) { + NSNumber *tokenNumber = NativeSdkRegisteredFontTokens()[@(value)]; + *out_token = tokenNumber ? tokenNumber.unsignedLongLongValue : 0; id descriptorObject = table[@(value)]; if (!descriptorObject) return nil; NSString *key = [NSString stringWithFormat:@"%llu/%.3f", value, (double)size]; @@ -1854,6 +1866,14 @@ static unsigned long long NativeSdkRegisteredFontToken(unsigned long long value) } } +// The registered face for a canvas font id at `size`, or nil when the id +// has no registered face — the snapshot above for callers that resolve +// but do not memoize by token (packet drawing, the advances batch). +static NSFont *NativeSdkRegisteredFontForId(unsigned long long value, CGFloat size) { + unsigned long long token = 0; + return NativeSdkRegisteredFontSnapshot(value, size, &token); +} + // Engine-validated TrueType bytes for a registered canvas font id: parse // them into a font descriptor once and key it by id, so measurement and // packet text drawing resolve the id to this exact face. Returns 1 on @@ -1948,16 +1968,14 @@ int native_sdk_appkit_unregister_font(uint64_t font_id, uint64_t token) { } } -// Resolves a canvas font id to the NSFont presentation draws with. Both -// packet text drawing and native_sdk_appkit_measure_text go through this -// single function so measured layout and drawn glyphs share font -// resolution. Ids 3-6 are the reserved sans span variants (medium, bold, -// italic, bold italic); everything else keeps the regular sans/mono -// candidates. Registered faces win first (see above). Resolved -// built-in fonts are cached per (font id, size). -static NSFont *NativeSdkFontForFontId(unsigned long long value, CGFloat size) { - NSFont *registered = NativeSdkRegisteredFontForId(value, size); - if (registered) return registered; +// The built-in-candidates half of font resolution: never consults the +// registered tables, so callers that already hold a registered-face +// snapshot (measure_text) fall back here without re-reading state the +// snapshot fixed. Ids 3-6 are the reserved sans span variants (medium, +// bold, italic, bold italic); everything else keeps the regular +// sans/mono candidates. Resolved built-in fonts are cached per +// (font id, size). +static NSFont *NativeSdkBuiltInFontForFontId(unsigned long long value, CGFloat size) { static NSCache *cache = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ @@ -2005,6 +2023,19 @@ int native_sdk_appkit_unregister_font(uint64_t font_id, uint64_t token) { return font; } +// Resolves a canvas font id to the NSFont presentation draws with. Both +// packet text drawing and native_sdk_appkit_measure_text go through this +// resolution order (registered faces win, then the built-in candidates) +// so measured layout and drawn glyphs share font resolution — +// measure_text inlines the two halves around its width cache because it +// must key that cache by the registration-token snapshot the first half +// fixes. +static NSFont *NativeSdkFontForFontId(unsigned long long value, CGFloat size) { + NSFont *registered = NativeSdkRegisteredFontForId(value, size); + if (registered) return registered; + return NativeSdkBuiltInFontForFontId(value, size); +} + static NSFont *NativeSdkPacketPreferredFont(NSDictionary *text, CGFloat size) { NSNumber *fontId = [text[@"font"] isKindOfClass:[NSNumber class]] ? text[@"font"] : nil; unsigned long long value = fontId ? fontId.unsignedLongLongValue : 1; @@ -2032,12 +2063,25 @@ int native_sdk_appkit_unregister_font(uint64_t font_id, uint64_t token) { // re-registered under the same id (a new runtime in this // process) never serves a previous face's widths — see // NativeSdkRegisteredFontTokens for why token, not eviction, - // invalidates this cache. - unsigned long long token = NativeSdkRegisteredFontToken((unsigned long long)font_id); + // invalidates this cache. Token and registered face come from + // ONE snapshot (one critical section — see + // NativeSdkRegisteredFontSnapshot): reading them under separate + // acquisitions let a registration land between the reads and + // cache the new face's width under the reusable token-0 key, + // serving stale registered widths in the unregistered state + // after teardown. + unsigned long long token = 0; + NSFont *registered = NativeSdkRegisteredFontSnapshot((unsigned long long)font_id, clamped, &token); NSString *key = [NSString stringWithFormat:@"%llu/%llu/%.3f/%@", (unsigned long long)font_id, token, (double)clamped, value]; NSNumber *cached = [widthCache objectForKey:key]; if (cached) return cached.doubleValue; - NSFont *font = NativeSdkFontForFontId(font_id, clamped); + // A nil snapshot means built-in resolution — with token 0 by the + // snapshot's contract unless a live descriptor's CTFont creation + // failed, so token 0 only ever keys built-in widths. Shaping + // stays OUTSIDE the critical section: a registration landing + // after the snapshot leaves this width stale but consistent, + // keyed by the now-retired token no future lookup reaches. + NSFont *font = registered ?: NativeSdkBuiltInFontForFontId(font_id, clamped); if (!font) return -1; double width = [value sizeWithAttributes:@{ NSFontAttributeName : font }].width; [widthCache setObject:@(width) forKey:key]; @@ -2055,6 +2099,14 @@ int native_sdk_appkit_unregister_font(uint64_t font_id, uint64_t token) { // call per text run replaces one measure_text round-trip per cluster of // every growing line prefix — the engine caches the batch, so a run is // typically shaped here once per content change, not once per frame. +// +// Unlike measure_text this needs no registration-token snapshot: it +// memoizes nothing host-side (the engine caches the batch and +// invalidates it by its own measure generation, bumped at every +// registration), so each call resolves the font once and uses it +// immediately — there is no (token, face) pairing that could tear +// across lock acquisitions or outlive the registration it was read +// from. int native_sdk_appkit_measure_text_advances(uint64_t font_id, double size, const char *text, size_t text_len, float *advances) { if (!text || text_len == 0 || !advances) return 0; CGFloat clamped = MAX(1, size); From 0b4090a6d229cc591c207aedd248d12d120acce6 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 18 Jul 2026 22:45:22 -0500 Subject: [PATCH 19/21] Clear the AppKit measured-width cache when a font registration is torn down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - unregister_font purged the descriptor, token, and size cache but the measured-width NSCache was a function-local static inside measure_text, unreachable from teardown: retired-token entries could never be served again (tokens never repeat) yet stayed resident — up to 16,384 keys carrying full text strings — until memory pressure, contradicting the zero-retained-state contract. - The cache is hoisted to a shared accessor (the same shape as the NSFont size cache) and the token-matched unregister path clears it wholesale — NSCache cannot enumerate keys, so per-id eviction is impossible; the trade is stated at the site (teardown-frequency event, live ids re-warm in one measure each), and a stale-token no-op never clears. - The cache-eviction pin step holds the accessor, the clear, and the measure_text call site; sabotage-verified (pin fails with the clear removed). No behavioral tier can observe the ObjC cache, so textual pins are the honest coverage. --- build.zig | 14 +++++++ src/platform/macos/appkit_host.m | 64 ++++++++++++++++++++++++-------- 2 files changed, 62 insertions(+), 16 deletions(-) diff --git a/build.zig b/build.zig index 30ad15b63..330f34985 100644 --- a/build.zig +++ b/build.zig @@ -869,6 +869,20 @@ pub fn build(b: *std.Build) void { .{ .path = "src/platform/macos/appkit_host.m", .pattern = "if (!current || current.unsignedLongLongValue != token) return 1;" }, .{ .path = "src/platform/macos/appkit_host.m", .pattern = "[NativeSdkRegisteredFontTokens() removeObjectForKey:@(font_id)];" }, .{ .path = "src/platform/macos/appkit_host.m", .pattern = "[table removeObjectForKey:@(font_id)];" }, + // Teardown must also CLEAR the measured-width NSCache: a + // retiring token's entries can never be served again (tokens + // never repeat) but they stay resident until memory pressure, + // and NSCache cannot enumerate keys, so the whole-cache clear on + // the token-matched path is the only release. Pinned textually + // because no behavioral tier can observe it — only managed app + // builds compile appkit_host.m, so no SDK test can watch the + // ObjC cache empty. The shared accessor and its measure_text + // call site are pinned with the clear so the cache cannot + // quietly retreat to a function-local static the unregister + // path has no way to reach. + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "static NSCache *NativeSdkMeasuredWidthCache(void)" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "[NativeSdkMeasuredWidthCache() removeAllObjects];" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "NSCache *widthCache = NativeSdkMeasuredWidthCache();" }, .{ .path = "src/runtime/canvas_fonts.zig", .pattern = ".host_unregister_fn = services.unregister_gpu_surface_font_fn," }, .{ .path = "src/runtime/canvas_fonts.zig", .pattern = ".host_registration_token = host_token," }, .{ .path = "src/runtime/core.zig", .pattern = "host_unregister_fn(entry.host_unregister_context, entry.id, entry.host_registration_token) catch {};" }, diff --git a/src/platform/macos/appkit_host.m b/src/platform/macos/appkit_host.m index 59aa0a5a9..6ef3ed549 100644 --- a/src/platform/macos/appkit_host.m +++ b/src/platform/macos/appkit_host.m @@ -1771,8 +1771,8 @@ static NSTextAlignment NativeSdkPacketTextAlignment(NSString *align) { // valid id in a new runtime is inside the documented lifecycle. Entries // therefore go stale exactly when an id's descriptor is (re)installed, // so `native_sdk_appkit_register_font` evicts the id's cached sizes -// before installing the new descriptor. The measured-width NSCache in -// `native_sdk_appkit_measure_text` goes stale at the same moment but +// before installing the new descriptor. The measured-width NSCache +// (`NativeSdkMeasuredWidthCache`) goes stale at the same moment but // cannot be prefix-evicted (NSCache does not enumerate keys), so it is // invalidated by registration token instead — see // NativeSdkRegisteredFontTokens below. Accessed only under the @@ -1787,16 +1787,18 @@ static NSTextAlignment NativeSdkPacketTextAlignment(NSString *align) { } // Per-id registration tokens, the invalidation handle for the -// measured-width NSCache in `native_sdk_appkit_measure_text`. That +// measured-width NSCache (`NativeSdkMeasuredWidthCache`). That // cache has the same lifetime hazard as the size cache above (per- // process cache, per-runtime id permanence) but no way to evict an // id's entries — NSCache cannot enumerate keys — so every registration // stamps its id with the next value of ONE process-global monotonic // counter and the width-cache key includes the stamp. Tokens never // repeat, so no registration can ever reach widths a previous life of -// its id cached (those entries become unreachable and age out under -// the cache's own count limit) — which is exactly what makes an id's -// record REMOVABLE at unregister, retaining zero state per retired id. +// its id cached (re-registration leaves those unreachable entries to +// age out under the cache's count limit; unregistration clears the +// cache outright — see `native_sdk_appkit_unregister_font`) — which is +// exactly what makes an id's record REMOVABLE at unregister, retaining +// zero state per retired id. // A per-id bump counter could not offer that: deleting a per-id count // would let a future registration of the id restart at 1 and collide // with the retired life's still-cached widths, so retired ids would @@ -1820,6 +1822,28 @@ static NSTextAlignment NativeSdkPacketTextAlignment(NSString *align) { // table's @synchronized guard, like every table here. static unsigned long long NativeSdkRegisteredFontTokenCounter = 0; +// The measured-width NSCache `native_sdk_appkit_measure_text` memoizes +// shaped widths in, hoisted out of that function (exactly the shared- +// accessor shape the NSFont size cache above took) so +// `native_sdk_appkit_unregister_font` can reach it at teardown: a +// retiring token's entries can never be SERVED again (tokens never +// repeat) but a function-local static left them RESIDENT — up to the +// full count limit of keys, each carrying the measured text itself — +// until memory pressure. NSCache is thread-safe, so unlike the +// dictionaries above this cache is deliberately NOT confined to the +// descriptor table's @synchronized guard: measure_text shapes and +// caches outside the critical section on purpose (see the snapshot +// comment there). +static NSCache *NativeSdkMeasuredWidthCache(void) { + static NSCache *cache = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + cache = [[NSCache alloc] init]; + cache.countLimit = 16384; + }); + return cache; +} + // The id's current registration token AND the registered face that // token resolves to, snapshotted under ONE acquisition of the // descriptor table's @synchronized guard. The pair must be atomic @@ -1927,13 +1951,15 @@ int native_sdk_appkit_register_font(uint64_t font_id, const uint8_t *bytes, size // caches) for the process lifetime. Removal drops EVERYTHING the // registration installed: the id's cached NSFonts (the same prefix // eviction re-registration runs), the id's registration token record, -// and the descriptor itself — zero retained state per retired id, so +// the descriptor itself, and — by clearing the measured-width NSCache +// wholesale, the only eviction NSCache permits — the widths measured +// under the retiring token. Zero retained state per retired id, so // runtime cycles truly do not accumulate host entries. Deleting the // token record (rather than bumping it) is safe precisely because // tokens come from one process-global monotonic counter: a future -// registration of this id takes a fresh, never-repeated token, so the -// widths this life cached in the measured-width NSCache stay -// unreachable without any per-id record surviving to say so. An id +// registration of this id takes a fresh, never-repeated token, so no +// per-id record has to survive to keep this life's width-cache keys +// unreachable. An id // with no installed descriptor (never registered host-side, or already // returned) is a no-op accept. // @@ -1963,6 +1989,17 @@ int native_sdk_appkit_unregister_font(uint64_t font_id, uint64_t token) { } [NativeSdkRegisteredFontTokens() removeObjectForKey:@(font_id)]; [table removeObjectForKey:@(font_id)]; + // The retiring token's measured widths can never be served + // again (tokens never repeat) but would otherwise stay + // resident until memory pressure. NSCache cannot enumerate + // keys, so a per-id eviction is impossible and clearing the + // WHOLE cache is the only honest release. The collateral is + // cheap where it lands: unregistration is a teardown- + // frequency event, and every other live id re-warms each + // width it still needs in one measure call. The clear runs + // only on this token-matched path — a stale-token no-op + // above must not cost live registrations their warm cache. + [NativeSdkMeasuredWidthCache() removeAllObjects]; } return 1; } @@ -2053,12 +2090,7 @@ int native_sdk_appkit_unregister_font(uint64_t font_id, uint64_t token) { @autoreleasepool { NSString *value = [[NSString alloc] initWithBytes:text length:text_len encoding:NSUTF8StringEncoding]; if (!value) return -1; - static NSCache *widthCache = nil; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - widthCache = [[NSCache alloc] init]; - widthCache.countLimit = 16384; - }); + NSCache *widthCache = NativeSdkMeasuredWidthCache(); // The key carries the id's registration token so a face // re-registered under the same id (a new runtime in this // process) never serves a previous face's widths — see From 3d7995ccea971173397601abddd75c0d74413044 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 18 Jul 2026 22:46:11 -0500 Subject: [PATCH 20/21] Cover the PR's user-visible fixes and API break in a changelog fragment - Seven review rounds accumulated user-visible surface beyond the original docs/diagnostics/CI fragment: the late-registration rebuild, the macOS host font teardown, and the registerGpuSurfaceFont token break with the new unregisterGpuSurfaceFont service. - A new fix-tagged fragment covers them, per the one-tag-per-fragment convention; the Breaking bullet states the break deliberately with a matter-of-fact migration note for custom-platform embedders. - Internal-only work (pins, test machinery) stays out, matching the fragments' user-facing voice. --- changelog.d/registered-fonts-late-rebuild-and-teardown.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/registered-fonts-late-rebuild-and-teardown.md diff --git a/changelog.d/registered-fonts-late-rebuild-and-teardown.md b/changelog.d/registered-fonts-late-rebuild-and-teardown.md new file mode 100644 index 000000000..e974b764d --- /dev/null +++ b/changelog.d/registered-fonts-late-rebuild-and-teardown.md @@ -0,0 +1,3 @@ +fix: **Late-registered fonts re-measure open surfaces**: registering a face after views are installed (`runtime.registerCanvasFont` on a live runtime) now rebuilds every installed `UiApp` surface — the main canvas and declared windows — on the next presented frame, so text laid out before the face joined re-measures with the registered face instead of keeping its pre-registration widths under a repaint. +- **macOS host font state ends with its runtime**: `Runtime.deinit` now returns each registered id's host-side registration — the CoreText descriptor and its measurement caches, including the measured-width cache the host previously retained until memory pressure — so embedders that cycle runtimes no longer accumulate per-process font state; removal is ownership-token guarded, so an older runtime's teardown never removes a newer runtime's live face under a shared id. +- **Breaking**: `PlatformServices.registerGpuSurfaceFont` now returns the host's ownership token for the registration (`u64`; 0 from hosts that retain no per-id state), and the new optional `unregisterGpuSurfaceFont(id, token)` service returns that state at teardown — a deliberate break while the toolkit is pre-1.0, so host font lifetime has an owner. Embedders implementing a custom platform change `register_gpu_surface_font_fn` to return a `u64` token (0 is fine for a stateless accept) and may supply `unregister_gpu_surface_font_fn` to release per-id host state when the registering runtime deinits. From cfe4aa86225519114712768f10c46f538838e09d Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 18 Jul 2026 23:42:56 -0500 Subject: [PATCH 21/21] Recheck the registration token before caching a measured width - measure_text shapes outside the descriptor guard, so an unregister landing mid-shape cleared the width cache and then had it repopulated by the in-flight write: a retired-token entry no lookup can serve but resident until memory pressure - Registered-token writes now re-enter the guard and cache only while the id still carries the snapshotted token; token 0 (built-in resolution, never unregistered) keeps the unconditional write - Pin the recheck shape in test-appkit-registered-font-cache-eviction; advances batch and packet drawing re-audited as memoizing nothing host-side that the window could touch --- build.zig | 16 +++++++++++ src/platform/macos/appkit_host.m | 49 ++++++++++++++++++++++++++------ 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/build.zig b/build.zig index 330f34985..f74d5dcb8 100644 --- a/build.zig +++ b/build.zig @@ -849,6 +849,22 @@ pub fn build(b: *std.Build) void { .{ .path = "src/platform/macos/appkit_host.m", .pattern = "NSFont *registered = NativeSdkRegisteredFontSnapshot((unsigned long long)font_id, clamped, &token);" }, .{ .path = "src/platform/macos/appkit_host.m", .pattern = "NSFont *font = registered ?: NativeSdkBuiltInFontForFontId(font_id, clamped);" }, .{ .path = "src/platform/macos/appkit_host.m", .pattern = "[NSString stringWithFormat:@\"%llu/%llu/%.3f/%@\", (unsigned long long)font_id, token, (double)clamped, value]" }, + // The width-cache WRITE is token-rechecked. Shaping runs outside + // the guard, so an unregister can land inside the shaping window: + // it clears the whole width cache (its only possible eviction), + // and an unconditional post-shape write would then repopulate the + // cleared cache with a retired-token entry — unreachable for + // serving (tokens never repeat) but resident until memory + // pressure, breaking the zero-retained-state teardown the clear + // exists to guarantee. measure_text therefore re-enters the + // descriptor guard after shaping and writes only while the id's + // current token still equals the snapshotted one; token 0 + // (built-in resolution — never registered, so never cleared) + // caches without the recheck. Both branches are pinned so the + // unconditional-write shape cannot quietly return. + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "if (token == 0) {" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "NSNumber *currentToken = NativeSdkRegisteredFontTokens()[@(font_id)];" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "if (currentToken && currentToken.unsignedLongLongValue == token) {" }, // The teardown half: Runtime.deinit returns the host-side // registration through the unregister owner each font entry // captured at registration time (never live `options.platform`, diff --git a/src/platform/macos/appkit_host.m b/src/platform/macos/appkit_host.m index 6ef3ed549..017af50b0 100644 --- a/src/platform/macos/appkit_host.m +++ b/src/platform/macos/appkit_host.m @@ -1831,9 +1831,10 @@ static NSTextAlignment NativeSdkPacketTextAlignment(NSString *align) { // full count limit of keys, each carrying the measured text itself — // until memory pressure. NSCache is thread-safe, so unlike the // dictionaries above this cache is deliberately NOT confined to the -// descriptor table's @synchronized guard: measure_text shapes and -// caches outside the critical section on purpose (see the snapshot -// comment there). +// descriptor table's @synchronized guard: measure_text shapes outside +// the critical section on purpose (see the snapshot comment) and +// re-enters the guard only to recheck the token before a +// registered-token write (see the recheck comment in measure_text). static NSCache *NativeSdkMeasuredWidthCache(void) { static NSCache *cache = nil; static dispatch_once_t onceToken; @@ -1855,8 +1856,9 @@ static NSTextAlignment NativeSdkPacketTextAlignment(NSString *align) { // down, when token 0 honestly means built-in resolution. One critical // section makes a torn pair unrepresentable; the width computation // itself stays outside the lock (see measure_text), because a -// stale-but-consistent pair is harmless — its widths are keyed by a -// retired token no future lookup can reach. Returns nil with +// stale-but-consistent pair is harmless: measure_text rechecks the +// token under the guard before caching, so a pair retired mid-shape +// is never even written. Returns nil with // *out_token = 0 when the id holds no registration, so token 0 only // ever pairs with built-in resolution. (A registered descriptor whose // CTFont creation fails also answers nil, under its live token: the @@ -1999,6 +2001,10 @@ int native_sdk_appkit_unregister_font(uint64_t font_id, uint64_t token) { // width it still needs in one measure call. The clear runs // only on this token-matched path — a stale-token no-op // above must not cost live registrations their warm cache. + // A measurement in flight during this teardown cannot + // repopulate the cleared cache: measure_text rechecks the + // id's token under this same guard before caching, and this + // removal already retired the token it snapshotted. [NativeSdkMeasuredWidthCache() removeAllObjects]; } return 1; @@ -2110,13 +2116,38 @@ int native_sdk_appkit_unregister_font(uint64_t font_id, uint64_t token) { // A nil snapshot means built-in resolution — with token 0 by the // snapshot's contract unless a live descriptor's CTFont creation // failed, so token 0 only ever keys built-in widths. Shaping - // stays OUTSIDE the critical section: a registration landing - // after the snapshot leaves this width stale but consistent, - // keyed by the now-retired token no future lookup reaches. + // stays OUTSIDE the critical section: a registration or + // unregistration landing after the snapshot leaves this width + // stale but consistent, and the token recheck below drops the + // write instead of caching it. NSFont *font = registered ?: NativeSdkBuiltInFontForFontId(font_id, clamped); if (!font) return -1; double width = [value sizeWithAttributes:@{ NSFontAttributeName : font }].width; - [widthCache setObject:@(width) forKey:key]; + if (token == 0) { + // Token 0 is built-in resolution: built-in faces are never + // registered, so no unregister can ever clear widths keyed + // under it — this write needs no recheck. + [widthCache setObject:@(width) forKey:key]; + return width; + } + // A registered-token width is cached only after RECHECKING the + // token under the guard. Unregister's only possible eviction is + // clearing the whole width cache, and shaping ran outside the + // critical section: an unconditional write here would land AFTER + // that clear whenever the teardown ran inside the shaping window, + // repopulating the cleared cache with a retired-token entry — one + // no lookup can ever serve (tokens never repeat) but that stays + // resident until memory pressure. A mismatch means the + // registration was torn down (or replaced) mid-shape — dropping + // the write costs one re-measure of a face that is already gone, + // and keeps teardown's cleared cache actually clear. + NSMutableDictionary *table = NativeSdkRegisteredFontDescriptors(); + @synchronized (table) { + NSNumber *currentToken = NativeSdkRegisteredFontTokens()[@(font_id)]; + if (currentToken && currentToken.unsignedLongLongValue == token) { + [widthCache setObject:@(width) forKey:key]; + } + } return width; } }