惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

博客园 - 叶小钗
雷峰网
雷峰网
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Martin Fowler
Martin Fowler
MyScale Blog
MyScale Blog
博客园 - 聂微东
有赞技术团队
有赞技术团队
The Cloudflare Blog
T
Tailwind CSS Blog
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
月光博客
月光博客
T
The Blog of Author Tim Ferriss
D
Docker
L
LangChain Blog
Vercel News
Vercel News
C
Check Point Blog
博客园 - Franky
博客园 - 三生石上(FineUI控件)
Recent Announcements
Recent Announcements
H
Hackread – Cybersecurity News, Data Breaches, AI and More
量子位
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
V
V2EX
人人都是产品经理
人人都是产品经理

Lobsters

CIFSwitch: a non-universal Linux local root vulnerability RIPE NCC session fixation: poaching logins with an Atlas probe GNOME 2.20 but its Web Components Agentic Search for Context Engineering – Leonie Monigatti Garnix is shutting down [not OC] akashina.tngl.sh/jjc Concerning Emacs (and Jazz) Nitpicking the shell history scene in ‘Tron: Legacy’ What's cooking on SourceHut? Q2 2026 The tenth OpenPGP email summit Package managers that package package managers Clojure on Fennel part three: parsing WordPress at 23 Finding Miscompiles for Fun, Not Profit GitHub - creusot-rs/creusot: Creusot helps you prove your Rust code is correct. Announcing Rust 1.96.0 | Rust Blog A Love Letter to Neovim sqlite AGENTS.md Am I a Bad Friend? CSS vs. JavaScript • Josh W. Comeau Erlang Ecosystem Foundation - Supporting the BEAM community A brief note about slot access cost in Common Lisp Keyboard latency probe Rethinking the GNOME clipboard issues Back to the Building Blocks’ Building Blocks Tech Notes: Theseus: translating win32 to wasm Fast is better than slow Content-addressed Rust builds (or, what kache actually caches) Intent to Prototype: Embedding API Canada’s Bill C-22 and the security cost of collecting more data
Building an AsyncIO executor for the 3DS (pt 2!)
blog.cat-gir · 2026-05-26 · via Lobsters

in the first part, we learned how to build a minimum executor to drive some futures. now let's do that on a 3DS!

the 3ds operating system

the 3ds runs a microkernel-based operating system called Horizon. drivers for peripherals are just slightly special processes: to interface with peripherals, we use Inter-Process Communication (IPC) to send these processes messages.

additionally, the 3ds provides a series of synchronization objects: things that a thread can wait for.

these two things are the basic pieces we need to build an asyncio executor!

so here we go:

components!

the executor itself

we're making a very minimal executor: its job is only to spawn futures and then get told to poll them.

initially, i used IPC for this - including sending rust future objects through IPC, which was a fun challenge - but this has a big problem: sending an IPC request to the same thread that's currently running breaks things very badly. considering futures can ideally wake each other up, this made lockups frequent.

instead, our executor will have three pieces:

  • a queue for requests to spawn new tasks
  • a queue to wake up existing tasks
  • a loop that waits for items to be added to either queue

here's our queues:

pub static TASK_QUEUE: OnceLock<SyncQueue<BoxFuture<'static, ()>>> = OnceLock::new();
pub static WAKE_QUEUE: OnceLock<SyncQueue<TaskToken>> = OnceLock::new();

these use the 3ds built-in semaphore primitive, to let other tasks signal us to wake up! we can wait on several semaphores at once and get woken up when any one of them is signalled:

let handles = [self.task_semaphore, self.wake_semaphore];
loop {
    let mut idx = 0;
    unsafe { svcWaitSynchronizationN(&mut idx, handles.as_ptr(), 2, false, i64::MAX) };
    if idx == 1 { // this is the wake semaphore!
        while let Some(task) = WAKE_QUEUE.get().unwrap().remove() {
            self.wake(task);
        }
    } else if idx == 0 { // this is the spawn semaphore!
        while let Some(task) = TASK_QUEUE.get().unwrap().remove() {
            self.spawn(task);
        }
    }
}
an aside: wakers

in our previous post, our Waker that we passed to futures was an Arc'd object containing both an id for the current task and a sender channel that linked up to the executor. we can trim this down a lot now!

to make a non-Arc'd waker, we can use the RawWaker type, which is constructed from (a) a pointer to some data, and (b) a set of pointers to functions that take that data as an argument to wake a task.

usually, the pointer is just to an Arc or a similar type. but pointers are just integers, and we're using integers to ID our tasks anyway... so we can just pass our task id as the "data pointer" and cast it back to a u32!

// snip: <impls for clone_waker, wake_by_ref, drop>

pub struct TaskToken(pub u32);

unsafe fn wake(data: *const ()) {
    WAKE_QUEUE.get().unwrap().add(TaskToken(data as u32));
}

static VTABLE: RawWakerVTable = RawWakerVTable::new(clone_waker, wake, wake_by_ref, drop);

pub fn make_waker(task: TaskToken) -> Waker {
    unsafe { Waker::from_raw(RawWaker::new(task.0 as *const (), &VTABLE)) }
}

sleeping & timers

we have an executor now! let's make it do something useful, like sleeping.

to implement this, we want to run a thread that:

  • receives requests to set up new timers for tasks,
  • waits for existing timers to complete, and wakes the tasks that asked for them

to run both concurrently, we can do a fun trick. remember how we're using the 3ds' Inter-Process-Communication (IPC) system instead of standard channels?

it turns out, the same system call that listens for IPC requests can also listen for.. anything that can be waited on in the 3ds' operating system? events, mutexes, timers: it can do it all.

implementing it (with some healthy abstraction)
struct ReactorHandler {
    wait_tokens: LiteMap<OSHandle, Vec<TaskToken>>,
}

impl IPCServerHandler<ReactorCmd, ReactorReply> for ReactorHandler {
    // handles IPC requests
    fn handle_request(
        &mut self,
        request: ReactorCmd,
        server: &mut IPCServer<ReactorCmd, ReactorReply>,
    ) -> ReactorReply {
        match request {
            ReactorCmd::AddHandle(task, handle) => {
                self.wait_tokens.entry(handle).or_default().push(task);
                server.add_handle_to_list(handle);
                ReactorReply::Ok
            }
            ReactorCmd::PopHandle => todo!(),
        }
    }

    // handles non-IPC (any other synchronization object)
    fn handle_additional_oshandle(
        &mut self,
        handle: OSHandle,
        _server: &mut IPCServer<ReactorCmd, ReactorReply>,
    ) {
        for token in self.wait_tokens.remove(&handle).unwrap() {
            WAKE_QUEUE.get().unwrap().add(token);
        }
    }
}

input, output!

files and sockets is where it gets a little bit more complicated. operations on both are implemented as IPC calls, and these are always synchronous: we can't convert an ongoing operation into a synchronization object that we wait on concurrently with other things; it's more akin to a normal function call.

instead, we use a thread pool: file operations will force a thread to yield if they aren't ready, so we can use them without hogging CPU.

so to do a file operation, we submit it to a thread, alongside the id for our task. the worker then picks it up, and uses the id for our task to wake us up when the operation is done!

and let's implement it

more specifically, we send a message containing the file's operating-system handle (a file descriptor, for unix people), the id of our task, a couple of pointers where the worker will store the result of the operation, and a pointer to the buffer where the actual data will be stored.

#[derive(IPCMessage)]
#[repr(u32)]
pub(crate) enum AsyncFsMsg {
    Write(#[flatten] FileIoOperation) = 0xA,
    Read(#[flatten] FileIoOperation) = 0xB,
    Flush(#[flatten] FileControlOperation) = 0xC,
    Close(#[flatten] FileControlOperation) = 0xD,
}

#[derive(IPCSerializable)]
pub(crate) struct FileIoOperation {
    #[normal]
    pub file: u32,
    #[normal]
    pub task: TaskToken,
    #[normal]
    pub state: u32, // atomici32 ptr (result code),
    #[normal]
    pub result: u32, // atomicu32 ptr (u32::MAX if not done, else bytes read)
    #[normal]
    pub offset: u32,
    #[normal]
    pub len: u32,
    #[normal]
    pub data_ptr: u32,
}

impl FileIoOperation {
    fn view<'a>(&'a self) -> FileIoOperationView<'a> {
        FileIoOperationView {
            file: ManuallyDrop::new(unsafe { FileHandle::from_raw(self.file) }),
            task: self.task,
            state: unsafe { AtomicI32::from_ptr(self.state as *mut i32) },
            result: unsafe { AtomicU32::from_ptr(self.result as *mut u32) },
            offset: self.offset,
            data: unsafe {
                std::slice::from_raw_parts(self.data_ptr as *const u8, self.len as usize)
            },
        }
    }
}

on the worker side:

match task {
    AsyncFsMsg::Write(op) => {
        info!("io.worker.{} write fd:{:x}", self.id, op.file);

        let mut op = op.view();
        let res: BunnyResult<usize> =
            op.file
                .write(op.offset as u64, op.data, super::WriteOptions::empty());
        op.resolve(res);
        self.executor.wake(op.task).unwrap();
    },
    _ => ...
}

and our future:

impl<'a> Future for Write<'a> {
    type Output = BunnyResult<usize>;

    fn poll(
        mut self: std::pin::Pin<&mut Self>,
        cx: &mut std::task::Context<'_>,
    ) -> std::task::Poll<Self::Output> {
        let bytes_read = self.bytes_written.load(Ordering::Acquire);
        if bytes_read == u32::MAX && !self.registered {
            if let Err(e) = self.client.request(&AsyncFsMsg::Write(FileIoOperation {
                file: self.file.inner.session,
                task: TaskToken::from_waker(cx.waker()),
                state: self.state.as_ptr() as u32,
                result: self.bytes_written.as_ptr() as u32,
                offset: self.offset,
                len: self.data.len() as u32,
                data_ptr: self.data.as_ptr() as u32,
            })) {
                Poll::Ready(Err(e.into()))
            } else {
                self.registered = true;
                Poll::Pending
            }
        } else if self.registered && bytes_read != u32::MAX {
            state_resolve(&self.state, bytes_read as usize)
        } else {
            Poll::Pending
        }
    }
}

locks

if we wanted to implement an async-only lock or mutex, that's easy: we just keep a list of tasks that are waiting on the lock. to lock it, we add ourselves to the list; to unlock it, we wake up the next task up on the list if there is one.

making a lock that can interface both synchronous and asynchronous code is a little bit trickier. my approach has been to modify libctru's LightLock, which uses a lightweight synchronization primitive called an AddressArbitrator to signal threads.

the full code is a little bit bulky to put on here, but here's the highlights. if you want to see the full impl, it lives here

impl DsLock {
    // if we want to use this from a non-async contest, we use TaskToken(u32::MAX) to mark that
    fn try_lock_and_register(&self, token: TaskToken) -> bool {
        let state = self.arb.0.load(Ordering::Acquire);
        if state > 0 {
            self.arb.0.store(-state, Ordering::Release);
            true
        } else {
            self.arb.0.store(state - 1, Ordering::Release);
            self.waiters.lock().push(token);
            false
        }
    }
    
    unsafe fn unlock(&self) {
        let lock_state = self.arb.0.load(Ordering::Acquire);
        if lock_state < 0 {
            self.arb.0.store(-lock_state, Ordering::Release);
            let mut waiters = self.waiters.lock();
            if waiters.is_empty() {
                return;
            }

            // get the next task to wake up...
            let task = waiters.remove(0);
            drop(waiters);

            if task == TaskToken(u32::MAX) {
                // it's a non-async one, signal it using the AddressArbitrator
                let _ = self.arb.signal_one();
            } else {
                // it's an async task, wake it up using the executor
                executor::WAKE_QUEUE.get().unwrap().add(task);
            }
        }
    }
}

putting it all together

here's a tiny example, using egui_citro3d (originally by github.com/LexiBigCheese, with some modifications by me).

it combines an asynchronous TCP socket with a blocking UI task, to display messages sent on the screen!

// full example: https://github.com/kore-signet/bunnyds-egui-example

let server = AsyncTcpSocket::bind("0.0.0.0:3027")?;
let client = server.accept().await?;

let messages: Arc<DSMutex<Vec<String>>> = Arc::new(DSMutex::new(Vec::new()));
let messages_tx: Arc<DSMutex<Vec<String>>> = Arc::clone(&messages);

let message_task = bunnyds::spawn(async move {
    let mut rd_buf = [0u8; 1024];
    while let Ok(bytes_read) = client.recv(&mut rd_buf).await {
        if bytes_read == 0 {
            return;
        }

        messages_tx
            .lock()
            .await
            .push(String::from_utf8_lossy(&rd_buf[..bytes_read]).into());
    }
});

let ui_task = bunnyds::spawn_blocking(Some(0x18), Some(2), move || {
    let mut hid = Hid::new().unwrap();
    let apt = Apt::new().unwrap();
    let gfx = Gfx::new().unwrap();
    let mut egui_ctx = EguiRenderer::new(&mut hid, &gfx, &apt, &key_mapping);
    while apt.main_loop() {
        gfx.wait_for_vblank();
        egui_ctx.render_frame(
            |ui| {
                egui::CentralPanel::default().show_inside(ui, |ui| {
                    let messages = messages.lock_sync();
                    for message in messages.iter() {
                        ui.label(message.trim());
                    }
                });
            },
            |_ui| {},
        );
    }
});

futures::future::join(ui_task, message_task).await;

this has been a very fun little adventure! if you have questions about this post or writing rust code on the 3ds, feel free to email me at kore at cat-girl.gay.

you can find the full code for the project at github.com/kore-signet/bunnyds.

an additional side-project of this whole thing has been making a library to abstract the 3ds IPC system into rust primitives, which lives here. if you think an article on that would be fun, let me know!

this whole project would not have been possible without: