swiftui_native/asyncfn.rs
1//! Swift `async` closures, supplied from Rust.
2//!
3//! `refreshable(action: @Sendable () async -> ())` cannot take an ordinary
4//! function pointer. An async closure's first word is not the function — it is
5//! an **async function pointer**, a two-field record the compiler emits:
6//!
7//! | offset | contents |
8//! |--------|----------|
9//! | `+0` | 32-bit relative pointer to the async function |
10//! | `+4` | the context size the function expects |
11//!
12//! The function itself runs under the async convention: its context arrives in
13//! **x22**, and it returns by tail-calling the continuation stored at `+8` in
14//! that context. An empty `{ }` compiles to exactly two instructions —
15//! `ldr x0, [x22, #8]` then `br x0` — which is the shape the thunks below keep,
16//! with the Rust callback run in between.
17//!
18//! The work happens synchronously and the continuation is called straight away:
19//! this is an async closure that never suspends, which is a real one, not a
20//! stub. Suspension would mean allocating a child context and driving the task
21//! system, and nothing here needs that.
22//!
23//! The thunks below are known to *run*: building `.task` on top of this and
24//! rendering it does reach the Rust callback. What is parity-*verified* is the
25//! value `refreshable` builds, not the closure running — a refresh action only
26//! fires on a pull, which the suite does not perform.
27//!
28//! Like [`crate::keypath`], the record is a `static mut`: its one field is a
29//! 32-bit offset to code in this image, which the heap is too far from.
30
31use core::ffi::c_void;
32
33/// The record an async closure's first word points at.
34#[repr(C, align(4))]
35pub struct FunctionPointer {
36 function: i32,
37 context_size: u32,
38}
39
40impl FunctionPointer {
41 /// An empty record, to be filled by [`prepare`].
42 pub const fn new() -> Self {
43 FunctionPointer { function: 0, context_size: 0 }
44 }
45}
46
47impl Default for FunctionPointer {
48 fn default() -> Self {
49 Self::new()
50 }
51}
52
53/// The context size to declare.
54///
55/// Thirty-two is what the compiler's partial-apply forwarder asks for — an empty
56/// closure's own record says sixteen, but the forwarder is what a call site
57/// actually passes, and asking for more than is used is free.
58const EMPTY_CONTEXT: u32 = 32;
59
60/// Point `record` at `thunk` and arm the thunk to call `action`.
61///
62/// Returns the two words of the closure: the record, and a null context —
63/// exactly what the compiler passes for a closure that captures nothing.
64///
65/// # Safety
66/// `record` must be a `static mut` in this image, and `slot` one of the three
67/// below. Arming a slot twice is allowed and the later action wins — two
68/// `.task` modifiers on two different views share a slot, and both run the
69/// second one's action, which is what the `fn()` version did too.
70pub unsafe fn prepare(
71 record: &'static mut FunctionPointer,
72 slot: usize,
73 action: impl Fn() + Send + Sync + 'static,
74) -> [u64; 2] {
75 unsafe {
76 let (code, target, shim) = thunks(slot);
77 // The thunk `blr`s straight to the target, so the target has to be
78 // executable code taking no arguments — which a boxed closure is not.
79 // Each slot therefore owns a shim that calls through the box beside it.
80 // The action has to be `Send + Sync` because this one is genuinely
81 // async: Swift may resume the task on a cooperative thread, not the
82 // main one, so unlike every other callback here it is not main-thread
83 // by construction.
84 // Last arming wins, as it did when this was a bare `fn` pointer; the
85 // previous action is leaked rather than dropped, because SwiftUI may
86 // already be running it.
87 ACTIONS[slot].store(
88 Box::into_raw(Box::new(Box::new(action) as Box<dyn Fn() + Send + Sync>)),
89 Ordering::Release,
90 );
91 *target = shim;
92 let at = &record.function as *const i32 as *const u8;
93 let delta = code as isize - at as isize;
94 record.function = i32::try_from(delta).expect("async function pointer out of range");
95 record.context_size = EMPTY_CONTEXT;
96 [record as *const FunctionPointer as u64, 0]
97 }
98}
99
100// x22 holds the async context and `[x22, #8]` is the continuation to tail-call
101// on return — which is the whole of what an empty `{ }` compiles to.
102//
103// The prologue is the one the compiler emits for an async function that calls
104// out and then returns. Bit 60 of x29 marks the frame as async so the unwinder
105// walks the context chain rather than the stack; x22 is saved because the
106// continuation is read from it after the call, and x30 because `blr` overwrites
107// it. Leaving any of that out lets the closure run to completion and then kills
108// the process on the way back — which `refreshable` never notices, because a
109// refresh action only fires on a pull, and `task` notices immediately.
110macro_rules! async_thunks {
111 ($($name:ident, $sym:literal, $target:ident;)*) => {$(
112 crate::thunk::runtime_thunk! {
113 /// An `async` closure body that runs a Rust callback and resumes.
114 fn $name = $sym via $target {
115 prologue: [
116 "orr x29, x29, #0x1000000000000000",
117 "sub sp, sp, #0x20",
118 "stp x29, x30, [sp, #0x10]",
119 "str x22, [sp, #0x8]",
120 "add x29, sp, #0x10",
121 ],
122 branch: "blr",
123 epilogue: [
124 "ldr x22, [sp, #0x8]",
125 "ldp x29, x30, [sp, #0x10]",
126 "and x29, x29, #0xefffffffffffffff",
127 "add sp, sp, #0x20",
128 "ldr x0, [x22, #8]",
129 "br x0",
130 ],
131 }
132 }
133 )*};
134}
135
136async_thunks! {
137 async0, "rswift_async0", RSWIFT_ASYNC0;
138 async1, "rswift_async1", RSWIFT_ASYNC1;
139 async2, "rswift_async2", RSWIFT_ASYNC2;
140}
141
142/// Where each slot's action lives.
143///
144/// A slot, not a single static, because a thunk carries no context of its own:
145/// an async closure is a *record* plus a context word, and the record only
146/// holds a 32-bit offset to code. The one way back to the right closure is to
147/// have branched from the right thunk, so there are as many simultaneous
148/// actions as there are thunks — three, one per call site: `refreshable`,
149/// `task`, and `task_id`. Slot 2 used to fall through to slot 1's thunk, so a
150/// view carrying both a `.task` and a `.task(id:)` ran whichever was applied
151/// second, twice.
152///
153/// **Within a slot, the last arming wins.** Two `.task` modifiers on two
154/// different views share slot 1, and both will run the second one's action.
155/// This is what the `fn()` version did as well — it stored the bare pointer in
156/// the same one slot — so nothing regressed, but it is a real ceiling, and the
157/// place to lift it is the context word: a per-closure Swift heap object,
158/// passed as the second word instead of the null below, would let one thunk
159/// serve every call. That needs the context to survive SwiftUI's retain and
160/// release of it, which a Rust box does not.
161static ACTIONS: [AtomicPtr<Box<dyn Fn() + Send + Sync>>; SLOTS] =
162 [const { AtomicPtr::new(core::ptr::null_mut()) }; SLOTS];
163
164use core::sync::atomic::{AtomicPtr, Ordering};
165
166const SLOTS: usize = 3;
167
168/// One shim per slot, since each has to name its own index.
169macro_rules! shims {
170 ($($name:ident = $i:expr;)*) => {$(
171 unsafe extern "C" fn $name() {
172 let f = ACTIONS[$i].load(Ordering::Acquire);
173 if !f.is_null() {
174 unsafe { (**f)() };
175 }
176 }
177 )*};
178}
179
180shims! { call0 = 0; call1 = 1; call2 = 2; }
181
182/// The async thunks, by slot — the code to branch from, the slot to write, and
183/// the shim to write into it.
184fn thunks(slot: usize) -> (*const c_void, *mut *const c_void, *const c_void) {
185 match slot {
186 0 => (async0 as *const c_void, &raw mut RSWIFT_ASYNC0, call0 as *const c_void),
187 1 => (async1 as *const c_void, &raw mut RSWIFT_ASYNC1, call1 as *const c_void),
188 _ => (async2 as *const c_void, &raw mut RSWIFT_ASYNC2, call2 as *const c_void),
189 }
190}
191
192// ── A task's entry, which *does* get a context ──
193//
194// The three slots above exist because a SwiftUI `@ViewBuilder` async closure is
195// handed `(record, context)` and SwiftUI passes back the context it was given —
196// which for a Rust closure is nothing it can own, so the closure has to live in
197// a slot the thunk names by its own address. That is a real ceiling: three
198// simultaneous actions, last arming wins within a slot.
199//
200// `swift_task_create` is not like that. Its fifth argument *is* the closure
201// context, and the entry receives it in **x20** — read off the disassembly of
202// `Task.detached { }`, whose entry starts `ldr x9, [x20, #0x10]` before x20
203// could have been set by anything else. So a task carries its own closure and
204// there is no slot and no ceiling: as many tasks as you like, at once.
205//
206// The frame is the same as above; the one addition is moving x20 into the first
207// argument register before the call, which nothing in the prologue disturbs.
208crate::thunk::runtime_thunk! {
209 /// A `Task`'s entry: run the closure the context names, then resume.
210 fn task_entry = "rswift_task_entry" via RSWIFT_TASK_ENTRY {
211 prologue: [
212 "orr x29, x29, #0x1000000000000000",
213 "sub sp, sp, #0x20",
214 "stp x29, x30, [sp, #0x10]",
215 "str x22, [sp, #0x8]",
216 "add x29, sp, #0x10",
217 "mov x0, x20",
218 ],
219 branch: "blr",
220 epilogue: [
221 "ldr x22, [sp, #0x8]",
222 "ldp x29, x30, [sp, #0x10]",
223 "and x29, x29, #0xefffffffffffffff",
224 "add sp, sp, #0x20",
225 "ldr x0, [x22, #8]",
226 "br x0",
227 ],
228 }
229}
230
231/// How a task's completion is reported back to whoever holds its handle.
232///
233/// A Swift `Task`'s result is delivered by awaiting it, and Rust has nothing to
234/// await from — so the entry signals this instead, and
235/// [`crate::runtime::Task::wait`] blocks on it. Shared by `Arc` between the
236/// handle and the context, so a handle dropped early frees it and a task that
237/// outlives its handle still has somewhere to write.
238pub(crate) struct TaskState {
239 done: std::sync::Mutex<bool>,
240 woken: std::sync::Condvar,
241}
242
243impl TaskState {
244 pub(crate) fn new() -> Self {
245 Self { done: std::sync::Mutex::new(false), woken: std::sync::Condvar::new() }
246 }
247
248 /// Has the task's closure returned?
249 pub(crate) fn is_finished(&self) -> bool {
250 *self.done.lock().expect("task state")
251 }
252
253 /// Block until it has, or until `limit` elapses. `true` if it finished.
254 pub(crate) fn wait(&self, limit: Option<std::time::Duration>) -> bool {
255 let mut done = self.done.lock().expect("task state");
256 match limit {
257 None => {
258 while !*done {
259 done = self.woken.wait(done).expect("task state");
260 }
261 true
262 }
263 Some(limit) => {
264 let deadline = std::time::Instant::now() + limit;
265 while !*done {
266 let left = deadline.saturating_duration_since(std::time::Instant::now());
267 if left.is_zero() {
268 return false;
269 }
270 let (next, timeout) = self
271 .woken
272 .wait_timeout(done, left)
273 .expect("task state");
274 done = next;
275 if timeout.timed_out() && !*done {
276 return false;
277 }
278 }
279 true
280 }
281 }
282 }
283
284 fn finish(&self) {
285 *self.done.lock().expect("task state") = true;
286 self.woken.notify_all();
287 }
288}
289
290/// What a task's context carries.
291///
292/// Laid out like [`crate::abi::ClosureCtx`] and for the same reason: the runtime
293/// treats it as a `HeapObject`, so it needs an `isa` and a refcount, and the
294/// refcount is the immortal pattern so the retains and releases around it are
295/// no-ops. A Rust `Box` would be freed by the first release.
296#[repr(C)]
297struct TaskCtx {
298 isa: *const c_void,
299 refcount: u64,
300 /// The closure, owned. Null once the entry has taken it.
301 body: *mut Box<dyn FnOnce() + Send>,
302 /// The completion signal, as a raw `Arc`. Null once the entry has used it.
303 state: *const TaskState,
304}
305
306/// Contexts whose task has finished, ready to carry another.
307///
308/// Not freed, and that is the point: the runtime releases the closure context
309/// after the task completes, and with the immortal refcount that release is a
310/// no-op *that still reads the header*. So the allocation has to outlive the
311/// task — but only the header does, and everything after it can be reused. The
312/// alternative was a fresh thirty-two bytes leaked per task forever; this bounds
313/// it at the high-water mark of tasks in flight.
314fn retired() -> &'static std::sync::Mutex<Vec<usize>> {
315 static R: std::sync::OnceLock<std::sync::Mutex<Vec<usize>>> = std::sync::OnceLock::new();
316 R.get_or_init(|| std::sync::Mutex::new(Vec::new()))
317}
318
319/// Branch target of [`task_entry`]: run the closure the context names.
320///
321/// # Safety
322/// `ctx` must be a `TaskCtx` from [`task_entry_point`].
323#[unsafe(no_mangle)]
324unsafe extern "C" fn rswift_task_body(ctx: *mut c_void) {
325 let c = unsafe { &mut *(ctx as *mut TaskCtx) };
326
327 let body = core::mem::replace(&mut c.body, core::ptr::null_mut());
328 if !body.is_null() {
329 let body = unsafe { Box::from_raw(body) };
330 // A Rust panic must not unwind into the Swift async frame this was
331 // called from: there is no landing pad there, and what happens instead
332 // is unspecified. Caught and reported, and the task still completes —
333 // which is what a Swift task does when its body throws.
334 if std::panic::catch_unwind(std::panic::AssertUnwindSafe(body)).is_err() {
335 eprintln!("rswift: a detached task's closure panicked; the task completed anyway");
336 }
337 }
338
339 let state = core::mem::replace(&mut c.state, core::ptr::null());
340 if !state.is_null() {
341 // Reclaiming the `Arc` the context was holding, and signalling before
342 // dropping it: a waiter is holding the other reference.
343 let state = unsafe { std::sync::Arc::from_raw(state) };
344 state.finish();
345 }
346
347 retired().lock().expect("retired contexts").push(ctx as usize);
348}
349
350/// The async function pointer and context for a task running `action`.
351///
352/// `swift_task_create` wants the two-field *record*, not the function, and the
353/// context separately.
354///
355/// # Safety
356/// The record is a `static mut` in this image, shared by every task; only its
357/// two fields are written, and they are the same values every time.
358pub(crate) unsafe fn task_entry_point(
359 action: Box<dyn FnOnce() + Send>,
360 state: &std::sync::Arc<TaskState>,
361) -> (*const c_void, *mut c_void) {
362 static mut RECORD: FunctionPointer = FunctionPointer::new();
363 static ONCE: std::sync::Once = std::sync::Once::new();
364 unsafe {
365 ONCE.call_once(|| {
366 RSWIFT_TASK_ENTRY = rswift_task_body as *const c_void;
367 let record = crate::static_mut(&raw mut RECORD);
368 let at = &record.function as *const i32 as *const u8;
369 let delta = task_entry as *const u8 as isize - at as isize;
370 record.function =
371 i32::try_from(delta).expect("async function pointer out of range");
372 record.context_size = EMPTY_CONTEXT;
373 });
374
375 let body = Box::into_raw(Box::new(action));
376 let state = std::sync::Arc::into_raw(std::sync::Arc::clone(state));
377
378 let reused = retired().lock().expect("retired contexts").pop();
379 let ctx = match reused {
380 Some(addr) => addr as *mut TaskCtx,
381 None => Box::leak(Box::new(TaskCtx {
382 isa: crate::abi::immortal_isa(),
383 refcount: crate::abi::IMMORTAL_REFCOUNT,
384 body: core::ptr::null_mut(),
385 state: core::ptr::null(),
386 })) as *mut TaskCtx,
387 };
388 // Only the payload is written; the header a reused context already has
389 // is the one the runtime may still be releasing against.
390 (*ctx).body = body;
391 (*ctx).state = state;
392
393 (&raw const RECORD as *const c_void, ctx as *mut c_void)
394 }
395}