Skip to content
5 changes: 5 additions & 0 deletions changelog.d/cjk-glyph-budgets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
fix: **Registered CJK fonts render dense glyphs**: glyph outline budgets are now sized from real CJK faces (Noto Sans JP/SC/TC/KR measured; 1024 points / 128 contours per glyph), so everyday dense kanji like 鬱 ink as real outlines instead of notdef blocks; the per-font registration size bound rose to 24 MiB so full CJK faces register.
- **Glyph complexity validates at registration, not render**: a face whose `maxp` declares glyphs denser than the outline budgets — simple maxima and flattened-composite maxima (`maxCompositePoints`/`maxCompositeContours`) alike — is refused at registration with a teaching that names its numbers against the budgets (`error.FontExceedsGlyphBudgets`), instead of silently degrading individual glyphs to blocks at render time.
- **Font bytes are heap-allocated on demand**: the registry copies each registered file into an exact-size allocation from the runtime's new `Options.allocator` (freed by the new `Runtime.deinit`), so the 24 MiB bound is validation, not a storage reservation — a runtime with no registered fonts carries zero font bytes, where a reservation-shaped pool would have embedded 192 MiB in every Runtime (measured on the docs wasm preview host: 313.5 MB → 121.5 MB per component tile).
- **`EmbeddedApp.deinit` completes the embed lifecycle**: direct embedders end an embedded app with `defer embedded.deinit()` (idempotent), which returns the runtime's heap-owned registrations — without it, a host creating and destroying apps in one process leaked the registered font storage per cycle. The wrapper hosts and the C ABI's `native_sdk_app_destroy` route their teardown through the same deinit, one lifecycle owner.
- **Glyph raster budgets are derived from the registration gate**: the vector core's glyph-fill capacities (`GlyphRasterizer`: 18,560 edges/crossings, flattening clamped at 16 segments per curve) are computed from the outline budgets registration admits, so a truthfully-declared budget-maximal glyph — a 1024-point zigzag contour included — rasterizes at any size instead of hitting `VectorPathTooComplex` and degrading to the block fallback the gate promises cannot happen; the clamp binds only above ~128-px ems and keeps the polyline within 0.2% of the em, so existing renders are byte-identical.
3 changes: 3 additions & 0 deletions skill-data/core/references/app-model-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ Use `EmbeddedApp` when another host owns the main loop:

```zig
var embedded = native_sdk.embed.EmbeddedApp.init(my_app.app(), my_platform);
defer embedded.deinit();
try embedded.start();
try embedded.frame();
try embedded.resize(new_surface);
Expand All @@ -119,6 +120,8 @@ try embedded.stop();

This is useful for mobile hosts, game engines, custom render loops, and headless tests. The repository includes iOS and Android examples that link `libnative-sdk.a` through Swift/Kotlin host apps.

`stop()` tells the app it is shutting down; `deinit()` releases what the embedded runtime owns on the heap (registered canvas font bytes). It is idempotent, so `defer embedded.deinit()` right after `init` is the idiom — an embedder that creates and destroys apps in one process leaks the font registry per cycle without it. The C ABI's `native_sdk_app_destroy` runs the same deinit through its host wrapper.

## Headless tests

Use `NullPlatform` or `TestHarness` when GUI behavior is not required:
Expand Down
15 changes: 15 additions & 0 deletions src/app_runner/root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,9 @@ fn runNull(app: native_sdk.App, options: RunOptions, init: std.process.Init) !vo
// stack overflows on a stack instance, so construct it on the heap.
const runtime = try std.heap.page_allocator.create(native_sdk.Runtime);
defer std.heap.page_allocator.destroy(runtime);
// Fonts registered at startup are heap-owned by the runtime; return
// them before the runtime storage itself goes.
defer runtime.deinit();
native_sdk.Runtime.initAt(runtime, .{
.platform = null_platform.platform(),
.trace_sink = runtime_trace_sink,
Expand Down Expand Up @@ -483,6 +486,9 @@ fn runMacos(app: native_sdk.App, options: RunOptions, init: std.process.Init) !v
// stack overflows on a stack instance, so construct it on the heap.
const runtime = try std.heap.page_allocator.create(native_sdk.Runtime);
defer std.heap.page_allocator.destroy(runtime);
// Fonts registered at startup are heap-owned by the runtime; return
// them before the runtime storage itself goes.
defer runtime.deinit();
native_sdk.Runtime.initAt(runtime, .{
.platform = mac_platform.platform(),
.trace_sink = runtime_trace_sink,
Expand Down Expand Up @@ -535,6 +541,9 @@ fn runLinux(app: native_sdk.App, options: RunOptions, init: std.process.Init) !v
// stack overflows on a stack instance, so construct it on the heap.
const runtime = try std.heap.page_allocator.create(native_sdk.Runtime);
defer std.heap.page_allocator.destroy(runtime);
// Fonts registered at startup are heap-owned by the runtime; return
// them before the runtime storage itself goes.
defer runtime.deinit();
native_sdk.Runtime.initAt(runtime, .{
.platform = linux_platform.platform(),
.trace_sink = runtime_trace_sink,
Expand Down Expand Up @@ -586,6 +595,9 @@ fn runWindows(app: native_sdk.App, options: RunOptions, init: std.process.Init)
// stack overflows on a stack instance, so construct it on the heap.
const runtime = try std.heap.page_allocator.create(native_sdk.Runtime);
defer std.heap.page_allocator.destroy(runtime);
// Fonts registered at startup are heap-owned by the runtime; return
// them before the runtime storage itself goes.
defer runtime.deinit();
native_sdk.Runtime.initAt(runtime, .{
.platform = windows_platform.platform(),
.trace_sink = runtime_trace_sink,
Expand Down Expand Up @@ -699,6 +711,9 @@ fn runSessionReplay(app: native_sdk.App, options: RunOptions, init: std.process.
}
const runtime = try std.heap.page_allocator.create(native_sdk.Runtime);
defer std.heap.page_allocator.destroy(runtime);
// Fonts registered at startup are heap-owned by the runtime; return
// them before the runtime storage itself goes.
defer runtime.deinit();
// Bridge policy and security must match what the recording ran
// under (they gate replayed bridge_message dispatch); automation,
// window-state restore, and tracing stay off — replay consumes only
Expand Down
24 changes: 24 additions & 0 deletions src/embed/host.zig
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,15 @@ pub fn deliverPresentedPixels(self: anytype, scale: f32, buffer: []u8, out: *typ
return true;
}

/// A runtime embedded in a host that owns the main loop (the mobile
/// shims, game engines, custom render loops, headless tests). Lifecycle:
/// `init`/`initInPlace`, `defer embedded.deinit()`, `start`, host-pumped
/// events and `frame`s, `stop` (dispatches app_shutdown), and finally
/// that deferred `deinit` — `stop` only tells the app it is shutting
/// down; `deinit` is what returns the embedded runtime's heap-owned
/// registrations (registered canvas font bytes), so a direct embedder
/// that creates and destroys apps in one process leaks a full font
/// registry per cycle without it.
pub const EmbeddedApp = struct {
app: runtime.App,
runtime: runtime.Runtime,
Expand All @@ -451,6 +460,17 @@ pub const EmbeddedApp = struct {
runtime.Runtime.initAt(&self.runtime, .{ .platform = platform_value });
}

/// End of the embedded lifecycle, symmetric with `init`/`initInPlace`:
/// releases everything the embedded app owns on the heap — the
/// runtime's registered canvas font bytes (`Runtime.deinit`); `init`
/// itself allocates nothing else. Idempotent: a second call is a
/// no-op, so `defer embedded.deinit()` composes with early exits.
/// Everything borrowing the runtime (parsed faces, measure
/// providers, views) is invalid past this point.
pub fn deinit(self: *EmbeddedApp) void {
self.runtime.deinit();
}

pub fn start(self: *EmbeddedApp) anyerror!void {
try self.runtime.dispatchPlatformEvent(self.app, .app_start);
}
Expand Down Expand Up @@ -757,6 +777,10 @@ pub const MobileHostApp = struct {

pub fn destroy(self: *MobileHostApp) void {
disableAutomation(self);
// One lifecycle owner: the embedded app's own deinit returns its
// heap-owned registrations (registered canvas font bytes) before
// the host storage goes.
self.embedded.deinit();
std.heap.page_allocator.destroy(self);
}

Expand Down
69 changes: 69 additions & 0 deletions src/embed/tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,75 @@ test "embedded app starts and loads source" {
try @import("std").testing.expectEqualStrings("<p>Embedded</p>", null_platform.loaded_source.?.bytes);
}

test "embedded app deinit returns registered font bytes across create-destroy cycles" {
// A direct embedder that creates and destroys apps in one process:
// every cycle registers a face (font bytes are the embedded
// runtime's only heap ownership) and tears down through the public
// `deinit` — the lifecycle end the doc comment promises. The
// leak-checking test allocator behind the `Options.allocator` seam
// fails this test if any cycle strands the storage, which is
// exactly what a stop()-only teardown did.
var cycle: usize = 0;
while (cycle < 3) : (cycle += 1) {
var null_platform = 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-cycle",
.source = platform.WebViewSource.html("<p>Fonts</p>"),
}, null_platform.platform());
defer embedded.deinit();
// Ownership is frozen at init (`Runtime.owned_allocator`;
// mutating `options.allocator` retargets nothing), so the test
// re-freezes the field itself — equivalent to constructing with
// the leak-checked allocator, safe while nothing is owned yet.
embedded.runtime.owned_allocator = std.testing.allocator;

try embedded.start();
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();
}
}

test "embedded app deinit is idempotent" {
var null_platform = 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-deinit-twice",
.source = platform.WebViewSource.html("<p>Fonts</p>"),
}, null_platform.platform());
embedded.runtime.owned_allocator = std.testing.allocator;
try embedded.runtime.registerCanvasFont(canvas.min_registered_font_id, canvas.font_ttf.geist_mono_bytes);

// 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.
embedded.deinit();
try std.testing.expectEqual(@as(usize, 0), embedded.runtime.registeredCanvasFontCount());
embedded.deinit();
}

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);

const self = mobileApp(app).?;
self.embedded.runtime.owned_allocator = std.testing.allocator;
try self.embedded.runtime.registerCanvasFont(canvas.min_registered_font_id, canvas.font_ttf.geist_mono_bytes);
try std.testing.expectEqual(@as(usize, 1), self.embedded.runtime.registeredCanvasFontCount());

// `native_sdk_app_destroy` is the shim's whole teardown: it routes
// through `EmbeddedApp.deinit` (one lifecycle owner), so the
// leak-checked font bytes must come back here.
native_sdk_app_destroy(app);
}

test "mobile C ABI can load packaged asset source" {
const app = native_sdk_app_create() orelse return error.TestUnexpectedResult;
defer native_sdk_app_destroy(app);
Expand Down
4 changes: 4 additions & 0 deletions src/embed/ui_host.zig
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,10 @@ pub fn UiAppHost(comptime AppDef: type) type {
host.disableAutomation(self);
self.render_memo.deinit();
self.ui.deinit();
// One lifecycle owner: the embedded app's own deinit returns
// its heap-owned registrations (registered canvas font
// bytes) before the host storage goes.
self.embedded.deinit();
std.heap.page_allocator.destroy(self);
}

Expand Down
6 changes: 4 additions & 2 deletions src/platform/types.zig
Original file line number Diff line number Diff line change
Expand Up @@ -260,8 +260,10 @@ pub const max_gpu_present_fallback_detail_bytes: usize = 32;
pub const max_gpu_surface_image_pixel_bytes: usize = 1024 * 1024;
/// Per-font bound for the gpu-surface font registration side-channel;
/// matches the runtime registry's per-slot bound
/// (`canvas_limits.max_registered_canvas_font_bytes`).
pub const max_gpu_surface_font_bytes: usize = 2 * 1024 * 1024;
/// (`canvas_limits.max_registered_canvas_font_bytes`), sized so full
/// CJK faces register (Noto Sans SC's TrueType build, the largest
/// measured, is 17.8 MB).
pub const max_gpu_surface_font_bytes: usize = 24 * 1024 * 1024;

pub const ShortcutModifiers = struct {
primary: bool = false,
Expand Down
Loading
Loading