Guides
Operating in production
Four things a shipped app needs that nobody should write for themselves: a crash report that names the Rust frame, the symbol files to read it with, a way to finish the offline queue while the app is not on screen, and a core that is as small as a native release build can make it.
Crash reports#
A panic in the core never crashes the app: the call that was running fails with UndraCallError.panicked and the core goes on. Your crash reporter still wants to know. Set onPanic when you load the core and it receives one structured report for every panic the core contained (a call, a detached task, a computed, a background task, an init or migration hook), once, in order, on the main thread. Nothing is linked for you: forward the report to the reporter you already use. Without onPanic the runtime logs one line, so a panic is never silent.
| Field | What it holds |
|---|---|
| The panic | message and location (file:line:column; release builds remap your home directory to ~, so no path names your machine). |
| The context | operation, what was running (Todos.add, task, computed Todos.visible), and the Rust thread. |
| The backtrace | frames, innermost first, at most 48. Always the address of each instruction as an offset into the core's image; a debug build also has symbol, file and line. |
| The build | namespace, coreVersion and schemaHash: which core. |
| The image | imageId: the Mach-O UUID, ELF build id or wasm SHA-256 of the image the addresses belong to, which is what symbol files are keyed by. |
Show the three loaders
var options = LoadOptions.inproc()
options.onPanic = { report in // main thread, once per panic
MyCrashReporter.recordNonFatal(
name: "UndraPanic in \(report.operation)",
reason: report.message,
location: report.location,
frames: report.frames.map(\.address), // offsets into the core's image
imageId: report.imageId // which build's symbol files resolve them
)
}
let core = try UndraTodos.load(options)val core = UndraTodos.load(
LoadOptions(onPanic = { report -> // the main dispatcher, once per panic
MyCrashReporter.recordNonFatal(
name = "UndraPanic in ${report.operation}",
reason = report.message,
location = report.location,
frames = report.frames.map { it.address },
imageId = report.imageId,
)
})
)const core = await UndraTodos.load({
mode: "wasm-main", wasm,
onPanic: (report) => { // once per trap, before any restart
reportError(new Error(`${report.operation}: ${report.message}`, { cause: report }));
},
});Crashlytics takes the frames as StackFrame(address:) on iOS and as synthetic stack trace elements on Android, Sentry as an event whose frames carry instruction_addr and whose debug image is imageId. The recipes are three lines each and Undra links no reporter.
On the web a panic traps the module, which cannot call out of its last moments. The runtime builds the same report from what the core logged before it trapped and the trap's stack, and calls onPanic before it restarts anything (crash recovery). A wasm frame's address is the module offset in the stack line (wasm-function[41]:0x1a2b), its symbol the function name when the build has one. The generated entry passes namespace; the module carries no version, so pass coreVersion in the load options if you want it in the report. In tests, CaptureDiagnostics (the Rust fakes and each testing kit) records the reports.
Symbol files#
A release build keeps line tables and nothing else: the code, the optimisation and the size of what ships are the same with or without them. undra build --release strips the copies it ships, keeps the unstripped ones below build/symbols/ and writes manifest.json there (per image: namespace, core version, schema hash and the image id a report carries). --no-symbols skips all of it.
| Platform | Ships | Symbols to keep (and upload) |
|---|---|---|
| iOS | build/ios/<Ns>Core.xcframework | The app's own dSYM: Xcode's Release configuration already asks for one, and it now contains the Rust frames. Crashlytics and Sentry upload it as for Swift. A static library has no dSYM of its own. |
| Android | build/android/jniLibs/<abi>/lib<ns>.so, stripped | build/symbols/android/<abi>/lib<ns>.so, and native-debug-symbols.zip in the Play Console layout (Crashlytics: unstrippedNativeLibsDir). The libraries carry a GNU build id. |
| Web | build/web/<ns>.wasm | build/symbols/web/<ns>.debug.wasm (the same code with its names), <ns>.wasm.functions.txt (the function map) and <ns>.dwarf.wasm for the browser's debugger. |
| Host (JVM, macOS) | build/host/lib<ns>.{dylib,so} | A .dSYM or .debug file next to it. |
The generated CI workflows upload build/symbols/ next to the app build, so every release keeps the files its crashes need. To read a report by hand, give it to undra symbolicate:
undra symbolicate report.json # a report as onPanic received it, serialised
undra symbolicate --platform android --image-id <build id> 0x2f3a1c
undra symbolicate --dsym App.app.dSYM report.json # iOS: the app's dSYMIt finds the image by imageId in the manifest and prints one line per frame, address symbol (file:line): atos with the dSYM on Apple platforms, llvm-symbolizer on the Android twin, the function map and the debug module on the web.
Built with Bazel, the same files are the symbols output group of a release undra_core, and the example has a test that resolves a real crash with them: symbol files and crash reports under Bazel.
Background runs#
The OS gives an app a window to work in while it is not on screen: iOS BGTaskScheduler, Android WorkManager, a page about to hide. The core spends it on what is worth finishing: the offline queue replays, stale queries refetch, and what waits to be written is written. The platform calls runInBackground with the window it was given; the core runs its background tasks inside it and reports back.
| Report | Meaning |
|---|---|
| Finished | finished: every task was done in time and nothing is pending. Tell the OS it succeeded only then. |
| Work done | replayed queued mutations sent, refetched queries fetched again. |
| Work left | stillPending: queued mutations and unfinished fetches. |
A run returns when its tasks are done, or half a second before the deadline so you can still tell the OS. Whatever a task did stays done: the queue persists per item, a request in flight is the client's and goes on, so a run that is cut short, or cancelled when the OS takes the window back, loses nothing and sends nothing twice. Offline, a run ends at once and says what is pending; it does not hold the window for a network that may not return.
Show the three platforms
// At launch. Registers a processing task (network, minutes) and a refresh task (about 30 s);
// when the app goes to the background with work pending it asks the OS for both.
UndraBackground.register(taskIdentifier: "com.example.todo.undra") {
try UndraTodos.load() // the app may have been launched in the background, without UI
}
// Your own run, for example from a push:
let report = try await core.runInBackground(deadline: 25)// Application.onCreate. UndraWorker loads the core with your loader and runs it for at most
// 9 minutes (WorkManager stops a worker at 10); android-work is an optional module.
UndraWork.configure(loader = { context -> UndraTodos.load(LoadOptions()) })
AndroidPlatformDefaults.install(context, core,
onBackgroundWorkPending = { UndraWork.schedule(this) }) // unique work, network required
val report = core.runInBackground(deadlineMs = 25_000)// Within the page's life: when the page is hidden the runtime reports Background and, if the
// core has work pending, runs it for a second. Opt out with backgroundRun: false.
const report = await core.runInBackground(25_000, { signal });On iOS the identifiers are <id>.processing and <id>.refresh; undra init writes them to Info.plist with the processing and fetch background modes. BackgroundTasks does not run in the simulator: test the handler with the fakes in the Swift package, and on a device with the debugger command Apple documents. On Android the worker returns retry() while work is pending, and the web has no background beyond the page's own life yet: Background Sync in a service worker needs the core inside the worker and is a follow-up.
Your own work joins the run with undra::background::register(ctx, name, pending, run) in Rust: pending says cheaply how much is waiting, run does it and reports progress to the Deadline as it goes. The core's statistics carry background.pending, which is what the platforms read to decide whether a window is worth asking for.
Size of the shipped core#
undra build --platform ios,android --release builds the core with the release-mobile profile of the generated crate: the release profile with the optimiser asked for size (opt-level = "s"), except for the crates the call path runs through (the wire codec, the signals, the runtime and the C ABI), which stay at opt-level = 3 so that a call is not slower. Nothing else changed. The panic strategy is not on the list: a native panic must unwind so the core can contain it and report it (the report above), so panic = "abort", the biggest size lever Rust has, is not used. Line tables stay (the symbol files above), the shipped copies are stripped as before, and the host build, which runs your core's tests and undra bindgen, keeps opt-level = 3.
| Hello-world core | Before (opt-level = 3) | Now (release-mobile) | Gate |
|---|---|---|---|
Android arm64-v8a .so | 966.0 KB | 898.2 KB | at most 1.2 MB, and 5% over the record |
Android x86_64 .so | 1,028.2 KB | 960.8 KB | at most 1.2 MB, and 5% over the record |
| iOS arm64, added to an app | 838.1 KB | 784.1 KB | at most 900 KB, and 5% over the record |
The Android numbers are the stripped, 16 KB aligned file Gradle packages. The iOS number is what the device slice of the XCFramework adds to an app once its link has kept what is reachable and dropped the local symbols; the .a file itself is larger and is not a measure. A bigger core is bigger: the playground, with queries, persistence and several stores, is 2.49 MB for arm64-v8a (2.84 MB at opt-level = 3, 12% less).
When a byte budget is hard, choose size over speed per platform with opt_level in [android] or [ios] of undra.toml. "z" optimises every crate for size, the call path included; "3" is the speed profile, for an app whose own hot code (it is optimised for size by default, like everything outside the call path) should not be. A panic unwinds in all three, and the Xcode and Gradle builds pick the setting up with no flag.
opt_level | Hello world, arm64-v8a | Playground, arm64-v8a | Playground, added to an iOS app | A synchronous call (Android emulator / iOS simulator) |
|---|---|---|---|---|
"3" | 966.0 KB | 2.84 MB | 2.62 MB | 1x |
"s" (the default) | 898.2 KB | 2.49 MB | 2.31 MB | 0.99x / 0.99x |
"z" | 766.5 KB | 2.02 MB | 1.65 MB | 1.58x / 1.33x |
At "z" a 1 KB record or a change-set of 100 signals also costs 13% to 18% more; the default is within the noise on every row the device bench measures. A core of 1.6 MB at the old speed profile is about 1.42 MB at the default and about 1.18 MB at "z". Measure your own: undra build --release prints the size.
The sizes are gated in CI (scripts/native-size.sh: over the design's budget, or more than 5% over the recorded size, fails). To measure your own core, undra build --release prints each library's size at the end. To see where the bytes are, the NDK's tools read the unstripped twin that --release keeps in build/symbols/android/<abi>/:
NDK_BIN=$ANDROID_HOME/ndk/27.2.12479018/toolchains/llvm/prebuilt/<host>/bin
$NDK_BIN/llvm-size -A build/android/jniLibs/arm64-v8a/lib<ns>.so # sections: .text, .eh_frame, .rela.dyn, ...
$NDK_BIN/llvm-nm --print-size --size-sort -C --defined-only \
build/symbols/android/arm64-v8a/lib<ns>.so | tail -40 # the largest functionsWhat is left is mostly the runtime, your core and Rust's standard library: a Rust library that does nothing but catch a panic is about 260 KB stripped, because the standard library's panic and backtrace support comes with it, and the unwind tables that make a contained panic possible are about a tenth of a core. Two linker options save more and are not applied for you: -Wl,--pack-dyn-relocs=android (about 3%; the loader of every Android version you support has to read the packed format, so that is your call) and -Wl,--icf=all (about 0.5%, merges functions with equal bodies, which changes function addresses); set them with RUSTFLAGS if your app can take them.
Debugging into Rust#
| Where | How |
|---|---|
| Xcode | A debug build keeps full DWARF in the core: step from a Swift call into Rust, and set breakpoints by breakpoint set -f todos.rs -l 42. undra init writes an .lldbinit that loads the toolchain's Rust formatters; undra doctor checks it. A bt of the full depth through the core works with them (the file skips pointers in the struct summaries, so a &T argument prints as its address, and keeps the Rust formatters off Swift and Objective-C values, which print as LLDB prints them without it). One limit of Xcode 26.6's LLDB remains in app code: a tuple variant named after the type it carries from the same crate (Pool(native::Pool)) makes LLDB exit (SIGILL) when the value is shown, so name one of the two differently. |
| Android Studio | The Dual (Java + Native) debugger with the unstripped debug .so (the generated Gradle module keeps its symbols in debug); build/symbols/android is the symbol directory for a release reproduction. doctor checks the NDK's LLDB. |
| Chrome | The C/C++ DevTools Support (DWARF) extension reads the debug module the dev build serves; breakpoints in .rs files. A browser extension is not something doctor can check, so it prints the link. |
| Host tests | rust-lldb or CodeLLDB on cargo test. |
The same panic that reaches your crash reporter is in the console during development: with undra dev the report goes to the app attached to the dev server like any port call.