Swift SDK Internals
The WendyLite SwiftPM library is an Embedded Swift target compiled to wasm32-unknown-wasip1. This document covers how the async runtime, entry point protocol, timers, and callback
Swift SDK Internals
The WendyLite SwiftPM library is an Embedded Swift target compiled to wasm32-unknown-wasip1. This document covers how the async runtime, entry point protocol, timers, and callback dispatch work — context needed when extending the SDK or debugging unexpected behaviour.
Requirements
- Swift 6.3.1 via
swiftly - Embedded Swift SDK:
swift-6.3.1-RELEASE_wasm-embedded - Target triple:
wasm32-unknown-wasip1
Build command:
swiftly run +6.3.1 swift build \
--swift-sdk swift-6.3.1-RELEASE_wasm-embedded \
--triple wasm32-unknown-wasip1 \
-c releaseEntry Point: WendyLiteApp
WendyLiteApp is a protocol with a static main() entry point wired to @main. Conform a type to it and annotate with @main:
@main
struct MyApp: WendyLiteApp {
let clock = WendyClock()
mutating func setup() async {
// One-time init (hardware config, WiFi connect, etc.)
}
mutating func loop() async {
// Called repeatedly for the lifetime of the app
}
}setup() has a default no-op implementation so it is optional.
main() does the following before entering the loop:
- Calls
bootstrapAsyncRuntime()(idempotent, guarded byRuntimeBootstrapState). - Constructs an instance via
Self(). - Awaits
setup(). - Loops forever awaiting
loop().
Async Runtime Bootstrap
Embedded Swift on WASM runs a cooperative single-threaded executor. bootstrapAsyncRuntime() (in WendyLiteApp.swift) sets it up:
- Registers the timer callback handler ID with
CallbackDispatch(handler ID1). - Drains any events that arrived before user code starts (
pumpAsyncRuntimeOnce(timeoutMs: 0)). - Launches a background
Taskthat callspumpAsyncRuntimeOnce(timeoutMs: 250)in a loop, yielding between iterations so Swift tasks can run.
pumpAsyncRuntimeOnce calls System.waitForEvent(timeoutMs:) (which maps to sys_wait_for_event on the host and blocks until a callback fires or the timeout elapses), then calls TimerState.shared.drainReady() to resume any sleeping tasks whose deadlines have passed.
Why not Task.sleep?
Task.sleep requires a conforming Clock type, but Embedded Swift cannot satisfy the untyped throwing requirement in the Clock protocol. Use WendyClock instead.
WendyClock
WendyClock (WendyClock.swift) is a concrete async-sleep implementation that drives await clock.sleep(for:) and await clock.sleep(until:).
let clock = WendyClock()
try? await clock.sleep(for: .milliseconds(500))Internally:
nowreadsSystem.uptimeMs()and wraps it inWendyClock.Instant.sleep(until:)delegates toTimerState.shared.sleep(until:)inTimerHub.
TimerHub
TimerHub maintains a sorted array of Waiter entries (deadline + checked continuation). When a sleep is requested:
- A
Waiteris inserted in deadline order. rescheduleForEarliestDeadline()callsTimer.setTimeouton the host for the earliest pending deadline (cancelling any previously scheduled timer first).- When the host fires the timer,
timerFired()moves all expired waiters toreadyWaiters. drainReady()(called frompumpAsyncRuntimeOnce) resumes those continuations.
Cancellation is handled via withTaskCancellationHandler: a SleepRegistration flag is shared between the operation and the cancel handler so that cancellation arriving before or after the continuation is registered is always handled correctly.
Edge case: If Timer.setTimeout returns a negative ID, the host could not allocate an ESP timer (no free slots or esp_timer_create/esp_timer_start failure). Pending sleepers will stall in that case — no recovery is attempted.
Callback Dispatch
CallbackDispatch (CallbackDispatch.swift) is the single routing point for all host-to-guest async events (GPIO interrupts, BLE events, timer callbacks, UART receive callbacks).
// Register a handler for handler_id = 42
CallbackDispatch.register(42) { arg0, arg1, arg2 in
// handle event
}The exported C function wendy_handle_callback (which the host calls to dispatch events into the WASM guest) routes by handler ID through the registered table.
Handler IDs are application-defined integers. Timer callbacks internally use ID 1 (a private constant in WendyClock.swift). Your application must use IDs that do not collide with internal use.
SwiftPM Package Structure
Package.swift
targets:
CWendyLite (C target) — wendy.h + shim.c
WendyLite (Swift target, depends on CWendyLite)
swiftSettings: .enableExperimentalFeature("Embedded"), -wmoThe WendyLite target uses whole-module optimisation (-wmo) and the Embedded experimental feature, both of which are required for the WASM Embedded SDK. App packages must replicate these settings in their own executable target (see the README for the complete template).
Linker Requirements for App Packages
App packages must pass specific linker flags:
| Flag | Purpose |
|---|---|
--allow-undefined | Host imports are resolved at load time by WAMR |
--initial-memory=65536 | Minimum WASM linear memory |
--table-base=1 | Avoid table slot 0 (reserved by some runtimes) |
--strip-all | Minimise binary size |
--export=malloc, --export=free | Host may call these for buffer allocation |
--export=wendy_handle_callback | Required for host-to-guest callbacks |
-z stack-size=8192 | Stack size; increase for deeper call stacks |
Known Constraints
- No
Task.sleep— useWendyClock.sleep. - Single-threaded executor — concurrent tasks interleave cooperatively; there is no parallelism.
- Timer slots are finite — the ESP timer subsystem has a limited pool; each outstanding
WendyClock.sleepoccupies one slot for the minimum-deadline timer. - nonisolated(unsafe) —
CallbackDispatchandTimerStateusenonisolated(unsafe)storage because the cooperative single-threaded WASM executor guarantees no true concurrent access.
Host API Reference
All functions are imported from the "wendy" WASM module. The canonical declaration source is Sources/CWendyLite/include/wendy.h. This page documents every function by subsystem, in
Wendy Os Publisher
A tool that uploads, removes and promotes (nightly - release) builds for WendyOS and Wendy Lite.