# Runtime and effects

> Runtime API surface, effect kinds for spawn/fetch/file/audio/clipboard, cancellation, worker wakes, and deterministic frame limits.

- Repository: vercel-labs/native
- GitHub: https://github.com/vercel-labs/native
- Human docs: https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a
- Complete Markdown: https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/llms-full.txt

## Source Files

- `src/runtime/core.zig`
- `src/runtime/api.zig`
- `src/runtime/effects.zig`
- `src/runtime/flow.zig`
- `docs/src/app/runtime/layout.tsx`

---

---
title: "Runtime and effects"
description: "Runtime API surface, effect kinds for spawn/fetch/file/audio/clipboard, cancellation, worker wakes, and deterministic frame limits."
---

`Runtime` owns the platform event loop, windows, views, security policy, automation, tracing, and shell materialization. `Effects(Msg)` is the TEA command channel for `UiApp`: spawn, HTTP fetch, file I/O, clipboard, timers, audio, and window actions leave `update` only through that channel. Workers post fixed-size completion records, nudge the loop via `PlatformServices.wake_fn`, and the loop thread drains results as typed `Msg` values on `.effects_wake` (and again on each presented frame for host-pumped embeds).

## Architecture

```mermaid
flowchart TB
  subgraph appLayer [App layer]
    Model["Model"]
    Update["update(model, msg, fx)"]
    View["view → ShellConfig / canvas tree"]
  end

  subgraph runtimeLayer [Runtime]
    Run["Runtime.run(App)"]
    Flow["flow.zig dispatchPlatformEvent"]
    Event["Event union incl. effects_wake"]
    Drain["UiApp.drainEffects → takeMsg → update"]
  end

  subgraph effectsLayer [Effects channel]
    FX["Effects(Msg)"]
    Slots["max_effects = 16 slots"]
    Queue["MPSC completion queue"]
    Fake["executor .real | .fake"]
  end

  subgraph platformLayer [Platform]
    Loop["platform.run"]
    Wake["wake_fn / request_frame_fn"]
    Services["clipboard, timers, audio, windows"]
  end

  Update -->|"fx.spawn / fetch / …"| FX
  FX --> Slots
  Slots -->|"worker posts"| Queue
  Queue --> Wake
  Wake --> Loop
  Loop --> Flow
  Flow --> Event
  Event --> Drain
  Drain --> Update
  Update --> Model
  Model --> View
  Run --> Loop
  FX --> Services
  Fake -.->|"tests / session replay"| FX
```

Side effects never touch `Model` off-thread. Payload slices on effect messages point into drain scratch and are valid only for the `update` call that receives them — copy what the model keeps.

## Runtime API surface

Public re-exports live in `src/runtime/root.zig` and `src/runtime/api.zig`. The concrete loop and storage live in `src/runtime/core.zig`; platform event routing is in `src/runtime/flow.zig`.

### `App` and lifecycle

`App(Runtime)` is a type-erased host contract:

| Field | Type | Role |
| --- | --- | --- |
| `context` | `*anyopaque` | App state pointer |
| `name` | `[]const u8` | Traces and automation snapshots |
| `source` / `source_fn` | WebView source | Compatibility WebView startup |
| `scene_fn` | `?fn(*anyopaque) !ShellConfig` | Declarative native shell (UiApp path) |
| `start_fn` | optional | After runtime start, before scene/source load |
| `event_fn` | optional | Every `Event` (lifecycle, commands, effects_wake, canvas, …) |
| `stop_fn` | optional | Before shutdown; guaranteed once via flow teardown |
| `replay_fn` | optional | Session replay arm/feed for effect stubbing |

`LifecycleEvent`: `start`, `activate`, `deactivate`, `frame`, `stop`.

### `Event`

| Tag | Purpose |
| --- | --- |
| `lifecycle` | App lifecycle |
| `command` / `shortcut` / `timer` | Commands, keybindings, platform timers |
| `effects_wake` | Cross-thread worker nudge — drain effect queue on the loop thread |
| `audio` | Platform audio player reports (UiApp maps via `Effects.takeAudioMsg`) |
| `files_dropped` | Native file drops |
| `gpu_surface_*` | GPU surface frame, resize, input |
| `canvas_widget_*` | Pointer, keyboard, scroll, drag, dismiss, resize, change, context menu |
| `window_closed` / `automation_provenance` | Window teardown and automation provenance |

### `Runtime.Options`

| Field | Default | Notes |
| --- | --- | --- |
| `platform` | required | Host platform or null/headless test host |
| `trace_sink` | `null` | Structured traces |
| `security` | `{}` | Navigation allowlist, permissions |
| `bridge` / `builtin_bridge` | null / `{}` | WebView bridge dispatcher and policy |
| `automation` | `null` | File-based automation server |
| `commands` / `menus` / `shortcuts` | empty | Manifest-driven chrome |
| `window_state_store` | `null` | Geometry persistence |
| `js_window_api` | `false` | Trusted `window.zero` helpers |
| `environ` | `null` | Env inherited by spawn children; fallback for embed hosts |
| `session_recorder` | `null` | Record/replay journal; armed via `NATIVE_SDK_SESSION_RECORD` |
| `gpu_surface_frame_diagnostics` | `true` | GPU frame diagnostics |
| `pixel_present_retained_baseline` | `false` | Pixel-only hosts (mobile embed) |

### Core runtime methods

| Method | Role |
| --- | --- |
| `init(options)` / `run(app)` | Construct and enter the platform loop |
| `createWindow` / `listWindows` / `focusWindow` / `closeWindow` | Window control |
| `invalidate` / `invalidateFor` | Request redraw |
| `frameDiagnostics` | Last-frame stats |
| `setCanvasFrameBudget` | Per-view soft budget checks |
| `setGpuSurfaceInputLatencyBudget` | Input-to-frame latency budget |
| `dispatchEvent` / `dispatchPlatformEvent` | Inject or forward events |
| `automationSnapshot` | Write automation state |

`UiApp(Model, Msg)` wires `Effects(Msg)`, binds platform services on start, and implements `drainEffects` so most apps never call `Runtime` methods directly.

## Effects channel

`Effects(Msg)` in `src/runtime/effects.zig` is the only supported path for side effects from `update`:

```zig
pub fn update(model: *Model, msg: Msg, fx: *Effects) void {
    switch (msg) {
        .start => fx.spawn(.{
            .key = stream_key,
            .argv = &stream_argv,
            .on_line = Effects.lineMsg(.line),
            .on_exit = Effects.exitMsg(.exited),
        }),
        .cancel => fx.cancel(stream_key),
        .copy_status => fx.writeClipboard(.{
            .key = clipboard_key,
            .text = text,
            .on_result = Effects.clipboardMsg(.copied),
        }),
        // ...
    }
}
```

Comptime Msg constructors: `lineMsg`, `exitMsg`, `responseMsg`, `fileMsg`, `clipboardMsg`, `timerMsg`, `audioMsg` — each builds a tagged union variant whose payload type matches the effect family.

### Shared capacity and key space

| Constant | Value | Applies to |
| --- | --- | --- |
| `max_effects` | 16 | In-flight slots shared by spawn, fetch, file, clipboard |
| `max_effect_queue_entries` | 64 | Combined completion queue depth |
| `max_effect_pending_exits` | 32 | Loop-thread pending terminal ring |
| `max_effect_timers` | 16 | Timers (own table; not effect slots) |
| `effect_error_exit_code` | `-1` | Non-`.exited` spawn exit code |

Caller-chosen `u64` keys identify effects. Spawn, fetch, file, and clipboard share one key space and the 16 slots — duplicate active keys reject. Timer keys and audio keys are separate namespaces (starting a timer key replaces it; `playAudio` replaces previous playback).

Overflow is never silent: rejections surface as terminal Msgs (`.rejected` / reason `.rejected`); dropped lines accumulate into `dropped_before` / `dropped_lines`.

### Effect kinds

#### Spawn

`fx.spawn(SpawnOptions)` runs a subprocess on a worker thread.

| Field | Default | Constraints |
| --- | --- | --- |
| `key` | required | Unique among active effects |
| `argv` | required | ≤ `max_effect_argv` (16) entries, ≤ `max_effect_argv_bytes` (2048) total |
| `stdin` | `null` | ≤ `max_effect_stdin_bytes` (4096); written once then closed |
| `output` | `.lines` | `.lines` streams stdout; `.collect` delivers whole stdout + stderr tail on exit |
| `max_line_bytes` | 4096 | Ceiling `max_effect_line_bytes_ceiling` (256 KiB); over-ceiling or zero → reject |
| `on_line` / `on_exit` | optional | Msg constructors |

`EffectExitReason`: `exited`, `signaled`, `cancelled`, `rejected`, `spawn_failed`.

Collect mode caps: `max_effect_collect_bytes` (512 KiB), `max_effect_stderr_tail_bytes` (4096). Lines mode ignores stderr.

Children inherit `Runtime.Options.environ` (or `fallbackEnviron()` on embed/mobile).

#### Fetch

`fx.fetch(FetchOptions)` — HTTP(S) only.

| Field | Default | Constraints |
| --- | --- | --- |
| `method` | `.GET` | |
| `url` | required | ≤ `max_effect_url_bytes` (2048); http(s) only |
| `headers` | empty | ≤ 8 headers, ≤ 1024 total header bytes |
| `body` | `null` | ≤ `max_effect_fetch_payload_bytes` (64 KiB) |
| `timeout_ms` | 30_000 | Whole exchange (stream lifetime for `.stream`) |
| `response` | `.buffered` | `.stream` frames body lines via `on_line` |
| `max_line_bytes` | 4096 | Stream mode only; same ceiling as spawn |
| `on_response` | optional | Exactly one terminal Msg |

`EffectFetchOutcome`: `ok`, `rejected`, `connect_failed`, `tls_failed`, `protocol_failed`, `timed_out`, `cancelled`. Body cap `max_effect_body_bytes` (256 KiB); oversize arrives truncated with `truncated = true`. HTTP non-2xx is still `ok` with the real status.

#### File

`fx.writeFile` / `fx.readFile` — worker-thread whole-file ops.

| Bound | Value |
| --- | --- |
| Path | `max_effect_file_path_bytes` (1024) |
| Payload | `max_effect_file_bytes` (1 MiB) |

Write over capacity is rejected outright (no partial write). Read oversize outcome is `.truncated` (not a flag on `.ok`). Outcomes: `ok`, `not_found`, `io_failed`, `truncated`, `rejected`, `cancelled`.

#### Clipboard

`fx.writeClipboard` / `fx.readClipboard` — loop-thread pasteboard calls (not workers). Cap `max_effect_clipboard_bytes` = `platform.max_clipboard_data_bytes` (65536). Outcomes: `ok`, `failed`, `rejected`, `cancelled`. Over-bound writes reject; cut strings never pass as whole content.

#### Timers

`fx.startTimer` / `fx.cancelTimer` — platform timer service, not effect slots.

| Field | Default |
| --- | --- |
| `interval_ms` | required; zero → `.rejected` |
| `mode` | `.one_shot` or `.repeating` |
| `on_fire` | optional → `EffectTimer` |

#### Audio

`fx.playAudio` / `pauseAudio` / `resumeAudio` / `stopAudio` / `seekAudio` / `setAudioVolume` — one player surface. Source cascade: local `path`, then verified `cache_path` for `url`, else progressive stream. Path/url bounds: `max_effect_audio_path_bytes` (1024). Events: `loaded`, `position`, `completed`, `failed`, `rejected`, `spectrum` (32 bands when host supports analysis). `audioCachePath` derives content-addressed cache paths under the OS cache dir.

#### Window actions

`fx.closeWindow(label)` / `fx.minimizeWindow(label)` — real OS verbs via `bindWindowActions` (UiApp wires this). Label capacity 64 bytes. Fake executor records requests without calling the host.

### Cancellation

```text
cancel(key)
  ├─ active slot → mark cancelled_generation, request cancel
  │     spawn: kill + reap → one on_exit { reason: .cancelled }
  │     fetch/file: interrupt worker → one terminal .cancelled
  │     clipboard: mark; real pasteboard may already have finished
  └─ finished-but-queued → drain rewrites terminal to .cancelled
       discards queued on_line for that generation
```

After `cancel(key)` returns: no further `on_line` for that spawn; exactly one terminal Msg. Timers use `cancelTimer(key)` separately. Audio stop uses `stopAudio()` (not `cancel`).

### Worker wakes and drain

```mermaid
sequenceDiagram
  participant Update as update on loop thread
  participant FX as Effects channel
  participant Worker as effect worker
  participant Plat as PlatformServices
  participant Flow as RuntimeFlow
  participant UiApp as UiApp.drainEffects

  Update->>FX: spawn / fetch / writeFile
  FX->>Worker: Thread.spawn slot
  Worker->>FX: post completion entry
  Worker->>Plat: wake_fn
  Plat->>Flow: platform .wake event
  Flow->>UiApp: Event.effects_wake
  UiApp->>FX: takeMsg loop
  UiApp->>Update: applyMsg for each result
  UiApp->>UiApp: rebuild view if any dispatched
```

1. `bindServices` points workers at the host wake service; `bindEnviron` / `bindImages` / `bindWindowActions` / `bindJournal` attach host context.
2. Workers call `wakeHost()` → `services.wake()`.
3. `flow.zig` maps platform `.wake` to `Event.effects_wake`.
4. `UiApp.drainEffects` runs `while (effects.takeMsg()) |msg| applyMsg(msg)`, then rebuilds once if anything dispatched.
5. Host-pumped embeds without wake delivery also drain on each presented frame.

Automation liveness uses a separate path: `request_frame_fn` so queued automation commands wake idle apps (one command per `frame_requested`).

### Fake executor and testing

`effects.executor = .fake` records requests and delivers only what tests feed:

| Feed API | Use |
| --- | --- |
| `feedLine` / `feedExit` / `feedExitReason` | Spawn |
| `feedResponseOutcome` | Fetch |
| `feedFileResult` / `feedClipboardResult` | File / clipboard |
| `fireTimer` | Timers |
| `feedAudioEvent` / `feedAudioSpectrum` | Audio |

Inspection: `pendingSpawnAt`, `pendingFetchAt`, `pendingFileAt`, `pendingClipboardAt`, `pendingTimerAt`, `pendingAudio`, `windowActionState`, `audioSnapshot`.

Session record/replay: `armReplay()` + journaled `EffectResultRecord` feeds; `NATIVE_SDK_SESSION_RECORD` arms the runtime recorder. Effect results drained during dispatch are journaled before the event so replay order stays correct.

## Deterministic frame limits

Two layers bound per-frame work:

### Hard per-view capacities (`canvas_limits.zig`)

Compile-time fixed arrays in `Runtime` / `RuntimeView`. Overflow errors name the budget; automation snapshots report headroom.

| Budget | Cap |
| --- | --- |
| `max_canvas_commands_per_view` | 2048 |
| `max_canvas_glyphs_per_view` | 8192 |
| `max_canvas_text_bytes_per_view` | 32768 |
| `max_canvas_path_elements_per_view` | 2048 |
| `max_canvas_widget_nodes_per_view` | 1024 |
| `max_canvas_widget_text_bytes_per_view` | 65536 |
| `max_canvas_text_layout_lines_per_view` | 8192 |
| `max_registered_canvas_images` | 16 |
| `max_ui_app_windows` | 4 |
| Retained GPU packet commands | = command budget |

These are absolute capacity, not soft targets.

### Soft `CanvasFrameBudget`

`Runtime.setCanvasFrameBudget(window_id, label, budget)` stores a `CanvasFrameBudget` of optional maxima (`max_commands`, `max_batches`, `max_glyph_atlas_entries`, `max_text_layouts`, …). Zero means unset for that field. Status compares frame diagnostics and reports `ok` / `exceededCount` for automation and view JSON (`canvasFrameBudgetOk`, `canvasFrameBudgetExceededCount`).

Use soft budgets in tests and CI to pin frame cost without changing hard engine capacities.

### Dispatch error ring

`max_dispatch_errors` (16) keeps degraded dispatch failures oldest-first; totals still accumulate beyond the ring for diagnostics.

## Operational constraints

| Constraint | Behavior |
| --- | --- |
| Payload lifetime | Copy effect slices inside the receiving `update` |
| Full completion queue | Lines drop with counters; exits still deliver |
| Slot exhaustion | Terminal `.rejected` Msg, not a panic |
| Teardown | `stop_fn` / `effects.deinit` join workers before platform host dies |
| Embed hosts | Drain on frame present when wake is unavailable |
| No Io in update | File and network only through effects |

## Verification signals

| Signal | Meaning |
| --- | --- |
| `examples/effects-probe` Start/Cancel/Copy | Spawn stream, cancel mid-stream, clipboard without `pbcopy` |
| `effects.executor = .fake` + feed APIs | Deterministic unit tests |
| Automation snapshot audio fields | Playback state mirrored from channel |
| View JSON budget fields | Soft budget exceeded |
| Session journal under `NATIVE_SDK_SESSION_RECORD` | Events + effect results + fingerprints |
| Trace `runtime.init` / `runtime.done` | Loop entered and exited cleanly |

## Related pages

<CardGroup>
  <Card title="App model" href="/app-model">
    Model, Msg, update wiring and UiApp over Runtime.
  </Card>
  <Card title="State and data flow" href="/state-data-flow">
    Derive-don't-store, message dispatch, effects as the only side-effect channel.
  </Card>
  <Card title="Capabilities" href="/capabilities">
    Guarded OS services and effect-channel access boundaries.
  </Card>
  <Card title="Testing" href="/testing">
    native test, headless drivers, effect tests, frame-level checks.
  </Card>
  <Card title="Automation" href="/automation">
    Snapshots, widget driving, record/replay, deterministic screenshots.
  </Card>
  <Card title="Bridge and builtin commands" href="/bridge">
    Host-to-content bridge payloads and builtin command policy.
  </Card>
</CardGroup>
