···7878make_context(): Operation not permitted
7979```
80808181-The root cause is that macOS 12 Monterey returns `EPERM` from `sigaltstack()` when a thread already has an alternate signal stack installed (indicated by `SS_ONSTACK`). `HAVE_POSIX_THREADS` avoids `sigaltstack()` entirely.
8181+The root cause is that macOS 12 Monterey returns `EPERM` from `sigaltstack()` when a thread already has an alternate signal stack installed (indicated by `SS_ONSTACK`). `HAVE_POSIX_THREADS` avoids `sigaltstack()` entirely and works identically on Monterey x86_64, Sequoia arm64, and Linux.
82828383### How `HAVE_POSIX_THREADS` works
8484···110110111111Like SDL threads, the `stack` / `stack_size` arguments are ignored (`(void)stack; (void)stack_size;`). The host OS assigns a default stack to each `pthread_create`'d thread.
112112113113+### Rust OS thread integration — `rb_kernel_lock` / `rb_kernel_unlock`
114114+115115+`thread-posix.c` also exposes two functions that let **any OS thread** (Rust or C) safely call Rockbox firmware FFI without a broker intermediary:
116116+117117+```c
118118+void rb_kernel_lock(void); // acquire g_mutex + set __running_self_entry
119119+void rb_kernel_unlock(void); // clear __running_self_entry + release g_mutex
120120+```
121121+122122+`init_threads()` pre-allocates a `g_external_entry` — a `struct thread_entry` with `state = STATE_RUNNING` and name `"rb_external"`. `rb_kernel_lock()` installs this entry as `__running_self_entry()` while holding `g_mutex`, making the calling OS thread indistinguishable from a real Rockbox thread from the kernel's point of view:
123123+124124+```c
125125+void rb_kernel_lock(void) {
126126+ pthread_mutex_lock(&g_mutex);
127127+ __running_self_entry() = g_external_entry;
128128+}
129129+130130+void rb_kernel_unlock(void) {
131131+ __running_self_entry() = NULL;
132132+ pthread_mutex_unlock(&g_mutex);
133133+}
134134+```
135135+136136+On the Rust side, `crates/server/src/fw_bus.rs` exposes a thin wrapper:
137137+138138+```rust
139139+pub fn with_kernel_lock<T, F: FnOnce() -> T>(f: F) -> T {
140140+ unsafe { rockbox_sys::rb_kernel_lock() };
141141+ let result = f();
142142+ unsafe { rockbox_sys::rb_kernel_unlock() };
143143+ result
144144+}
145145+```
146146+147147+All `fw_bus::run_on_broker` / `send_and_wait` / `send` calls are implemented via `with_kernel_lock` — O(1) overhead, no mpsc round-trip, no 30 s timeout path.
148148+149149+**On SDL builds** `rb_kernel_lock` / `rb_kernel_unlock` are `__attribute__((weak))` no-ops defined in `apps/broker_thread.c`. The SDL build will be migrated to a matching `thread-sdl.c` implementation separately.
150150+113151---
114152115153## The Headless Scheduler (Android cdylib)
···118156119157### Three-way comparison
120158121121-| Concern | SDL hosted | Headless macOS/Linux | Android cdylib |
122122-| -------------------- | ------------------------------------ | ---------------------------------------------- | ------------------------------------------------------- |
123123-| Autoconf define | `HAVE_SDL_THREADS` | `HAVE_POSIX_THREADS` | `HAVE_SIGALTSTACK_THREADS` |
124124-| Backend file | `thread-sdl.c` | `thread-posix.c` | `thread-unix.c` |
125125-| Cooperative token | `SDL_mutex *m` (single global) | `pthread_mutex_t g_mutex` (single global) | `__cores[0].running` (global current-thread pointer) |
126126-| Per-thread blocking | `SDL_sem *` (SDL semaphore) | `posix_sem_t *` (cond+mutex+count) | `setjmp`/`longjmp` + signal alt-stack |
127127-| Thread creation | `SDL_CreateThread` | `pthread_create` + `pthread_detach` | Bootstrapped via `sigaltstack()` + `SIGUSR1` |
128128-| Scheduler yield | `SDL_UnlockMutex` / `SDL_LockMutex` | `pthread_mutex_unlock` / `pthread_mutex_lock` | `longjmp` to next thread's `jmp_buf` |
129129-| Boot path | SDL event thread initialises audio | Zig linker entry → `main_c()` | `rb_daemon_start` (JNI) spawns `rockbox-engine` pthread |
130130-| Power-off | `SDL_QUIT` event loop | `exit(0)` via `power_off()` | `exit(0)` via `power_off()` |
131131-| Stack size respected | No (SDL default, typically 8 MB) | No (pthread default) | Yes — passed to signal alt-stack |
132132-| macOS 12 x86_64 | Works | Works | **Broken** — `sigaltstack()` returns `EPERM` |
133133-| stdio | terminal / controlled | stderr via `debug-headless.c` | piped to logcat via `redirect_stdio_to_logcat` |
159159+| Concern | SDL hosted | Headless macOS/Linux | Android cdylib |
160160+| -------------------- | ------------------------------------ | ------------------------------------------------------ | ------------------------------------------------------- |
161161+| Autoconf define | `HAVE_SDL_THREADS` | `HAVE_POSIX_THREADS` | `HAVE_SIGALTSTACK_THREADS` |
162162+| Backend file | `thread-sdl.c` | `thread-posix.c` | `thread-unix.c` |
163163+| Cooperative token | `SDL_mutex *m` (single global) | `pthread_mutex_t g_mutex` (single global) | `__cores[0].running` (global current-thread pointer) |
164164+| Per-thread blocking | `SDL_sem *` (SDL semaphore) | `posix_sem_t *` (cond+mutex+count) | `setjmp`/`longjmp` + signal alt-stack |
165165+| Thread creation | `SDL_CreateThread` | `pthread_create` + `pthread_detach` | Bootstrapped via `sigaltstack()` + `SIGUSR1` |
166166+| Scheduler yield | `SDL_UnlockMutex` / `SDL_LockMutex` | `pthread_mutex_unlock` / `pthread_mutex_lock` | `longjmp` to next thread's `jmp_buf` |
167167+| Rust OS thread FFI | weak no-op stubs (broker still used) | `rb_kernel_lock` / `rb_kernel_unlock` (direct, O(1)) | fw_bus broker channel |
168168+| Boot path | SDL event thread initialises audio | Zig linker entry → `main_c()` | `rb_daemon_start` (JNI) spawns `rockbox-engine` pthread |
169169+| Power-off | `SDL_QUIT` event loop | `exit(0)` via `power_off()` | `exit(0)` via `power_off()` |
170170+| Stack size respected | No (SDL default, typically 8 MB) | No (pthread default) | Yes — passed to signal alt-stack |
171171+| macOS 12 x86_64 | Works | Works | **Broken** — `sigaltstack()` returns `EPERM` |
172172+| stdio | terminal / controlled | stderr via `debug-headless.c` | piped to logcat via `redirect_stdio_to_logcat` |
134173135174### `__cores[0].running` — the critical invariant
136175137137-On the Android cdylib (and every non-SDL Rockbox target), the kernel tracks the currently-executing kernel thread via the global `__cores[0].running` pointer. **Any firmware function that calls `queue_send`, `wakeup_thread`, `pcmbuf_*`, or any kernel primitive reads this pointer to find its own thread entry.**
176176+On every Rockbox target the kernel tracks the currently-executing kernel thread via `__running_self_entry()`, which expands to `__cores[0].running` — a plain C global, **not TLS**. Any firmware function that calls `queue_send`, `wakeup_thread`, `pcmbuf_*`, or any kernel primitive reads this pointer to find its own thread entry.
138177139139-On the headless POSIX target (`HAVE_POSIX_THREADS`) the same invariant holds: `__running_self_entry()` expands to `__cores[0].running` and is updated by every thread at the top of `runthread()` and inside `switch_thread()`. The global `g_mutex` guarantees only one Rockbox kernel thread writes this pointer at a time — but OS threads that bypass `g_mutex` (actix workers, gRPC handlers) will still see a stale value.
178178+On the headless POSIX target `g_mutex` guarantees that only one thread writes this pointer at a time. `rb_kernel_lock()` extends this guarantee to arbitrary Rust OS threads: by acquiring `g_mutex` and installing `g_external_entry`, it ensures `__running_self_entry()` is always valid while any firmware FFI is in flight.
140179141141-If such a function is called from a non-Rockbox pthread (e.g. an actix worker or a tonic gRPC handler) the pointer resolves to the wrong thread entry. Consequences:
142142-- `wakeup_thread_` dereferences a stale or null function pointer → **SIGSEGV at PC=0**
143143-- Kernel scheduler state is silently corrupted
144144-- Symptoms may appear seconds later, during a track switch or settings change
180180+On other builds (SDL, Android cdylib) an external OS thread calling firmware FFI without the correct `__running_self_entry()` will cause:
181181+- `wakeup_thread_` to dereference a stale or null function pointer → **SIGSEGV at PC=0**
182182+- Silent kernel scheduler corruption
183183+- Symptoms appearing seconds later during a track switch or settings change
145184146146-This is why **all firmware-mutating calls from Rust handlers must go through the firmware-command bus** (see below).
185185+This is why those builds still route firmware-mutating calls through the fw_bus broker.
147186148187### Daemon boot sequence (Android)
149188···161200162201## The Firmware-Command Bus (`crates/server/src/fw_bus.rs`)
163202164164-### Problem
203203+### Headless target (current)
204204+205205+On the headless POSIX target all `fw_bus` functions execute the firmware call **inline on the calling OS thread** under the Rockbox cooperative lock:
206206+207207+```
208208+actix worker / tonic task
209209+ → fw_bus::run_on_broker(|| rb::playback::play(elapsed, offset))
210210+ → rb_kernel_lock() ← acquire g_mutex, install g_external_entry
211211+ → rb::playback::play() ← runs safely: __running_self_entry() is valid
212212+ → rb_kernel_unlock() ← clear entry, release g_mutex
213213+ ← returns immediately
214214+```
165215166166-gRPC / HTTP handlers run on actix workers or tonic tasks — plain Rust OS threads. Calling firmware FFI directly from these threads violates the `__cores[0].running` invariant and causes the SIGSEGV / scheduler corruption described above. This affects both the Android cdylib and any future hosted-pthread build.
216216+There is no channel, no broker round-trip, and no timeout. The cost is one `pthread_mutex_lock` + one `pthread_mutex_unlock`.
167217168168-### Solution
218218+### SDL / Android broker path (legacy)
169219170170-All kernel-mutating calls are serialised through a single `std::sync::mpsc` channel. **Only the broker thread drains this channel.** The broker IS a real Rockbox kernel thread (spawned by `apps/broker_thread.c::create_thread`), so its firmware calls always run with a valid `__running_self_entry()`.
220220+On SDL and Android cdylib builds `rb_kernel_lock` / `rb_kernel_unlock` are no-ops (weak stubs). Those builds serialise firmware calls through the old mpsc broker channel instead:
171221172222```
173223actix worker / tonic task
···176226 → broker thread (real Rockbox kernel thread)
177227 → rb::playback::play(elapsed, offset)
178228 → reply_tx.send(())
179179- ← fw_bus::send_and_wait blocks until reply arrives (≤ 30 s)
229229+ ← send_and_wait blocks until reply arrives (≤ 30 s)
180230```
231231+232232+The broker is a real Rockbox kernel thread (spawned by `apps/broker_thread.c::create_thread`), so its firmware calls always run with a valid `__running_self_entry()`.
181233182234### API
183235184184-| Function | Use case |
185185-| ------------------------------ | -------------------------------------------------------------------- |
186186-| `fw_bus::init()` | Call once at startup, before broker thread spawns |
187187-| `fw_bus::send(cmd)` | Fire-and-forget (no reply needed) |
188188-| `fw_bus::send_and_wait(make)` | Send + block until broker confirms (for actix `web::block` handlers) |
189189-| `fw_bus::run_on_broker(f)` | Run arbitrary closure on broker; returns `T::default()` on timeout |
190190-| `fw_bus::try_run_on_broker(f)` | Same but returns `Option<T>` — `None` on timeout |
191191-| `fw_bus::drain(rx)` | Called once per broker iteration to execute pending commands |
236236+| Function | Headless behaviour | SDL/Android behaviour |
237237+| ------------------------------- | ----------------------------------------------- | -------------------------------------------------- |
238238+| `fw_bus::init()` | No-op (channel kept for compat, never drained) | Creates mpsc channel before broker spawns |
239239+| `fw_bus::with_kernel_lock(f)` | Acquires `g_mutex`, runs `f`, releases | Runs `f` without any lock (unsafe on those builds) |
240240+| `fw_bus::run_on_broker(f)` | `with_kernel_lock(f)` — inline, no channel | Sends `FwCmd::Custom` to broker, blocks ≤ 30 s |
241241+| `fw_bus::try_run_on_broker(f)` | `Some(with_kernel_lock(f))` — never None | `None` on 30 s broker timeout |
242242+| `fw_bus::send_and_wait(make)` | Inline under lock; reply channel is a no-op | Sends to broker, waits for reply ≤ 30 s |
243243+| `fw_bus::send(cmd)` | Executes immediately under lock | Fire-and-forget enqueue to broker |
244244+| `fw_bus::drain(rx)` | No-op | Drains pending commands from broker queue |
245245+| `fw_bus::drain_blocking(rx, t)` | No-op | Blocks up to `t` for first command, then drains |
246246+| `fw_bus::take_receiver()` | Returns `None` | Takes the `Receiver` for the broker to own |
192247193248### `FwCmd` variants
194249195250`Play`, `Pause`, `Resume`, `Next`, `Prev`, `Stop`, `FfRewind`, `FlushAndReloadTracks`, `SetCrossfade`, and `Custom(Box<dyn FnOnce() + Send>)` (escape hatch for anything not enumerated).
196251197197-### Timeout behaviour
198198-199199-The broker tick period is ~10 ms at idle (the `rb::system::sleep(HZ/10)` in the broker loop). `BROKER_TIMEOUT` is 30 s — generous because building a large playlist can take several seconds. On timeout `run_on_broker` logs a warning and returns `T::default()` rather than panicking; `try_run_on_broker` returns `None`.
200200-201252### Where fw_bus is NOT needed
202253203254- Read-only status queries (`rb::playback::status()`, `rb::playback::current_track()`) — these only read atomics or copy structs; no kernel primitive is called.
204204-- Anything running inside the broker thread itself (it already owns `__running_self_entry()`).
205205-- Desktop SDL builds — the SDL mutex serialises everything anyway. The bus is still compiled in and works correctly; it just adds one channel hop of latency.
255255+- Anything running inside a real Rockbox kernel thread (it already owns `__running_self_entry()`).
206256207257---
208258···213263| Thread | C entry point | What it does |
214264| --------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------- |
215265| `server_thread` | `server_thread.c` → `start_server()` | Spawns the actix HTTP server in a Rust OS thread, then loops yielding to the Rockbox scheduler |
216216-| `broker_thread` | `broker_thread.c` → `start_broker()` | Event loop: drains `fw_bus`, publishes playback state to GraphQL subscriptions, scrobbles tracks, restores playlist |
266266+| `broker_thread` | `broker_thread.c` → `start_broker()` | Event loop: publishes playback state to GraphQL subscriptions, scrobbles tracks, restores playlist on first tick. On SDL/Android also drains fw_bus. |
217267218218-Both threads must call `rb::system::sleep(rb::HZ)` (desktop) or equivalent on every loop iteration.
268268+Both threads must call `rb::system::sleep(rb::HZ)` (or equivalent) on every loop iteration.
219269220270**`server_thread` pattern** (`crates/server/src/lib.rs`):
221271```rust
···240290**`broker_thread` pattern** (`crates/server/src/lib.rs`):
241291```rust
242292pub extern "C" fn start_broker() {
293293+ // take_receiver() returns None on headless (no channel to drain).
243294 let fw_rx = fw_bus::take_receiver();
244295 loop {
245245- // Drain firmware commands from actix/gRPC handlers first.
246296 if let Some(rx) = &fw_rx {
247247- fw_bus::drain(rx);
297297+ fw_bus::drain(rx); // SDL/Android only — no-op on headless
248298 }
249299 // ... publish events, scrobble, restore playlist ...
250300 thread::sleep(std::time::Duration::from_millis(100));
···255305256306### Rust OS threads (no Rockbox scheduler involvement)
257307258258-These are spawned with `std::thread::spawn` — they are pure OS threads and never interact with the Rockbox scheduler token or `__cores[0].running`.
308308+These are spawned with `std::thread::spawn` — they are pure OS threads and never interact with the Rockbox scheduler token.
259309260310| Thread / component | Spawned from | Runtime |
261311| ---------------------------------------------------------------------- | ------------------------------------------ | --------------------------------------------------------- |
···282332283333## Startup Sequences
284334285285-### Desktop (SDL hosted)
335335+### Headless (`librockboxd.a` — GPUI desktop client, embedded daemon)
336336+337337+```
338338+Zig entry → main_c()
339339+│
340340+├── server_init() → create_thread(server_thread) ← Rockbox kernel thread
341341+│ └── start_server()
342342+│ ├── load_settings()
343343+│ ├── rockbox_upnp::init()
344344+│ ├── thread::spawn(actix HTTP server) ← Rust OS thread (free to block)
345345+│ └── loop { sleep + rb::system::sleep(HZ) } ← yields g_mutex
346346+│
347347+├── sleep(HZ)
348348+│
349349+└── start_servers() ← called on main C thread
350350+ ├── fw_bus::init() ← no-op on headless
351351+ ├── thread::spawn(gRPC server) ← Rust OS thread
352352+ │ └── handlers call fw_bus::run_on_broker(f)
353353+ │ → rb_kernel_lock() + f() + rb_kernel_unlock() ← inline, O(1)
354354+ ├── thread::spawn(GraphQL server)
355355+ ├── thread::spawn(MPD server)
356356+ └── thread::spawn(command relay)
357357+358358+broker_init() → create_thread(broker_thread) ← Rockbox kernel thread
359359+└── start_broker()
360360+ ├── fw_bus::take_receiver() → None ← no channel on headless
361361+ └── loop { publish events + scrobble + sleep + rb::system::sleep(HZ) }
362362+```
363363+364364+### Desktop SDL (`rockboxd` binary)
286365287366```
288367main.c: server_init()
···292371│ ├── load_settings()
293372│ ├── rockbox_upnp::init() ← creates static UPnP multi-thread runtime
294373│ ├── thread::spawn(actix) ← Rust OS thread (free to block)
295295-│ └── loop { sleep + rb::system::sleep(HZ) } ← yields Rockbox mutex
374374+│ └── loop { sleep + rb::system::sleep(HZ) } ← yields SDL mutex
296375│
297297-├── sleep(HZ) ← Rockbox scheduler sleep on main thread (~1 s)
376376+├── sleep(HZ)
298377│
299378└── start_servers() ← called on main C thread after 1 s
300379 ├── fw_bus::init() ← create mpsc channel before broker spawns
301380 ├── thread::spawn(gRPC server) ← Rust OS thread, new_current_thread runtime
302302- ├── thread::spawn(GraphQL server) ← Rust OS thread, new_current_thread runtime
303303- ├── thread::spawn(MPD server) ← Rust OS thread, new_current_thread runtime
304304- ├── thread::spawn(MPRIS, Linux) ← Rust OS thread, async_std
305305- └── thread::spawn(command relay) ← Rust OS thread, reqwest blocking
381381+ ├── thread::spawn(GraphQL server)
382382+ ├── thread::spawn(MPD server)
383383+ ├── thread::spawn(MPRIS, Linux)
384384+ └── thread::spawn(command relay)
306385307386main.c: broker_init()
308387│
···472551473552`rockbox_graphql::simplebroker::SimpleBroker` uses `futures_channel::mpsc::UnboundedSender` for pub/sub. `publish()` is a synchronous call — it does not require or interact with any tokio runtime. It is safe to call from any thread, including Rockbox kernel threads and scanner threads with their own runtimes.
474553475475-### Rule 6: All firmware-mutating FFI from Rust handlers must go through `fw_bus`
554554+### Rule 6: On headless, use `fw_bus::run_on_broker` / `with_kernel_lock` for firmware-mutating FFI from Rust handlers
476555477477-On the headless pthread target (Android cdylib) calling `rb::playback::play`, `rb::sound::set_volume`, or any function that reaches `queue_send` / `wakeup_thread` from a non-Rockbox pthread corrupts `__cores[0].running` and causes a SIGSEGV. Route every such call through `fw_bus::run_on_broker(|| ...)` from inside a `web::block(...)` closure.
556556+On SDL and Android builds, calling `rb::playback::play`, `rb::sound::set_volume`, or any function that reaches `queue_send` / `wakeup_thread` from a non-Rockbox pthread corrupts `__cores[0].running` and causes a SIGSEGV. On those builds these calls must still go through `fw_bus::run_on_broker(|| ...)`.
478557479479-Read-only queries (status, current track) that only touch atomics or copy structs are safe to call directly.
558558+On the headless POSIX target `fw_bus::run_on_broker` uses `rb_kernel_lock` / `rb_kernel_unlock` directly and is safe from any OS thread — no broker intermediary needed.
480559481481-### Rule 7: `fw_bus::init()` must be called before any handler sends a command
560560+Read-only queries (status, current track) that only touch atomics or copy structs are safe to call directly on all builds.
482561483483-`fw_bus::send()` silently drops commands if the bus hasn't been initialised. `init()` is called inside `start_servers()`, which runs before the HTTP/gRPC servers start accepting requests. On Android, the boot sequence guarantees `start_servers()` (inside `main_c()`) completes before `rb_daemon_start` returns.
562562+### Rule 7: `fw_bus::init()` is a no-op on headless but must still be called on SDL/Android
563563+564564+On headless builds `fw_bus::init()` initialises the static channel statics (so `take_receiver` doesn't panic) but the channel is never drained. On SDL/Android it must be called before any handler sends a command — `fw_bus::send()` silently drops commands if the channel hasn't been created.
484565485566### Rule 8: Never re-enable `HAVE_SIGALTSTACK_THREADS` for the headless host
486567···498579```
499580500581and restore it if configure has reverted it to `HAVE_SIGALTSTACK_THREADS`.
582582+583583+### Rule 9: Do not nest `rb_kernel_lock` calls
584584+585585+`g_mutex` is a non-recursive `pthread_mutex_t`. Calling `rb_kernel_lock()` from a thread that already holds it (e.g. from inside a `with_kernel_lock` closure that calls another `fw_bus` function) will deadlock. Flatten the calls: acquire the lock once, do all the firmware work, release it.
···3737 start_broker();
3838}
39394040+/* Fallback stubs for builds that do not include thread-posix.c (e.g. the SDL
4141+ * hosted target). thread-posix.c provides strong definitions that override
4242+ * these via the linker's weak-symbol resolution, so headless builds get the
4343+ * real cooperative-mutex implementation automatically. */
4444+__attribute__((weak)) void rb_kernel_lock(void) {}
4545+__attribute__((weak)) void rb_kernel_unlock(void) {}
4646+4047/** -- Startup -- **/
41484249/* Initialize the broker - called from init() in main.c */
···6161626263636464-/* the threading backend we use */
6565-#define HAVE_SIGALTSTACK_THREADS
6464+/* the threading backend we use.
6565+ * HAVE_POSIX_THREADS: one pthread per Rockbox thread, g_mutex for cooperative
6666+ * exclusion, rb_kernel_lock/rb_kernel_unlock for safe FFI from Rust OS threads.
6767+ * Replaces HAVE_SIGALTSTACK_THREADS (green coroutines in a single OS thread,
6868+ * no global mutex, incompatible with rb_kernel_lock). */
6969+#define HAVE_POSIX_THREADS
66706771/* lcd dimensions for application builds from configure */
6872#define LCD_WIDTH 320
···54545555/* optional defines for RTC mod for h1x0 */
56565757-/* the threading backend we use */
5858-#define HAVE_SIGALTSTACK_THREADS
5757+/* the threading backend we use.
5858+ * HAVE_POSIX_THREADS: one pthread per Rockbox thread, g_mutex for cooperative
5959+ * exclusion, rb_kernel_lock/rb_kernel_unlock for safe FFI from Rust OS threads.
6060+ * Replaces HAVE_SIGALTSTACK_THREADS (green coroutines in a single OS thread,
6161+ * no global mutex, incompatible with rb_kernel_lock). */
6262+#define HAVE_POSIX_THREADS
59636064/* lcd dimensions for application builds from configure */
6165#define LCD_WIDTH 320
···11-//! Firmware-command bus.
22-//!
33-//! On the Android cdylib (and any hosted-pthread build), Rockbox kernel
44-//! state is identified via the global `__cores[0].running` slot — there is
55-//! no thread-local-storage involved. Calling firmware kernel functions
66-//! (`audio_play`, `audio_set_crossfade`, `pcmbuf_*`, anything that
77-//! eventually reaches `queue_send` / `wakeup_thread`) from a non-Rockbox
88-//! pthread (e.g. an actix worker handling a gRPC request) reads/writes
99-//! the wrong thread_entry and corrupts the kernel scheduler. Symptoms:
1010-//! SIGSEGV at PC=0 in `wakeup_thread_` on track switches or settings
1111-//! changes, intermittent kernel coroutine corruption.
1212-//!
1313-//! Solution: serialise every kernel-affecting call through a single
1414-//! mpsc channel that the **broker** thread drains. The broker is a real
1515-//! Rockbox kernel thread (created via `create_thread` from
1616-//! `apps/broker_thread.c`), so its calls into firmware run with a sane
1717-//! `__running_self_entry()`.
1818-//!
1919-//! Synchronous handlers wait on a oneshot reply so they keep their
2020-//! original signatures from the caller's POV.
2121-2222-use std::sync::mpsc::{self, Receiver, Sender, TryRecvError};
2323-use std::sync::Mutex;
2424-use std::sync::OnceLock;
2525-2626-/// Discriminated union of every firmware-mutating call we need to route
2727-/// through the broker. Add a variant when a new handler proves to need it.
2828-pub enum FwCmd {
2929- Play {
3030- elapsed: i64,
3131- offset: i64,
3232- reply: Option<mpsc::SyncSender<()>>,
3333- },
3434- Pause {
3535- reply: Option<mpsc::SyncSender<()>>,
3636- },
3737- Resume {
3838- reply: Option<mpsc::SyncSender<()>>,
3939- },
4040- Next {
4141- reply: Option<mpsc::SyncSender<()>>,
4242- },
4343- Prev {
4444- reply: Option<mpsc::SyncSender<()>>,
4545- },
4646- Stop {
4747- reply: Option<mpsc::SyncSender<()>>,
4848- },
4949- FfRewind {
5050- newtime: i32,
5151- reply: Option<mpsc::SyncSender<()>>,
5252- },
5353- FlushAndReloadTracks {
5454- reply: Option<mpsc::SyncSender<()>>,
5555- },
5656- SetCrossfade {
5757- value: i32,
5858- reply: Option<mpsc::SyncSender<()>>,
5959- },
6060- /// Escape hatch for anything not covered yet. Closure runs on the
6161- /// broker thread; sender is responsible for not panicking inside.
6262- Custom(Box<dyn FnOnce() + Send + 'static>),
6363-}
6464-6565-static SENDER: OnceLock<Sender<FwCmd>> = OnceLock::new();
6666-// Receiver is wrapped in a Mutex<Option> so the broker can `take()` it on
6767-// startup; only one broker should ever exist.
6868-static RECEIVER: OnceLock<Mutex<Option<Receiver<FwCmd>>>> = OnceLock::new();
6969-7070-/// Initialise the channel. Call once at startup, BEFORE the broker thread
7171-/// is spawned and BEFORE any handler tries to `send()`. Idempotent.
7272-pub fn init() {
7373- SENDER.get_or_init(|| {
7474- let (tx, rx) = mpsc::channel();
7575- RECEIVER
7676- .set(Mutex::new(Some(rx)))
7777- .unwrap_or_else(|_| panic!("fw_bus::init called twice"));
7878- tx
7979- });
8080-}
8181-8282-/// The broker thread takes ownership of the receiver on its first iteration.
8383-/// Returns None if init() wasn't called or the receiver was already taken.
8484-pub fn take_receiver() -> Option<Receiver<FwCmd>> {
8585- RECEIVER.get()?.lock().ok()?.take()
8686-}
8787-8888-/// Enqueue a command for the broker to execute on its next tick. Drops
8989-/// silently if the bus isn't initialised (e.g. desktop / non-cdylib build);
9090-/// callers should treat it as a fire-and-forget side effect.
9191-pub fn send(cmd: FwCmd) {
9292- if let Some(tx) = SENDER.get() {
9393- let _ = tx.send(cmd);
9494- }
9595-}
9696-9797-/// How long to wait for the broker to drain a queued command. Generous
9898-/// because building a large playlist (1000+ tracks via `playlist_create` +
9999-/// `build_playlist`) can take several seconds when the kernel is also
100100-/// busy decoding audio. Under rapid user-driven play actions, multiple
101101-/// commands queue up and each subsequent caller waits for the broker to
102102-/// finish the ones ahead of it.
103103-const BROKER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
104104-105105-/// Send a command and block until the broker confirms execution. Use
106106-/// from actix `web::block(...)` callbacks — the wait is bounded by the
107107-/// broker tick (~10 ms idle, immediate when busy).
108108-pub fn send_and_wait(make: impl FnOnce(mpsc::SyncSender<()>) -> FwCmd) {
109109- let (reply_tx, reply_rx) = mpsc::sync_channel::<()>(1);
110110- send(make(reply_tx));
111111- if reply_rx.recv_timeout(BROKER_TIMEOUT).is_err() {
112112- tracing::warn!("fw_bus::send_and_wait: broker timed out (no reply within 30 s)");
113113- }
114114-}
115115-116116-/// Run an arbitrary closure on the broker thread and block until it
117117-/// finishes. Returns the closure's value. Typical use from a handler:
118118-///
119119-/// let ret: i32 = fw_bus::run_on_broker(|| rb::playlist::shuffle(seed, idx));
120120-///
121121-/// The closure runs on a real Rockbox kernel thread, so any firmware
122122-/// FFI it makes resolves `__cores[0].running` correctly. Blocks the
123123-/// caller for up to 30 s; intended to be called from `web::block(...)`.
124124-///
125125-/// On timeout (e.g. the broker is wedged or the queue is backed up under
126126-/// rapid-fire user actions) we **do not** panic — that would unwind the
127127-/// actix worker and surface as a scary 500. Instead we log a warning and
128128-/// return `T::default()`. Callers that care about distinguishing success
129129-/// from a timed-out broker call should use [`try_run_on_broker`] instead.
130130-pub fn run_on_broker<T, F>(f: F) -> T
131131-where
132132- T: Default + Send + 'static,
133133- F: FnOnce() -> T + Send + 'static,
134134-{
135135- try_run_on_broker(f).unwrap_or_else(|| {
136136- tracing::warn!("fw_bus::run_on_broker: broker tick timed out (30 s), returning default");
137137- T::default()
138138- })
139139-}
140140-141141-/// Same as [`run_on_broker`] but returns `None` on timeout instead of a
142142-/// default value. Use when the caller needs to surface the timeout as a
143143-/// proper error (e.g. an HTTP 503).
144144-pub fn try_run_on_broker<T, F>(f: F) -> Option<T>
145145-where
146146- T: Send + 'static,
147147- F: FnOnce() -> T + Send + 'static,
148148-{
149149- let (tx, rx) = mpsc::sync_channel::<T>(1);
150150- send(FwCmd::Custom(Box::new(move || {
151151- let _ = tx.send(f());
152152- })));
153153- rx.recv_timeout(BROKER_TIMEOUT).ok()
154154-}
155155-156156-/// Drain the channel and execute everything pending. Called once per
157157-/// broker iteration. Non-blocking — returns when the queue is empty.
158158-/// Must be called from the broker thread (a real Rockbox kernel thread).
159159-pub fn drain(rx: &Receiver<FwCmd>) {
160160- loop {
161161- match rx.try_recv() {
162162- Ok(cmd) => {
163163- execute_on_broker(cmd, &|| true).unwrap_or_else(|e| {
164164- tracing::warn!("fw_bus: broker exec failed: {e}");
165165- });
166166- }
167167- Err(TryRecvError::Empty) => return,
168168- Err(TryRecvError::Disconnected) => {
169169- tracing::error!("fw_bus: sender disconnected — bus shutdown");
170170- return;
171171- }
172172- }
173173- }
174174-}
175175-176176-/// Like drain() but blocks up to `timeout` waiting for the first command.
177177-/// When a command arrives before the timeout the broker wakes up immediately
178178-/// instead of waiting for the next poll cycle (≤100 ms). Use this in place
179179-/// of `thread::sleep(timeout)` at the bottom of the broker loop.
180180-pub fn drain_blocking(rx: &Receiver<FwCmd>, timeout: std::time::Duration) {
181181- match rx.recv_timeout(timeout) {
182182- Ok(cmd) => {
183183- execute_on_broker(cmd, &|| true).unwrap_or_else(|e| {
184184- tracing::warn!("fw_bus: broker exec failed: {e}");
185185- });
186186- drain(rx);
187187- }
188188- Err(mpsc::RecvTimeoutError::Disconnected) => {
189189- tracing::error!("fw_bus: sender disconnected — bus shutdown");
190190- }
191191- Err(mpsc::RecvTimeoutError::Timeout) => {}
192192- }
193193-}
194194-195195-fn execute_on_broker(cmd: FwCmd, _alive: &dyn Fn() -> bool) -> Result<(), &'static str> {
196196- use rockbox_sys as rb;
197197- macro_rules! reply {
198198- ($r:expr) => {
199199- if let Some(r) = $r {
200200- let _ = r.send(());
201201- }
202202- };
203203- }
204204- match cmd {
205205- FwCmd::Play {
206206- elapsed,
207207- offset,
208208- reply,
209209- } => {
210210- rb::playback::play(elapsed, offset);
211211- reply!(reply);
212212- }
213213- FwCmd::Pause { reply } => {
214214- rb::playback::pause();
215215- reply!(reply);
216216- }
217217- FwCmd::Resume { reply } => {
218218- rb::playback::resume();
219219- reply!(reply);
220220- }
221221- FwCmd::Next { reply } => {
222222- rb::playback::next();
223223- reply!(reply);
224224- }
225225- FwCmd::Prev { reply } => {
226226- rb::playback::prev();
227227- reply!(reply);
228228- }
229229- FwCmd::Stop { reply } => {
230230- rb::playback::hard_stop();
231231- reply!(reply);
232232- }
233233- FwCmd::FfRewind { newtime, reply } => {
234234- rb::playback::ff_rewind(newtime);
235235- reply!(reply);
236236- }
237237- FwCmd::FlushAndReloadTracks { reply } => {
238238- rb::playback::flush_and_reload_tracks();
239239- reply!(reply);
240240- }
241241- FwCmd::SetCrossfade { value, reply } => {
242242- rb::sound::audio_set_crossfade_safe(value);
243243- reply!(reply);
244244- }
245245- FwCmd::Custom(f) => f(),
246246- }
247247- Ok(())
248248-}
···239239240240 web::block(move || {
241241 let _player_mutex = PLAYER_MUTEX.lock().unwrap();
242242- // Build + start a saved playlist — `playlist_start` triggers
243243- // halt_decoding_track / codec_stop in the kernel scheduler.
244244- // Must run on the broker (real Rockbox kernel thread).
245245- crate::fw_bus::run_on_broker(move || {
242242+ rb::with_kernel_lock(move || {
246243 let first = &paths[0];
247244 let dir = {
248245 let parts: Vec<_> = first.split('/').collect();
···18181919pub async fn update_global_settings(body: web::Json<NewGlobalSettings>) -> HandlerResult {
2020 let settings = body.into_inner();
2121- // Route the settings apply through the firmware-command bus — most
2222- // settings just write a value, but `crossfade` calls
2323- // `audio_set_crossfade()` which queues `Q_AUDIO_REMAKE_AUDIO_BUFFER`
2424- // and ends up in the kernel scheduler. From an actix worker the
2525- // scheduler reads the wrong `__cores[0].running` slot and corrupts
2626- // kernel-thread state — see crates/server/src/fw_bus.rs. Run the
2727- // whole load_settings on the broker (a real Rockbox kernel thread)
2828- // so the FFI calls resolve to a sane current-thread.
2921 web::block(move || {
3022 let _player_mutex = PLAYER_MUTEX.lock().unwrap();
3131- crate::fw_bus::send_and_wait(|reply| {
3232- crate::fw_bus::FwCmd::Custom(Box::new(move || {
3333- if let Err(e) = rockbox_settings::load_settings(Some(settings)) {
3434- tracing::error!("update_global_settings: load_settings failed: {e}");
3535- } else if let Err(e) = rockbox_settings::write_settings() {
3636- tracing::error!("update_global_settings: write_settings failed: {e}");
3737- }
3838- let _ = reply.send(());
3939- }))
2323+ rb::with_kernel_lock(move || {
2424+ if let Err(e) = rockbox_settings::load_settings(Some(settings)) {
2525+ tracing::error!("update_global_settings: load_settings failed: {e}");
2626+ } else if let Err(e) = rockbox_settings::write_settings() {
2727+ tracing::error!("update_global_settings: write_settings failed: {e}");
2828+ }
4029 });
4130 })
4231 .await
···13881388 fn plugin_get_buffer();
13891389 fn plugin_get_current_filename();
13901390 fn plugin_reserve_buffer();
13911391+13921392+ // Kernel lock for Rust OS threads (headless hosted only; no-op on SDL)
13931393+ pub fn rb_kernel_lock();
13941394+ pub fn rb_kernel_unlock();
13951395+}
13961396+13971397+/// Execute `f` under the Rockbox cooperative kernel lock and return its value.
13981398+///
13991399+/// Any OS thread (e.g. a tokio/actix worker) may call Rockbox firmware FFI
14001400+/// safely by wrapping the call with this function. On the headless target
14011401+/// this acquires g_mutex so no Rockbox kernel thread runs concurrently, then
14021402+/// installs g_external_entry as the current thread. On SDL builds
14031403+/// `rb_kernel_lock` is a no-op (weak-symbol stub in broker_thread.c).
14041404+pub fn with_kernel_lock<T, F: FnOnce() -> T>(f: F) -> T {
14051405+ unsafe { rb_kernel_lock() };
14061406+ let result = f();
14071407+ unsafe { rb_kernel_unlock() };
14081408+ result
13911409}
···123123124124static jmp_buf thread_jmpbufs[MAXTHREADS];
125125static pthread_mutex_t g_mutex;
126126+static struct thread_entry *g_external_entry = NULL;
126127127128#define THREADS_RUN 0
128129#define THREADS_EXIT 1
···379380 return;
380381 }
381382383383+ /* Allocate a stub entry for external (Rust OS thread) callers.
384384+ * rb_kernel_lock() sets __running_self_entry() to this entry while the
385385+ * caller holds g_mutex, so the kernel always sees a valid STATE_RUNNING
386386+ * thread_entry without needing a dedicated broker thread. */
387387+ g_external_entry = thread_alloc();
388388+ if (!g_external_entry) {
389389+ fprintf(stderr, "posix: external entry alloc failed\n");
390390+ return;
391391+ }
392392+ g_external_entry->name = "rb_external";
393393+ g_external_entry->state = STATE_RUNNING;
394394+ g_external_entry->context.s = psem_create(0);
395395+ g_external_entry->context.t = NULL;
396396+ g_external_entry->context.told = NULL;
397397+398398+ if (!g_external_entry->context.s) {
399399+ fprintf(stderr, "posix: external semaphore alloc failed\n");
400400+ return;
401401+ }
402402+382403 /* setjmp here is the exit trampoline: if thread_exit() longjmps here
383404 * for the main thread we fall through and exit the process. */
384405 if (setjmp(thread_jmpbufs[THREAD_ID_SLOT(thread->id)]) == 0)
···386407387408 pthread_mutex_unlock(&g_mutex);
388409 exit(0);
410410+}
411411+412412+/* ── Rust OS thread kernel-lock API ───────────────────────────────────────────
413413+ *
414414+ * Any OS thread (e.g. a tokio/actix worker) may call Rockbox firmware FFI
415415+ * safely by bracketing the call with rb_kernel_lock() / rb_kernel_unlock():
416416+ *
417417+ * rb_kernel_lock();
418418+ * audio_play(elapsed, offset);
419419+ * rb_kernel_unlock();
420420+ *
421421+ * rb_kernel_lock() acquires the global cooperative mutex (g_mutex) so no
422422+ * Rockbox kernel thread can run concurrently, then installs g_external_entry
423423+ * as the current thread so __running_self_entry() always returns a valid
424424+ * STATE_RUNNING entry. rb_kernel_unlock() reverses both steps.
425425+ *
426426+ * These replace the fw_bus broker channel: direct in-line calls with O(1)
427427+ * overhead instead of mpsc round-trips with a 30 s timeout safety net.
428428+ *
429429+ * macOS: pthread_mutex_lock/unlock work identically on Monterey x86_64 and
430430+ * Sequoia arm64 — same as the rest of this file.
431431+ */
432432+433433+void rb_kernel_lock(void)
434434+{
435435+ pthread_mutex_lock(&g_mutex);
436436+ __running_self_entry() = g_external_entry;
437437+}
438438+439439+void rb_kernel_unlock(void)
440440+{
441441+ __running_self_entry() = NULL;
442442+ pthread_mutex_unlock(&g_mutex);
389443}
390444391445/* ── Priority scheduling stub ─────────────────────────────────────────────────
···517517518518 thread_support=
519519 if [ -z "$ARG_THREAD_SUPPORT" ] || [ "$ARG_THREAD_SUPPORT" = "0" ]; then
520520- if [ "$sigaltstack" = "0" ]; then
521521- thread_support="HAVE_SIGALTSTACK_THREADS"
522522- LDOPTS="$LDOPTS -lpthread" # pthread needed
523523- echo "Selected sigaltstack threads"
524524- elif [ "$fibers" = "0" ]; then
525525- thread_support="HAVE_WIN32_FIBER_THREADS"
526526- echo "Selected Win32 Fiber threads"
527527- fi
520520+ # Prefer HAVE_POSIX_THREADS on POSIX platforms (Darwin/Linux): one pthread
521521+ # per Rockbox thread with a global cooperative mutex, enabling
522522+ # rb_kernel_lock/rb_kernel_unlock for safe FFI from Rust OS threads.
523523+ # Replaces HAVE_SIGALTSTACK_THREADS (green coroutines, no global mutex).
524524+ case `uname -s` in
525525+ Darwin|Linux)
526526+ thread_support="HAVE_POSIX_THREADS"
527527+ thread_support_comment="/* the threading backend we use.
528528+ * HAVE_POSIX_THREADS: one pthread per Rockbox thread, g_mutex for cooperative
529529+ * exclusion, rb_kernel_lock/rb_kernel_unlock for safe FFI from Rust OS threads.
530530+ * Replaces HAVE_SIGALTSTACK_THREADS (green coroutines in a single OS thread,
531531+ * no global mutex, incompatible with rb_kernel_lock). */"
532532+ LDOPTS="$LDOPTS -lpthread"
533533+ echo "Selected POSIX threads"
534534+ ;;
535535+ *)
536536+ if [ "$sigaltstack" = "0" ]; then
537537+ thread_support="HAVE_SIGALTSTACK_THREADS"
538538+ LDOPTS="$LDOPTS -lpthread" # pthread needed
539539+ echo "Selected sigaltstack threads"
540540+ elif [ "$fibers" = "0" ]; then
541541+ thread_support="HAVE_WIN32_FIBER_THREADS"
542542+ echo "Selected Win32 Fiber threads"
543543+ fi
544544+ ;;
545545+ esac
528546 fi
529547530548 if [ -n `echo $app_type | grep "sdl"` ] && [ -z "$thread_support" ] \
···818836 GLOBAL_LDOPTS=""
819837 LDOPTS="-llog -landroid -laaudio"
820838821821- # firmware/asm/thread.c handles HAVE_SIGALTSTACK_THREADS via thread-unix.c
822822- # which works on bionic. HAVE_PTHREAD_THREADS is set by androidcc but not
823823- # actually consumed by anything in firmware/ — would land us at the
824824- # "Missing thread impl" error.
825825- thread_support="HAVE_SIGALTSTACK_THREADS"
839839+ # HAVE_POSIX_THREADS: one pthread per Rockbox thread + g_mutex for cooperative
840840+ # exclusion. rb_kernel_lock/rb_kernel_unlock let any Rust OS thread call
841841+ # firmware FFI safely. Android bionic has full pthread support (API 21+).
842842+ # Replaces HAVE_SIGALTSTACK_THREADS (green coroutines, no global mutex,
843843+ # incompatible with rb_kernel_lock).
844844+ thread_support="HAVE_POSIX_THREADS"
845845+ thread_support_comment="/* the threading backend we use.
846846+ * HAVE_POSIX_THREADS: one pthread per Rockbox thread, g_mutex for cooperative
847847+ * exclusion, rb_kernel_lock/rb_kernel_unlock for safe FFI from Rust OS threads.
848848+ * Replaces HAVE_SIGALTSTACK_THREADS (green coroutines in a single OS thread,
849849+ * no global mutex, incompatible with rb_kernel_lock). */"
826850 gccchoice=""
827851 gcctarget="${cc_triple}-"
828852