A modern, network-enabled music player platform built on Rockbox technology. rockboxd.tsiry-sandratraina.com
rust deno navidrome airplay libadwaita zig mpris snapcast mpd rockbox audio subsonic
2

Configure Feed

Select the types of activity you want to include in your feed.

Switch to POSIX threads and add kernel lock

+430 -635
+6 -6
README.md
··· 29 29 ## ✨ Features 30 30 31 31 ### Audio output 32 - - [x] Built-in SDL audio 32 + - [x] Built-in CPAL audio 33 33 - [x] AirPlay (RAOP) — single or multi-room fan-out to Apple TV, HomePod, Airport Express, shairport-sync 34 34 - [x] Snapcast — synchronised multi-room via snapserver (FIFO/pipe **and** direct TCP with mDNS auto-discovery) 35 35 - [x] Squeezelite (Slim Protocol + HTTP broadcast) — synchronised multi-room ··· 72 72 73 73 ```toml 74 74 music_dir = "/path/to/your/Music" 75 - audio_output = "builtin" # SDL audio — see Audio Output for other options 75 + audio_output = "builtin" # CPAL audio — see Audio Output for other options 76 76 playlist_shuffle = false 77 77 repeat_mode = 1 78 78 bass = 0 ··· 195 195 `music_dir` is always required. `audio_output` defaults to `"builtin"` if 196 196 omitted. 197 197 198 - ### Built-in SDL — default 198 + ### Built-in CPAL — default 199 199 200 200 ```toml 201 201 music_dir = "/path/to/Music" 202 202 audio_output = "builtin" 203 203 ``` 204 204 205 - Uses SDL2 audio — plays through the OS default device. No extra setup needed. 205 + Uses CPAL — plays through the OS default device. No extra setup needed. 206 206 207 207 ### Snapcast 208 208 ··· 581 581 582 582 The Rockbox C firmware (audio engine, codecs, DSP) is compiled into 583 583 `libfirmware.a` and linked with two Rust static libraries 584 - (`librockbox_cli.a`, `librockbox_server.a`) and SDL2 by the Zig build script. 584 + (`librockbox_cli.a`, `librockbox_server.a`) and CPAL by the Zig build script. 585 585 The result is a single `rockboxd` binary. Rust crates expose the firmware over 586 586 gRPC, GraphQL, HTTP, and MPD, and implement output sinks (AirPlay, Squeezelite, 587 587 Snapcast) and the Typesense search integration. ··· 629 629 The Mintlify source lives in [`mintlify/`](./mintlify/). Topics covered: 630 630 631 631 - **Getting started** — install, quickstart, configuration 632 - - **Audio output** — built-in SDL, Snapcast, AirPlay, Squeezelite, Chromecast, UPnP 632 + - **Audio output** — built-in CPAL, Snapcast, AirPlay, Squeezelite, Chromecast, UPnP 633 633 - **Audio settings** — parametric EQ, DSP, ReplayGain, crossfade 634 634 - **Clients** — web UI, desktop apps, MPD, MPRIS 635 635 - **API reference** — HTTP REST (auto-generated from OpenAPI), GraphQL, gRPC, MPD
+142 -57
THREADING.md
··· 78 78 make_context(): Operation not permitted 79 79 ``` 80 80 81 - 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. 81 + 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. 82 82 83 83 ### How `HAVE_POSIX_THREADS` works 84 84 ··· 110 110 111 111 Like 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. 112 112 113 + ### Rust OS thread integration — `rb_kernel_lock` / `rb_kernel_unlock` 114 + 115 + `thread-posix.c` also exposes two functions that let **any OS thread** (Rust or C) safely call Rockbox firmware FFI without a broker intermediary: 116 + 117 + ```c 118 + void rb_kernel_lock(void); // acquire g_mutex + set __running_self_entry 119 + void rb_kernel_unlock(void); // clear __running_self_entry + release g_mutex 120 + ``` 121 + 122 + `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: 123 + 124 + ```c 125 + void rb_kernel_lock(void) { 126 + pthread_mutex_lock(&g_mutex); 127 + __running_self_entry() = g_external_entry; 128 + } 129 + 130 + void rb_kernel_unlock(void) { 131 + __running_self_entry() = NULL; 132 + pthread_mutex_unlock(&g_mutex); 133 + } 134 + ``` 135 + 136 + On the Rust side, `crates/server/src/fw_bus.rs` exposes a thin wrapper: 137 + 138 + ```rust 139 + pub fn with_kernel_lock<T, F: FnOnce() -> T>(f: F) -> T { 140 + unsafe { rockbox_sys::rb_kernel_lock() }; 141 + let result = f(); 142 + unsafe { rockbox_sys::rb_kernel_unlock() }; 143 + result 144 + } 145 + ``` 146 + 147 + 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. 148 + 149 + **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. 150 + 113 151 --- 114 152 115 153 ## The Headless Scheduler (Android cdylib) ··· 118 156 119 157 ### Three-way comparison 120 158 121 - | Concern | SDL hosted | Headless macOS/Linux | Android cdylib | 122 - | -------------------- | ------------------------------------ | ---------------------------------------------- | ------------------------------------------------------- | 123 - | Autoconf define | `HAVE_SDL_THREADS` | `HAVE_POSIX_THREADS` | `HAVE_SIGALTSTACK_THREADS` | 124 - | Backend file | `thread-sdl.c` | `thread-posix.c` | `thread-unix.c` | 125 - | Cooperative token | `SDL_mutex *m` (single global) | `pthread_mutex_t g_mutex` (single global) | `__cores[0].running` (global current-thread pointer) | 126 - | Per-thread blocking | `SDL_sem *` (SDL semaphore) | `posix_sem_t *` (cond+mutex+count) | `setjmp`/`longjmp` + signal alt-stack | 127 - | Thread creation | `SDL_CreateThread` | `pthread_create` + `pthread_detach` | Bootstrapped via `sigaltstack()` + `SIGUSR1` | 128 - | Scheduler yield | `SDL_UnlockMutex` / `SDL_LockMutex` | `pthread_mutex_unlock` / `pthread_mutex_lock` | `longjmp` to next thread's `jmp_buf` | 129 - | Boot path | SDL event thread initialises audio | Zig linker entry → `main_c()` | `rb_daemon_start` (JNI) spawns `rockbox-engine` pthread | 130 - | Power-off | `SDL_QUIT` event loop | `exit(0)` via `power_off()` | `exit(0)` via `power_off()` | 131 - | Stack size respected | No (SDL default, typically 8 MB) | No (pthread default) | Yes — passed to signal alt-stack | 132 - | macOS 12 x86_64 | Works | Works | **Broken** — `sigaltstack()` returns `EPERM` | 133 - | stdio | terminal / controlled | stderr via `debug-headless.c` | piped to logcat via `redirect_stdio_to_logcat` | 159 + | Concern | SDL hosted | Headless macOS/Linux | Android cdylib | 160 + | -------------------- | ------------------------------------ | ------------------------------------------------------ | ------------------------------------------------------- | 161 + | Autoconf define | `HAVE_SDL_THREADS` | `HAVE_POSIX_THREADS` | `HAVE_SIGALTSTACK_THREADS` | 162 + | Backend file | `thread-sdl.c` | `thread-posix.c` | `thread-unix.c` | 163 + | Cooperative token | `SDL_mutex *m` (single global) | `pthread_mutex_t g_mutex` (single global) | `__cores[0].running` (global current-thread pointer) | 164 + | Per-thread blocking | `SDL_sem *` (SDL semaphore) | `posix_sem_t *` (cond+mutex+count) | `setjmp`/`longjmp` + signal alt-stack | 165 + | Thread creation | `SDL_CreateThread` | `pthread_create` + `pthread_detach` | Bootstrapped via `sigaltstack()` + `SIGUSR1` | 166 + | Scheduler yield | `SDL_UnlockMutex` / `SDL_LockMutex` | `pthread_mutex_unlock` / `pthread_mutex_lock` | `longjmp` to next thread's `jmp_buf` | 167 + | Rust OS thread FFI | weak no-op stubs (broker still used) | `rb_kernel_lock` / `rb_kernel_unlock` (direct, O(1)) | fw_bus broker channel | 168 + | Boot path | SDL event thread initialises audio | Zig linker entry → `main_c()` | `rb_daemon_start` (JNI) spawns `rockbox-engine` pthread | 169 + | Power-off | `SDL_QUIT` event loop | `exit(0)` via `power_off()` | `exit(0)` via `power_off()` | 170 + | Stack size respected | No (SDL default, typically 8 MB) | No (pthread default) | Yes — passed to signal alt-stack | 171 + | macOS 12 x86_64 | Works | Works | **Broken** — `sigaltstack()` returns `EPERM` | 172 + | stdio | terminal / controlled | stderr via `debug-headless.c` | piped to logcat via `redirect_stdio_to_logcat` | 134 173 135 174 ### `__cores[0].running` — the critical invariant 136 175 137 - 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.** 176 + 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. 138 177 139 - 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. 178 + 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. 140 179 141 - 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: 142 - - `wakeup_thread_` dereferences a stale or null function pointer → **SIGSEGV at PC=0** 143 - - Kernel scheduler state is silently corrupted 144 - - Symptoms may appear seconds later, during a track switch or settings change 180 + On other builds (SDL, Android cdylib) an external OS thread calling firmware FFI without the correct `__running_self_entry()` will cause: 181 + - `wakeup_thread_` to dereference a stale or null function pointer → **SIGSEGV at PC=0** 182 + - Silent kernel scheduler corruption 183 + - Symptoms appearing seconds later during a track switch or settings change 145 184 146 - This is why **all firmware-mutating calls from Rust handlers must go through the firmware-command bus** (see below). 185 + This is why those builds still route firmware-mutating calls through the fw_bus broker. 147 186 148 187 ### Daemon boot sequence (Android) 149 188 ··· 161 200 162 201 ## The Firmware-Command Bus (`crates/server/src/fw_bus.rs`) 163 202 164 - ### Problem 203 + ### Headless target (current) 204 + 205 + On the headless POSIX target all `fw_bus` functions execute the firmware call **inline on the calling OS thread** under the Rockbox cooperative lock: 206 + 207 + ``` 208 + actix worker / tonic task 209 + → fw_bus::run_on_broker(|| rb::playback::play(elapsed, offset)) 210 + → rb_kernel_lock() ← acquire g_mutex, install g_external_entry 211 + → rb::playback::play() ← runs safely: __running_self_entry() is valid 212 + → rb_kernel_unlock() ← clear entry, release g_mutex 213 + ← returns immediately 214 + ``` 165 215 166 - 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. 216 + There is no channel, no broker round-trip, and no timeout. The cost is one `pthread_mutex_lock` + one `pthread_mutex_unlock`. 167 217 168 - ### Solution 218 + ### SDL / Android broker path (legacy) 169 219 170 - 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()`. 220 + 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: 171 221 172 222 ``` 173 223 actix worker / tonic task ··· 176 226 → broker thread (real Rockbox kernel thread) 177 227 → rb::playback::play(elapsed, offset) 178 228 → reply_tx.send(()) 179 - ← fw_bus::send_and_wait blocks until reply arrives (≤ 30 s) 229 + ← send_and_wait blocks until reply arrives (≤ 30 s) 180 230 ``` 231 + 232 + 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()`. 181 233 182 234 ### API 183 235 184 - | Function | Use case | 185 - | ------------------------------ | -------------------------------------------------------------------- | 186 - | `fw_bus::init()` | Call once at startup, before broker thread spawns | 187 - | `fw_bus::send(cmd)` | Fire-and-forget (no reply needed) | 188 - | `fw_bus::send_and_wait(make)` | Send + block until broker confirms (for actix `web::block` handlers) | 189 - | `fw_bus::run_on_broker(f)` | Run arbitrary closure on broker; returns `T::default()` on timeout | 190 - | `fw_bus::try_run_on_broker(f)` | Same but returns `Option<T>` — `None` on timeout | 191 - | `fw_bus::drain(rx)` | Called once per broker iteration to execute pending commands | 236 + | Function | Headless behaviour | SDL/Android behaviour | 237 + | ------------------------------- | ----------------------------------------------- | -------------------------------------------------- | 238 + | `fw_bus::init()` | No-op (channel kept for compat, never drained) | Creates mpsc channel before broker spawns | 239 + | `fw_bus::with_kernel_lock(f)` | Acquires `g_mutex`, runs `f`, releases | Runs `f` without any lock (unsafe on those builds) | 240 + | `fw_bus::run_on_broker(f)` | `with_kernel_lock(f)` — inline, no channel | Sends `FwCmd::Custom` to broker, blocks ≤ 30 s | 241 + | `fw_bus::try_run_on_broker(f)` | `Some(with_kernel_lock(f))` — never None | `None` on 30 s broker timeout | 242 + | `fw_bus::send_and_wait(make)` | Inline under lock; reply channel is a no-op | Sends to broker, waits for reply ≤ 30 s | 243 + | `fw_bus::send(cmd)` | Executes immediately under lock | Fire-and-forget enqueue to broker | 244 + | `fw_bus::drain(rx)` | No-op | Drains pending commands from broker queue | 245 + | `fw_bus::drain_blocking(rx, t)` | No-op | Blocks up to `t` for first command, then drains | 246 + | `fw_bus::take_receiver()` | Returns `None` | Takes the `Receiver` for the broker to own | 192 247 193 248 ### `FwCmd` variants 194 249 195 250 `Play`, `Pause`, `Resume`, `Next`, `Prev`, `Stop`, `FfRewind`, `FlushAndReloadTracks`, `SetCrossfade`, and `Custom(Box<dyn FnOnce() + Send>)` (escape hatch for anything not enumerated). 196 251 197 - ### Timeout behaviour 198 - 199 - 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`. 200 - 201 252 ### Where fw_bus is NOT needed 202 253 203 254 - Read-only status queries (`rb::playback::status()`, `rb::playback::current_track()`) — these only read atomics or copy structs; no kernel primitive is called. 204 - - Anything running inside the broker thread itself (it already owns `__running_self_entry()`). 205 - - 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. 255 + - Anything running inside a real Rockbox kernel thread (it already owns `__running_self_entry()`). 206 256 207 257 --- 208 258 ··· 213 263 | Thread | C entry point | What it does | 214 264 | --------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------- | 215 265 | `server_thread` | `server_thread.c` → `start_server()` | Spawns the actix HTTP server in a Rust OS thread, then loops yielding to the Rockbox scheduler | 216 - | `broker_thread` | `broker_thread.c` → `start_broker()` | Event loop: drains `fw_bus`, publishes playback state to GraphQL subscriptions, scrobbles tracks, restores playlist | 266 + | `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. | 217 267 218 - Both threads must call `rb::system::sleep(rb::HZ)` (desktop) or equivalent on every loop iteration. 268 + Both threads must call `rb::system::sleep(rb::HZ)` (or equivalent) on every loop iteration. 219 269 220 270 **`server_thread` pattern** (`crates/server/src/lib.rs`): 221 271 ```rust ··· 240 290 **`broker_thread` pattern** (`crates/server/src/lib.rs`): 241 291 ```rust 242 292 pub extern "C" fn start_broker() { 293 + // take_receiver() returns None on headless (no channel to drain). 243 294 let fw_rx = fw_bus::take_receiver(); 244 295 loop { 245 - // Drain firmware commands from actix/gRPC handlers first. 246 296 if let Some(rx) = &fw_rx { 247 - fw_bus::drain(rx); 297 + fw_bus::drain(rx); // SDL/Android only — no-op on headless 248 298 } 249 299 // ... publish events, scrobble, restore playlist ... 250 300 thread::sleep(std::time::Duration::from_millis(100)); ··· 255 305 256 306 ### Rust OS threads (no Rockbox scheduler involvement) 257 307 258 - These are spawned with `std::thread::spawn` — they are pure OS threads and never interact with the Rockbox scheduler token or `__cores[0].running`. 308 + These are spawned with `std::thread::spawn` — they are pure OS threads and never interact with the Rockbox scheduler token. 259 309 260 310 | Thread / component | Spawned from | Runtime | 261 311 | ---------------------------------------------------------------------- | ------------------------------------------ | --------------------------------------------------------- | ··· 282 332 283 333 ## Startup Sequences 284 334 285 - ### Desktop (SDL hosted) 335 + ### Headless (`librockboxd.a` — GPUI desktop client, embedded daemon) 336 + 337 + ``` 338 + Zig entry → main_c() 339 + 340 + ├── server_init() → create_thread(server_thread) ← Rockbox kernel thread 341 + │ └── start_server() 342 + │ ├── load_settings() 343 + │ ├── rockbox_upnp::init() 344 + │ ├── thread::spawn(actix HTTP server) ← Rust OS thread (free to block) 345 + │ └── loop { sleep + rb::system::sleep(HZ) } ← yields g_mutex 346 + 347 + ├── sleep(HZ) 348 + 349 + └── start_servers() ← called on main C thread 350 + ├── fw_bus::init() ← no-op on headless 351 + ├── thread::spawn(gRPC server) ← Rust OS thread 352 + │ └── handlers call fw_bus::run_on_broker(f) 353 + │ → rb_kernel_lock() + f() + rb_kernel_unlock() ← inline, O(1) 354 + ├── thread::spawn(GraphQL server) 355 + ├── thread::spawn(MPD server) 356 + └── thread::spawn(command relay) 357 + 358 + broker_init() → create_thread(broker_thread) ← Rockbox kernel thread 359 + └── start_broker() 360 + ├── fw_bus::take_receiver() → None ← no channel on headless 361 + └── loop { publish events + scrobble + sleep + rb::system::sleep(HZ) } 362 + ``` 363 + 364 + ### Desktop SDL (`rockboxd` binary) 286 365 287 366 ``` 288 367 main.c: server_init() ··· 292 371 │ ├── load_settings() 293 372 │ ├── rockbox_upnp::init() ← creates static UPnP multi-thread runtime 294 373 │ ├── thread::spawn(actix) ← Rust OS thread (free to block) 295 - │ └── loop { sleep + rb::system::sleep(HZ) } ← yields Rockbox mutex 374 + │ └── loop { sleep + rb::system::sleep(HZ) } ← yields SDL mutex 296 375 297 - ├── sleep(HZ) ← Rockbox scheduler sleep on main thread (~1 s) 376 + ├── sleep(HZ) 298 377 299 378 └── start_servers() ← called on main C thread after 1 s 300 379 ├── fw_bus::init() ← create mpsc channel before broker spawns 301 380 ├── thread::spawn(gRPC server) ← Rust OS thread, new_current_thread runtime 302 - ├── thread::spawn(GraphQL server) ← Rust OS thread, new_current_thread runtime 303 - ├── thread::spawn(MPD server) ← Rust OS thread, new_current_thread runtime 304 - ├── thread::spawn(MPRIS, Linux) ← Rust OS thread, async_std 305 - └── thread::spawn(command relay) ← Rust OS thread, reqwest blocking 381 + ├── thread::spawn(GraphQL server) 382 + ├── thread::spawn(MPD server) 383 + ├── thread::spawn(MPRIS, Linux) 384 + └── thread::spawn(command relay) 306 385 307 386 main.c: broker_init() 308 387 ··· 472 551 473 552 `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. 474 553 475 - ### Rule 6: All firmware-mutating FFI from Rust handlers must go through `fw_bus` 554 + ### Rule 6: On headless, use `fw_bus::run_on_broker` / `with_kernel_lock` for firmware-mutating FFI from Rust handlers 476 555 477 - 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. 556 + 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(|| ...)`. 478 557 479 - Read-only queries (status, current track) that only touch atomics or copy structs are safe to call directly. 558 + 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. 480 559 481 - ### Rule 7: `fw_bus::init()` must be called before any handler sends a command 560 + Read-only queries (status, current track) that only touch atomics or copy structs are safe to call directly on all builds. 482 561 483 - `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. 562 + ### Rule 7: `fw_bus::init()` is a no-op on headless but must still be called on SDL/Android 563 + 564 + 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. 484 565 485 566 ### Rule 8: Never re-enable `HAVE_SIGALTSTACK_THREADS` for the headless host 486 567 ··· 498 579 ``` 499 580 500 581 and restore it if configure has reverted it to `HAVE_SIGALTSTACK_THREADS`. 582 + 583 + ### Rule 9: Do not nest `rb_kernel_lock` calls 584 + 585 + `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.
+7
apps/broker_thread.c
··· 37 37 start_broker(); 38 38 } 39 39 40 + /* Fallback stubs for builds that do not include thread-posix.c (e.g. the SDL 41 + * hosted target). thread-posix.c provides strong definitions that override 42 + * these via the linker's weak-symbol resolution, so headless builds get the 43 + * real cooperative-mutex implementation automatically. */ 44 + __attribute__((weak)) void rb_kernel_lock(void) {} 45 + __attribute__((weak)) void rb_kernel_unlock(void) {} 46 + 40 47 /** -- Startup -- **/ 41 48 42 49 /* Initialize the broker - called from init() in main.c */
+6 -2
autoconf/autoconf-android.h
··· 61 61 62 62 63 63 64 - /* the threading backend we use */ 65 - #define HAVE_SIGALTSTACK_THREADS 64 + /* the threading backend we use. 65 + * HAVE_POSIX_THREADS: one pthread per Rockbox thread, g_mutex for cooperative 66 + * exclusion, rb_kernel_lock/rb_kernel_unlock for safe FFI from Rust OS threads. 67 + * Replaces HAVE_SIGALTSTACK_THREADS (green coroutines in a single OS thread, 68 + * no global mutex, incompatible with rb_kernel_lock). */ 69 + #define HAVE_POSIX_THREADS 66 70 67 71 /* lcd dimensions for application builds from configure */ 68 72 #define LCD_WIDTH 320
+6 -2
autoconf/autoconf.h
··· 54 54 55 55 /* optional defines for RTC mod for h1x0 */ 56 56 57 - /* the threading backend we use */ 58 - #define HAVE_SIGALTSTACK_THREADS 57 + /* the threading backend we use. 58 + * HAVE_POSIX_THREADS: one pthread per Rockbox thread, g_mutex for cooperative 59 + * exclusion, rb_kernel_lock/rb_kernel_unlock for safe FFI from Rust OS threads. 60 + * Replaces HAVE_SIGALTSTACK_THREADS (green coroutines in a single OS thread, 61 + * no global mutex, incompatible with rb_kernel_lock). */ 62 + #define HAVE_POSIX_THREADS 59 63 60 64 /* lcd dimensions for application builds from configure */ 61 65 #define LCD_WIDTH 320
+27 -30
crates/graphql/src/schema/playback.rs
··· 1 - use std::{ 2 - fs, 3 - sync::{mpsc::Sender, Arc, Mutex}, 4 - }; 1 + use std::fs; 5 2 6 3 use crate::{check_and_load_player, read_files, schema::objects, AUDIO_EXTENSIONS}; 7 4 use async_graphql::*; 8 5 use futures_util::Stream; 9 6 use rockbox_library::repo; 10 - use rockbox_sys::{ 11 - events::RockboxCommand, 12 - types::{audio_status::AudioStatus, file_position::FilePosition, mp3_entry::Mp3Entry}, 7 + use rockbox_sys::types::{ 8 + audio_status::AudioStatus, file_position::FilePosition, mp3_entry::Mp3Entry, 13 9 }; 14 10 use rockbox_types::device::Device; 15 11 use sqlx::{Pool, Sqlite}; ··· 83 79 84 80 #[Object] 85 81 impl PlaybackMutation { 86 - async fn play(&self, ctx: &Context<'_>, elapsed: i64, offset: i64) -> Result<i32, Error> { 87 - let cmd = ctx.data::<Arc<Mutex<Sender<RockboxCommand>>>>().unwrap(); 88 - cmd.lock() 89 - .unwrap() 90 - .send(RockboxCommand::Play(elapsed, offset))?; 82 + async fn play(&self, _ctx: &Context<'_>, elapsed: i64, offset: i64) -> Result<i32, Error> { 83 + tokio::task::spawn_blocking(move || { 84 + rockbox_sys::with_kernel_lock(|| rockbox_sys::playback::play(elapsed, offset)) 85 + }) 86 + .await 87 + .map_err(|e| Error::new(e.to_string()))?; 91 88 Ok(0) 92 89 } 93 90 ··· 119 116 Ok(0) 120 117 } 121 118 122 - async fn fast_forward_rewind(&self, ctx: &Context<'_>, new_time: i32) -> Result<i32, Error> { 123 - ctx.data::<Arc<Mutex<Sender<RockboxCommand>>>>() 124 - .unwrap() 125 - .lock() 126 - .unwrap() 127 - .send(RockboxCommand::FfRewind(new_time))?; 119 + async fn fast_forward_rewind(&self, _ctx: &Context<'_>, new_time: i32) -> Result<i32, Error> { 120 + tokio::task::spawn_blocking(move || { 121 + rockbox_sys::with_kernel_lock(|| rockbox_sys::playback::ff_rewind(new_time)) 122 + }) 123 + .await 124 + .map_err(|e| Error::new(e.to_string()))?; 128 125 Ok(0) 129 126 } 130 127 131 - async fn flush_and_reload_tracks(&self, ctx: &Context<'_>) -> Result<i32, Error> { 132 - ctx.data::<Arc<Mutex<Sender<RockboxCommand>>>>() 133 - .unwrap() 134 - .lock() 135 - .unwrap() 136 - .send(RockboxCommand::FlushAndReloadTracks)?; 128 + async fn flush_and_reload_tracks(&self, _ctx: &Context<'_>) -> Result<i32, Error> { 129 + tokio::task::spawn_blocking(|| { 130 + rockbox_sys::with_kernel_lock(|| rockbox_sys::playback::flush_and_reload_tracks()) 131 + }) 132 + .await 133 + .map_err(|e| Error::new(e.to_string()))?; 137 134 Ok(0) 138 135 } 139 136 140 - async fn hard_stop(&self, ctx: &Context<'_>) -> Result<i32, Error> { 141 - ctx.data::<Arc<Mutex<Sender<RockboxCommand>>>>() 142 - .unwrap() 143 - .lock() 144 - .unwrap() 145 - .send(RockboxCommand::Stop)?; 137 + async fn hard_stop(&self, _ctx: &Context<'_>) -> Result<i32, Error> { 138 + tokio::task::spawn_blocking(|| { 139 + rockbox_sys::with_kernel_lock(|| rockbox_sys::playback::hard_stop()) 140 + }) 141 + .await 142 + .map_err(|e| Error::new(e.to_string()))?; 146 143 Ok(0) 147 144 } 148 145
+24 -12
crates/graphql/src/schema/playlist.rs
··· 1 - use std::sync::{mpsc::Sender, Arc, Mutex}; 2 - 3 1 use async_graphql::*; 4 2 use futures_util::Stream; 5 3 use rockbox_library::repo; 6 - use rockbox_sys::{ 7 - events::RockboxCommand, 8 - types::{playlist_amount::PlaylistAmount, playlist_info::PlaylistInfo}, 9 - }; 4 + use rockbox_sys::types::{playlist_amount::PlaylistAmount, playlist_info::PlaylistInfo}; 10 5 11 6 use crate::{ 12 7 rockbox_url, schema::objects::playlist::Playlist, simplebroker::SimpleBroker, types::StatusCode, ··· 77 72 Ok(response.code) 78 73 } 79 74 80 - async fn resume_track(&self, ctx: &Context<'_>) -> Result<String, Error> { 81 - ctx.data::<Arc<Mutex<Sender<RockboxCommand>>>>() 82 - .unwrap() 83 - .lock() 84 - .unwrap() 85 - .send(RockboxCommand::PlaylistResumeTrack)?; 75 + async fn resume_track(&self, _ctx: &Context<'_>) -> Result<String, Error> { 76 + tokio::task::spawn_blocking(|| { 77 + rockbox_sys::with_kernel_lock(|| { 78 + let status = rockbox_sys::system::get_global_status(); 79 + if status.resume_index == -1 { 80 + return; 81 + } 82 + if rockbox_sys::playlist::amount() == 0 { 83 + let ret = rockbox_sys::playlist::resume(); 84 + if ret == -1 { 85 + return; 86 + } 87 + } 88 + rockbox_sys::playlist::resume_track( 89 + status.resume_index, 90 + status.resume_crc32, 91 + status.resume_elapsed.into(), 92 + status.resume_offset.into(), 93 + ); 94 + }); 95 + }) 96 + .await 97 + .map_err(|e| Error::new(e.to_string()))?; 86 98 Ok("".to_string()) 87 99 } 88 100
+2 -7
crates/graphql/src/server.rs
··· 1 - use std::{ 2 - path::PathBuf, 3 - sync::{mpsc::Sender, Arc, Mutex}, 4 - }; 1 + use std::path::PathBuf; 5 2 6 3 use actix_cors::Cors; 7 4 use actix_files::{self as fs, NamedFile}; ··· 17 14 use async_graphql_actix_web::{GraphQLRequest, GraphQLResponse, GraphQLSubscription}; 18 15 use rockbox_library::{create_connection_pool, repo}; 19 16 use rockbox_playlists::PlaylistStore; 20 - use rockbox_sys::events::RockboxCommand; 21 17 use rockbox_webui::{dist, index, index_spa}; 22 18 use sqlx::{Pool, Sqlite}; 23 19 ··· 88 84 } 89 85 } 90 86 91 - pub async fn start(cmd_tx: Arc<Mutex<Sender<RockboxCommand>>>) -> Result<(), Error> { 87 + pub async fn start() -> Result<(), Error> { 92 88 let client = reqwest::Client::new(); 93 89 let pool = create_connection_pool().await?; 94 90 let playlist_store = PlaylistStore::new(pool.clone()); ··· 98 94 Mutation::default(), 99 95 Subscription::default(), 100 96 ) 101 - .data(cmd_tx) 102 97 .data(client) 103 98 .data(pool.clone()) 104 99 .data(playlist_store)
+45 -55
crates/rpc/src/playback.rs
··· 1 - use std::{ 2 - env, fs, 3 - pin::Pin, 4 - sync::{mpsc::Sender, Arc, Mutex}, 5 - }; 1 + use std::{env, fs, pin::Pin}; 6 2 7 3 use crate::{ 8 4 api::rockbox::v1alpha1::{playback_service_server::PlaybackService, *}, ··· 12 8 use rockbox_graphql::schema::objects::track::Track; 13 9 use rockbox_graphql::simplebroker::SimpleBroker; 14 10 use rockbox_library::repo; 15 - use rockbox_sys::{ 16 - self as rb, 17 - events::RockboxCommand, 18 - types::{audio_status::AudioStatus, system_status::SystemStatus}, 19 - }; 11 + use rockbox_sys::{self as rb, types::audio_status::AudioStatus}; 20 12 use sqlx::Sqlite; 21 13 use tokio_stream::{Stream, StreamExt}; 22 14 23 15 pub struct Playback { 24 - cmd_tx: Arc<Mutex<Sender<RockboxCommand>>>, 25 16 client: reqwest::Client, 26 17 pool: sqlx::Pool<Sqlite>, 27 18 } 28 19 29 20 impl Playback { 30 - pub fn new( 31 - cmd_tx: Arc<Mutex<Sender<RockboxCommand>>>, 32 - client: reqwest::Client, 33 - pool: sqlx::Pool<Sqlite>, 34 - ) -> Self { 35 - Self { 36 - cmd_tx, 37 - client, 38 - pool, 39 - } 21 + pub fn new(client: reqwest::Client, pool: sqlx::Pool<Sqlite>) -> Self { 22 + Self { client, pool } 40 23 } 41 24 } 42 25 ··· 47 30 request: tonic::Request<PlayRequest>, 48 31 ) -> Result<tonic::Response<PlayResponse>, tonic::Status> { 49 32 let params = request.into_inner(); 50 - self.cmd_tx 51 - .lock() 52 - .unwrap() 53 - .send(RockboxCommand::Play(params.elapsed, params.offset)) 54 - .map_err(|_| tonic::Status::internal("Failed to send command"))?; 33 + let elapsed = params.elapsed; 34 + let offset = params.offset; 35 + tokio::task::spawn_blocking(move || { 36 + rb::with_kernel_lock(|| rb::playback::play(elapsed, offset)); 37 + }) 38 + .await 39 + .map_err(|e| tonic::Status::internal(e.to_string()))?; 55 40 Ok(tonic::Response::new(PlayResponse::default())) 56 41 } 57 42 ··· 98 83 .map_err(|e| tonic::Status::internal(e.to_string()))?; 99 84 } 100 85 _ => { 101 - let url = format!("{}/status", rockbox_url()); 102 - let response = client 103 - .get(url) 104 - .send() 105 - .await 106 - .map_err(|e| tonic::Status::internal(e.to_string()))?; 107 - let status = response 108 - .json::<SystemStatus>() 86 + let status = rb::system::get_global_status(); 87 + if status.resume_index > -1 { 88 + tokio::task::spawn_blocking(move || { 89 + rb::with_kernel_lock(|| { 90 + if rb::playlist::amount() == 0 { 91 + let ret = rb::playlist::resume(); 92 + if ret == -1 { 93 + return; 94 + } 95 + } 96 + rb::playlist::resume_track( 97 + status.resume_index, 98 + status.resume_crc32, 99 + status.resume_elapsed.into(), 100 + status.resume_offset.into(), 101 + ); 102 + }); 103 + }) 109 104 .await 110 105 .map_err(|e| tonic::Status::internal(e.to_string()))?; 111 - if status.resume_index > -1 { 112 - self.cmd_tx 113 - .lock() 114 - .unwrap() 115 - .send(RockboxCommand::PlaylistResumeTrack) 116 - .map_err(|_| tonic::Status::internal("Failed to send command"))?; 117 106 } 118 107 } 119 108 }; ··· 161 150 request: tonic::Request<FastForwardRewindRequest>, 162 151 ) -> Result<tonic::Response<FastForwardRewindResponse>, tonic::Status> { 163 152 let params = request.into_inner(); 164 - self.cmd_tx 165 - .lock() 166 - .unwrap() 167 - .send(RockboxCommand::FfRewind(params.new_time)) 168 - .map_err(|_| tonic::Status::internal("Failed to send command"))?; 153 + let newtime = params.new_time; 154 + tokio::task::spawn_blocking(move || { 155 + rb::with_kernel_lock(|| rb::playback::ff_rewind(newtime)); 156 + }) 157 + .await 158 + .map_err(|e| tonic::Status::internal(e.to_string()))?; 169 159 Ok(tonic::Response::new(FastForwardRewindResponse::default())) 170 160 } 171 161 ··· 243 233 &self, 244 234 _request: tonic::Request<FlushAndReloadTracksRequest>, 245 235 ) -> Result<tonic::Response<FlushAndReloadTracksResponse>, tonic::Status> { 246 - self.cmd_tx 247 - .lock() 248 - .unwrap() 249 - .send(RockboxCommand::FlushAndReloadTracks) 250 - .map_err(|_| tonic::Status::internal("Failed to send command"))?; 236 + tokio::task::spawn_blocking(move || { 237 + rb::with_kernel_lock(|| rb::playback::flush_and_reload_tracks()); 238 + }) 239 + .await 240 + .map_err(|e| tonic::Status::internal(e.to_string()))?; 251 241 Ok(tonic::Response::new(FlushAndReloadTracksResponse::default())) 252 242 } 253 243 ··· 272 262 &self, 273 263 _request: tonic::Request<HardStopRequest>, 274 264 ) -> Result<tonic::Response<HardStopResponse>, tonic::Status> { 275 - self.cmd_tx 276 - .lock() 277 - .unwrap() 278 - .send(RockboxCommand::Stop) 279 - .map_err(|_| tonic::Status::internal("Failed to send command"))?; 265 + tokio::task::spawn_blocking(move || { 266 + rb::with_kernel_lock(|| rb::playback::hard_stop()); 267 + }) 268 + .await 269 + .map_err(|e| tonic::Status::internal(e.to_string()))?; 280 270 Ok(tonic::Response::new(HardStopResponse::default())) 281 271 } 282 272
+25 -22
crates/rpc/src/playlist.rs
··· 1 - use std::sync::{mpsc::Sender, Arc, Mutex}; 2 - 3 1 use rockbox_library::repo; 4 - use rockbox_sys::{ 5 - events::RockboxCommand, 6 - types::{playlist_amount::PlaylistAmount, playlist_info::PlaylistInfo}, 7 - }; 2 + use rockbox_sys::types::{playlist_amount::PlaylistAmount, playlist_info::PlaylistInfo}; 8 3 use sqlx::Sqlite; 9 4 10 5 use crate::{ ··· 14 9 }; 15 10 16 11 pub struct Playlist { 17 - cmd_tx: Arc<Mutex<Sender<RockboxCommand>>>, 18 12 client: reqwest::Client, 19 13 pool: sqlx::Pool<Sqlite>, 20 14 } 21 15 22 16 impl Playlist { 23 - pub fn new( 24 - cmd_tx: Arc<Mutex<Sender<RockboxCommand>>>, 25 - client: reqwest::Client, 26 - pool: sqlx::Pool<Sqlite>, 27 - ) -> Self { 28 - Self { 29 - cmd_tx, 30 - client, 31 - pool, 32 - } 17 + pub fn new(client: reqwest::Client, pool: sqlx::Pool<Sqlite>) -> Self { 18 + Self { client, pool } 33 19 } 34 20 } 35 21 ··· 139 125 &self, 140 126 _request: tonic::Request<ResumeTrackRequest>, 141 127 ) -> Result<tonic::Response<ResumeTrackResponse>, tonic::Status> { 142 - self.cmd_tx 143 - .lock() 144 - .unwrap() 145 - .send(RockboxCommand::PlaylistResumeTrack) 146 - .map_err(|_| tonic::Status::internal("Failed to send command"))?; 128 + tokio::task::spawn_blocking(|| { 129 + rockbox_sys::with_kernel_lock(|| { 130 + let status = rockbox_sys::system::get_global_status(); 131 + if status.resume_index == -1 { 132 + return; 133 + } 134 + if rockbox_sys::playlist::amount() == 0 { 135 + let ret = rockbox_sys::playlist::resume(); 136 + if ret == -1 { 137 + return; 138 + } 139 + } 140 + rockbox_sys::playlist::resume_track( 141 + status.resume_index, 142 + status.resume_crc32, 143 + status.resume_elapsed.into(), 144 + status.resume_offset.into(), 145 + ); 146 + }); 147 + }) 148 + .await 149 + .map_err(|e| tonic::Status::internal(e.to_string()))?; 147 150 Ok(tonic::Response::new(ResumeTrackResponse::default())) 148 151 } 149 152
+3 -8
crates/rpc/src/server.rs
··· 1 1 use std::net::SocketAddr; 2 - use std::sync::mpsc::Sender; 3 - use std::sync::{Arc, Mutex}; 4 2 5 3 use crate::api::rockbox::v1alpha1::bluetooth_service_server::BluetoothServiceServer; 6 4 use crate::api::rockbox::v1alpha1::browse_service_server::BrowseServiceServer; ··· 28 26 use crate::system::System; 29 27 use rockbox_library::create_connection_pool; 30 28 use rockbox_playlists::PlaylistStore; 31 - use rockbox_sys::events::RockboxCommand; 32 29 use tonic::transport::Server; 33 30 34 - pub async fn start( 35 - cmd_tx: Arc<Mutex<Sender<RockboxCommand>>>, 36 - ) -> Result<(), Box<dyn std::error::Error>> { 31 + pub async fn start() -> Result<(), Box<dyn std::error::Error>> { 37 32 let rockbox_port: u16 = std::env::var("ROCKBOX_PORT") 38 33 .unwrap_or_else(|_| "6061".to_string()) 39 34 .parse() ··· 63 58 pool.clone(), 64 59 )))) 65 60 .add_service(tonic_web::enable(PlaylistServiceServer::new( 66 - Playlist::new(cmd_tx.clone(), client.clone(), pool.clone()), 61 + Playlist::new(client.clone(), pool.clone()), 67 62 ))) 68 63 .add_service(tonic_web::enable(PlaybackServiceServer::new( 69 - Playback::new(cmd_tx.clone(), client.clone(), pool.clone()), 64 + Playback::new(client.clone(), pool.clone()), 70 65 ))) 71 66 .add_service(tonic_web::enable(BrowseServiceServer::new( 72 67 Browse::default(),
-248
crates/server/src/fw_bus.rs
··· 1 - //! Firmware-command bus. 2 - //! 3 - //! On the Android cdylib (and any hosted-pthread build), Rockbox kernel 4 - //! state is identified via the global `__cores[0].running` slot — there is 5 - //! no thread-local-storage involved. Calling firmware kernel functions 6 - //! (`audio_play`, `audio_set_crossfade`, `pcmbuf_*`, anything that 7 - //! eventually reaches `queue_send` / `wakeup_thread`) from a non-Rockbox 8 - //! pthread (e.g. an actix worker handling a gRPC request) reads/writes 9 - //! the wrong thread_entry and corrupts the kernel scheduler. Symptoms: 10 - //! SIGSEGV at PC=0 in `wakeup_thread_` on track switches or settings 11 - //! changes, intermittent kernel coroutine corruption. 12 - //! 13 - //! Solution: serialise every kernel-affecting call through a single 14 - //! mpsc channel that the **broker** thread drains. The broker is a real 15 - //! Rockbox kernel thread (created via `create_thread` from 16 - //! `apps/broker_thread.c`), so its calls into firmware run with a sane 17 - //! `__running_self_entry()`. 18 - //! 19 - //! Synchronous handlers wait on a oneshot reply so they keep their 20 - //! original signatures from the caller's POV. 21 - 22 - use std::sync::mpsc::{self, Receiver, Sender, TryRecvError}; 23 - use std::sync::Mutex; 24 - use std::sync::OnceLock; 25 - 26 - /// Discriminated union of every firmware-mutating call we need to route 27 - /// through the broker. Add a variant when a new handler proves to need it. 28 - pub enum FwCmd { 29 - Play { 30 - elapsed: i64, 31 - offset: i64, 32 - reply: Option<mpsc::SyncSender<()>>, 33 - }, 34 - Pause { 35 - reply: Option<mpsc::SyncSender<()>>, 36 - }, 37 - Resume { 38 - reply: Option<mpsc::SyncSender<()>>, 39 - }, 40 - Next { 41 - reply: Option<mpsc::SyncSender<()>>, 42 - }, 43 - Prev { 44 - reply: Option<mpsc::SyncSender<()>>, 45 - }, 46 - Stop { 47 - reply: Option<mpsc::SyncSender<()>>, 48 - }, 49 - FfRewind { 50 - newtime: i32, 51 - reply: Option<mpsc::SyncSender<()>>, 52 - }, 53 - FlushAndReloadTracks { 54 - reply: Option<mpsc::SyncSender<()>>, 55 - }, 56 - SetCrossfade { 57 - value: i32, 58 - reply: Option<mpsc::SyncSender<()>>, 59 - }, 60 - /// Escape hatch for anything not covered yet. Closure runs on the 61 - /// broker thread; sender is responsible for not panicking inside. 62 - Custom(Box<dyn FnOnce() + Send + 'static>), 63 - } 64 - 65 - static SENDER: OnceLock<Sender<FwCmd>> = OnceLock::new(); 66 - // Receiver is wrapped in a Mutex<Option> so the broker can `take()` it on 67 - // startup; only one broker should ever exist. 68 - static RECEIVER: OnceLock<Mutex<Option<Receiver<FwCmd>>>> = OnceLock::new(); 69 - 70 - /// Initialise the channel. Call once at startup, BEFORE the broker thread 71 - /// is spawned and BEFORE any handler tries to `send()`. Idempotent. 72 - pub fn init() { 73 - SENDER.get_or_init(|| { 74 - let (tx, rx) = mpsc::channel(); 75 - RECEIVER 76 - .set(Mutex::new(Some(rx))) 77 - .unwrap_or_else(|_| panic!("fw_bus::init called twice")); 78 - tx 79 - }); 80 - } 81 - 82 - /// The broker thread takes ownership of the receiver on its first iteration. 83 - /// Returns None if init() wasn't called or the receiver was already taken. 84 - pub fn take_receiver() -> Option<Receiver<FwCmd>> { 85 - RECEIVER.get()?.lock().ok()?.take() 86 - } 87 - 88 - /// Enqueue a command for the broker to execute on its next tick. Drops 89 - /// silently if the bus isn't initialised (e.g. desktop / non-cdylib build); 90 - /// callers should treat it as a fire-and-forget side effect. 91 - pub fn send(cmd: FwCmd) { 92 - if let Some(tx) = SENDER.get() { 93 - let _ = tx.send(cmd); 94 - } 95 - } 96 - 97 - /// How long to wait for the broker to drain a queued command. Generous 98 - /// because building a large playlist (1000+ tracks via `playlist_create` + 99 - /// `build_playlist`) can take several seconds when the kernel is also 100 - /// busy decoding audio. Under rapid user-driven play actions, multiple 101 - /// commands queue up and each subsequent caller waits for the broker to 102 - /// finish the ones ahead of it. 103 - const BROKER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); 104 - 105 - /// Send a command and block until the broker confirms execution. Use 106 - /// from actix `web::block(...)` callbacks — the wait is bounded by the 107 - /// broker tick (~10 ms idle, immediate when busy). 108 - pub fn send_and_wait(make: impl FnOnce(mpsc::SyncSender<()>) -> FwCmd) { 109 - let (reply_tx, reply_rx) = mpsc::sync_channel::<()>(1); 110 - send(make(reply_tx)); 111 - if reply_rx.recv_timeout(BROKER_TIMEOUT).is_err() { 112 - tracing::warn!("fw_bus::send_and_wait: broker timed out (no reply within 30 s)"); 113 - } 114 - } 115 - 116 - /// Run an arbitrary closure on the broker thread and block until it 117 - /// finishes. Returns the closure's value. Typical use from a handler: 118 - /// 119 - /// let ret: i32 = fw_bus::run_on_broker(|| rb::playlist::shuffle(seed, idx)); 120 - /// 121 - /// The closure runs on a real Rockbox kernel thread, so any firmware 122 - /// FFI it makes resolves `__cores[0].running` correctly. Blocks the 123 - /// caller for up to 30 s; intended to be called from `web::block(...)`. 124 - /// 125 - /// On timeout (e.g. the broker is wedged or the queue is backed up under 126 - /// rapid-fire user actions) we **do not** panic — that would unwind the 127 - /// actix worker and surface as a scary 500. Instead we log a warning and 128 - /// return `T::default()`. Callers that care about distinguishing success 129 - /// from a timed-out broker call should use [`try_run_on_broker`] instead. 130 - pub fn run_on_broker<T, F>(f: F) -> T 131 - where 132 - T: Default + Send + 'static, 133 - F: FnOnce() -> T + Send + 'static, 134 - { 135 - try_run_on_broker(f).unwrap_or_else(|| { 136 - tracing::warn!("fw_bus::run_on_broker: broker tick timed out (30 s), returning default"); 137 - T::default() 138 - }) 139 - } 140 - 141 - /// Same as [`run_on_broker`] but returns `None` on timeout instead of a 142 - /// default value. Use when the caller needs to surface the timeout as a 143 - /// proper error (e.g. an HTTP 503). 144 - pub fn try_run_on_broker<T, F>(f: F) -> Option<T> 145 - where 146 - T: Send + 'static, 147 - F: FnOnce() -> T + Send + 'static, 148 - { 149 - let (tx, rx) = mpsc::sync_channel::<T>(1); 150 - send(FwCmd::Custom(Box::new(move || { 151 - let _ = tx.send(f()); 152 - }))); 153 - rx.recv_timeout(BROKER_TIMEOUT).ok() 154 - } 155 - 156 - /// Drain the channel and execute everything pending. Called once per 157 - /// broker iteration. Non-blocking — returns when the queue is empty. 158 - /// Must be called from the broker thread (a real Rockbox kernel thread). 159 - pub fn drain(rx: &Receiver<FwCmd>) { 160 - loop { 161 - match rx.try_recv() { 162 - Ok(cmd) => { 163 - execute_on_broker(cmd, &|| true).unwrap_or_else(|e| { 164 - tracing::warn!("fw_bus: broker exec failed: {e}"); 165 - }); 166 - } 167 - Err(TryRecvError::Empty) => return, 168 - Err(TryRecvError::Disconnected) => { 169 - tracing::error!("fw_bus: sender disconnected — bus shutdown"); 170 - return; 171 - } 172 - } 173 - } 174 - } 175 - 176 - /// Like drain() but blocks up to `timeout` waiting for the first command. 177 - /// When a command arrives before the timeout the broker wakes up immediately 178 - /// instead of waiting for the next poll cycle (≤100 ms). Use this in place 179 - /// of `thread::sleep(timeout)` at the bottom of the broker loop. 180 - pub fn drain_blocking(rx: &Receiver<FwCmd>, timeout: std::time::Duration) { 181 - match rx.recv_timeout(timeout) { 182 - Ok(cmd) => { 183 - execute_on_broker(cmd, &|| true).unwrap_or_else(|e| { 184 - tracing::warn!("fw_bus: broker exec failed: {e}"); 185 - }); 186 - drain(rx); 187 - } 188 - Err(mpsc::RecvTimeoutError::Disconnected) => { 189 - tracing::error!("fw_bus: sender disconnected — bus shutdown"); 190 - } 191 - Err(mpsc::RecvTimeoutError::Timeout) => {} 192 - } 193 - } 194 - 195 - fn execute_on_broker(cmd: FwCmd, _alive: &dyn Fn() -> bool) -> Result<(), &'static str> { 196 - use rockbox_sys as rb; 197 - macro_rules! reply { 198 - ($r:expr) => { 199 - if let Some(r) = $r { 200 - let _ = r.send(()); 201 - } 202 - }; 203 - } 204 - match cmd { 205 - FwCmd::Play { 206 - elapsed, 207 - offset, 208 - reply, 209 - } => { 210 - rb::playback::play(elapsed, offset); 211 - reply!(reply); 212 - } 213 - FwCmd::Pause { reply } => { 214 - rb::playback::pause(); 215 - reply!(reply); 216 - } 217 - FwCmd::Resume { reply } => { 218 - rb::playback::resume(); 219 - reply!(reply); 220 - } 221 - FwCmd::Next { reply } => { 222 - rb::playback::next(); 223 - reply!(reply); 224 - } 225 - FwCmd::Prev { reply } => { 226 - rb::playback::prev(); 227 - reply!(reply); 228 - } 229 - FwCmd::Stop { reply } => { 230 - rb::playback::hard_stop(); 231 - reply!(reply); 232 - } 233 - FwCmd::FfRewind { newtime, reply } => { 234 - rb::playback::ff_rewind(newtime); 235 - reply!(reply); 236 - } 237 - FwCmd::FlushAndReloadTracks { reply } => { 238 - rb::playback::flush_and_reload_tracks(); 239 - reply!(reply); 240 - } 241 - FwCmd::SetCrossfade { value, reply } => { 242 - rb::sound::audio_set_crossfade_safe(value); 243 - reply!(reply); 244 - } 245 - FwCmd::Custom(f) => f(), 246 - } 247 - Ok(()) 248 - }
+8 -26
crates/server/src/handlers/player.rs
··· 131 131 let elapsed = query.elapsed.unwrap_or(0); 132 132 let offset = query.offset.unwrap_or(0); 133 133 134 - // Route through fw_bus so audio_play() runs on the broker (a real 135 - // Rockbox kernel thread), not on the actix worker. Calling firmware 136 - // kernel functions from a non-Rockbox pthread corrupts the global 137 - // `__cores[0].running` slot and crashes the scheduler — see 138 - // crates/server/src/fw_bus.rs. 139 134 web::block(move || { 140 135 let _player_mutex = PLAYER_MUTEX.lock().unwrap(); 141 136 if state.player.lock().unwrap().is_none() { 142 - crate::fw_bus::send_and_wait(|reply| crate::fw_bus::FwCmd::Play { 143 - elapsed, 144 - offset, 145 - reply: Some(reply), 146 - }); 137 + rb::with_kernel_lock(|| rb::playback::play(elapsed, offset)); 147 138 } 148 139 }) 149 140 .await ··· 163 154 } else { 164 155 web::block(move || { 165 156 let _player_mutex = PLAYER_MUTEX.lock().unwrap(); 166 - crate::fw_bus::send_and_wait(|reply| crate::fw_bus::FwCmd::Pause { 167 - reply: Some(reply), 168 - }); 157 + rb::with_kernel_lock(|| rb::playback::pause()); 169 158 }) 170 159 .await 171 160 .map_err(ErrorInternalServerError)?; ··· 183 172 let newtime = query.newtime.unwrap_or(0); 184 173 web::block(move || { 185 174 let _player_mutex = PLAYER_MUTEX.lock().unwrap(); 186 - crate::fw_bus::send_and_wait(|reply| crate::fw_bus::FwCmd::FfRewind { 187 - newtime, 188 - reply: Some(reply), 189 - }); 175 + rb::with_kernel_lock(|| rb::playback::ff_rewind(newtime)); 190 176 }) 191 177 .await 192 178 .map_err(ErrorInternalServerError)?; ··· 339 325 pub async fn flush_and_reload_tracks() -> HandlerResult { 340 326 web::block(|| { 341 327 let _player_mutex = PLAYER_MUTEX.lock().unwrap(); 342 - crate::fw_bus::send_and_wait(|reply| crate::fw_bus::FwCmd::FlushAndReloadTracks { 343 - reply: Some(reply), 344 - }); 328 + rb::with_kernel_lock(|| rb::playback::flush_and_reload_tracks()); 345 329 }) 346 330 .await 347 331 .map_err(ErrorInternalServerError)?; ··· 359 343 } else { 360 344 web::block(|| { 361 345 let _player_mutex = PLAYER_MUTEX.lock().unwrap(); 362 - crate::fw_bus::send_and_wait(|reply| crate::fw_bus::FwCmd::Resume { 363 - reply: Some(reply), 364 - }); 346 + rb::with_kernel_lock(|| rb::playback::resume()); 365 347 }) 366 348 .await 367 349 .map_err(ErrorInternalServerError)?; ··· 373 355 pub async fn next() -> HandlerResult { 374 356 web::block(|| { 375 357 let _player_mutex = PLAYER_MUTEX.lock().unwrap(); 376 - crate::fw_bus::send_and_wait(|reply| crate::fw_bus::FwCmd::Next { reply: Some(reply) }); 358 + rb::with_kernel_lock(|| rb::playback::next()); 377 359 }) 378 360 .await 379 361 .map_err(ErrorInternalServerError)?; ··· 383 365 pub async fn previous() -> HandlerResult { 384 366 web::block(|| { 385 367 let _player_mutex = PLAYER_MUTEX.lock().unwrap(); 386 - crate::fw_bus::send_and_wait(|reply| crate::fw_bus::FwCmd::Prev { reply: Some(reply) }); 368 + rb::with_kernel_lock(|| rb::playback::prev()); 387 369 }) 388 370 .await 389 371 .map_err(ErrorInternalServerError)?; ··· 393 375 pub async fn stop() -> HandlerResult { 394 376 web::block(|| { 395 377 let _player_mutex = PLAYER_MUTEX.lock().unwrap(); 396 - crate::fw_bus::send_and_wait(|reply| crate::fw_bus::FwCmd::Stop { reply: Some(reply) }); 378 + rb::with_kernel_lock(|| rb::playback::hard_stop()); 397 379 }) 398 380 .await 399 381 .map_err(ErrorInternalServerError)?;
+7 -16
crates/server/src/handlers/playlists.rs
··· 104 104 105 105 let start_index = web::block(move || { 106 106 let _player_mutex = PLAYER_MUTEX.lock().unwrap(); 107 - // hard_stop / playlist_create / build_playlist all touch 108 - // firmware kernel state — route through the broker so they run 109 - // with the right __cores[0].running entry. 110 - crate::fw_bus::run_on_broker(move || { 107 + rb::with_kernel_lock(move || { 111 108 let current_is_http = rb::playback::current_track() 112 109 .map(|t| t.path.starts_with("http://") || t.path.starts_with("https://")) 113 110 .unwrap_or(false); ··· 153 150 let offset = query.offset.unwrap_or(0); 154 151 web::block(move || { 155 152 let _player_mutex = PLAYER_MUTEX.lock().unwrap(); 156 - // playlist_start() inside firmware sends Q_AUDIO_PLAY to audio_thread 157 - // → halt_decoding_track → codec_stop → kernel scheduler. Must run on 158 - // the broker (real kernel thread) — see crates/server/src/fw_bus.rs. 159 - crate::fw_bus::run_on_broker(move || { 153 + rb::with_kernel_lock(move || { 160 154 rb::playlist::start(start_index, elapsed, offset); 161 155 }); 162 156 PLAYLIST_DIRTY.store(true, Ordering::Relaxed); ··· 175 169 let start_index = query.start_index.unwrap_or(0); 176 170 let ret = web::block(move || { 177 171 let _player_mutex = PLAYER_MUTEX.lock().unwrap(); 178 - crate::fw_bus::run_on_broker(move || { 172 + rb::with_kernel_lock(move || { 179 173 let seed = rb::system::current_tick(); 180 174 let ret = rb::playlist::shuffle(seed as i32, start_index); 181 175 PLAYLIST_DIRTY.store(true, Ordering::Relaxed); ··· 200 194 pub async fn resume_playlist() -> HandlerResult { 201 195 let code = web::block(|| { 202 196 let _player_mutex = PLAYER_MUTEX.lock().unwrap(); 203 - crate::fw_bus::run_on_broker(|| { 197 + rb::with_kernel_lock(|| { 204 198 let status = rb::system::get_global_status(); 205 199 let playback_status = rb::playback::status(); 206 200 if status.resume_index == -1 || playback_status.status == 1 { ··· 219 213 pub async fn resume_track() -> HandlerResult { 220 214 web::block(|| { 221 215 let _player_mutex = PLAYER_MUTEX.lock().unwrap(); 222 - crate::fw_bus::run_on_broker(|| { 216 + rb::with_kernel_lock(|| { 223 217 let status = rb::system::get_global_status(); 224 218 if status.resume_index == -1 { 225 219 return; ··· 363 357 } 364 358 } 365 359 366 - // Built-in player: all firmware kernel calls must run on the broker 367 - // thread (real Rockbox kernel thread) — see crates/server/src/fw_bus.rs. 368 360 let response_body = web::block(move || -> Result<String, String> { 369 361 let _player_mutex = PLAYER_MUTEX.lock().unwrap(); 370 - crate::fw_bus::try_run_on_broker(move || { 362 + rb::with_kernel_lock(move || { 371 363 let amount = rb::playlist::amount(); 372 364 373 365 if amount == 0 { ··· 403 395 PLAYLIST_DIRTY.store(true, Ordering::Relaxed); 404 396 Ok(tracklist.position.to_string()) 405 397 }) 406 - .unwrap_or_else(|| Err("broker timed out".to_string())) 407 398 }) 408 399 .await 409 400 .map_err(ErrorInternalServerError)? ··· 427 418 } 428 419 drop(player); 429 420 430 - crate::fw_bus::run_on_broker(move || { 421 + rb::with_kernel_lock(move || { 431 422 let mut ret = 0; 432 423 433 424 for position in &params.positions {
+1 -4
crates/server/src/handlers/saved_playlists.rs
··· 239 239 240 240 web::block(move || { 241 241 let _player_mutex = PLAYER_MUTEX.lock().unwrap(); 242 - // Build + start a saved playlist — `playlist_start` triggers 243 - // halt_decoding_track / codec_stop in the kernel scheduler. 244 - // Must run on the broker (real Rockbox kernel thread). 245 - crate::fw_bus::run_on_broker(move || { 242 + rb::with_kernel_lock(move || { 246 243 let first = &paths[0]; 247 244 let dir = { 248 245 let parts: Vec<_> = first.split('/').collect();
+6 -17
crates/server/src/handlers/settings.rs
··· 18 18 19 19 pub async fn update_global_settings(body: web::Json<NewGlobalSettings>) -> HandlerResult { 20 20 let settings = body.into_inner(); 21 - // Route the settings apply through the firmware-command bus — most 22 - // settings just write a value, but `crossfade` calls 23 - // `audio_set_crossfade()` which queues `Q_AUDIO_REMAKE_AUDIO_BUFFER` 24 - // and ends up in the kernel scheduler. From an actix worker the 25 - // scheduler reads the wrong `__cores[0].running` slot and corrupts 26 - // kernel-thread state — see crates/server/src/fw_bus.rs. Run the 27 - // whole load_settings on the broker (a real Rockbox kernel thread) 28 - // so the FFI calls resolve to a sane current-thread. 29 21 web::block(move || { 30 22 let _player_mutex = PLAYER_MUTEX.lock().unwrap(); 31 - crate::fw_bus::send_and_wait(|reply| { 32 - crate::fw_bus::FwCmd::Custom(Box::new(move || { 33 - if let Err(e) = rockbox_settings::load_settings(Some(settings)) { 34 - tracing::error!("update_global_settings: load_settings failed: {e}"); 35 - } else if let Err(e) = rockbox_settings::write_settings() { 36 - tracing::error!("update_global_settings: write_settings failed: {e}"); 37 - } 38 - let _ = reply.send(()); 39 - })) 23 + rb::with_kernel_lock(move || { 24 + if let Err(e) = rockbox_settings::load_settings(Some(settings)) { 25 + tracing::error!("update_global_settings: load_settings failed: {e}"); 26 + } else if let Err(e) = rockbox_settings::write_settings() { 27 + tracing::error!("update_global_settings: write_settings failed: {e}"); 28 + } 40 29 }); 41 30 }) 42 31 .await
+1 -3
crates/server/src/handlers/smart_playlists.rs
··· 211 211 let paths: Vec<String> = tracks.iter().map(|t| t.path.clone()).collect(); 212 212 web::block(move || { 213 213 let _player_mutex = PLAYER_MUTEX.lock().unwrap(); 214 - // Same broker routing as saved_playlists::play_smart_playlist — 215 - // playlist_start hits the kernel scheduler. 216 - crate::fw_bus::run_on_broker(move || { 214 + rb::with_kernel_lock(move || { 217 215 let first = &paths[0]; 218 216 let dir = { 219 217 let parts: Vec<_> = first.split('/').collect();
+5 -107
crates/server/src/lib.rs
··· 8 8 }; 9 9 use rockbox_library::repo; 10 10 use rockbox_mpd::MpdServer; 11 - use rockbox_sys::events::RockboxCommand; 12 11 use rockbox_sys::{self as rb, types::mp3_entry::Mp3Entry}; 13 12 use sqlx::{Pool, Sqlite}; 14 13 use std::{ ··· 26 25 pub(crate) static PLAYLIST_DIRTY: AtomicBool = AtomicBool::new(false); 27 26 28 27 pub mod cache; 29 - pub mod fw_bus; 30 28 pub mod handlers; 31 29 pub mod http; 32 30 pub mod kv; ··· 550 548 551 549 #[no_mangle] 552 550 pub extern "C" fn start_servers() { 553 - // Set up the firmware-command bus before any HTTP/gRPC handler can 554 - // accept a request — handlers will enqueue here and the broker thread 555 - // (a real Rockbox kernel thread) will execute synchronously on its 556 - // own pthread context. See crates/server/src/fw_bus.rs. 557 - fw_bus::init(); 558 - 559 - let (cmd_tx, cmd_rx) = std::sync::mpsc::channel::<RockboxCommand>(); 560 - let cmd_tx = Arc::new(Mutex::new(cmd_tx)); 561 - 562 - thread::spawn(move || { 563 - let port = std::env::var("ROCKBOX_TCP_PORT").unwrap_or_else(|_| "6063".to_string()); 564 - let url = format!("http://127.0.0.1:{}", port); 565 - let client = reqwest::blocking::Client::new(); 566 - 567 - while let Ok(event) = cmd_rx.recv() { 568 - match event { 569 - RockboxCommand::Play(elapsed, offset) => { 570 - client 571 - .put(&format!( 572 - "{}/player/play?elapsed={}&offset={}", 573 - url, elapsed, offset 574 - )) 575 - .send() 576 - .unwrap(); 577 - } 578 - RockboxCommand::Pause => { 579 - client.put(&format!("{}/player/pause", url)).send().unwrap(); 580 - } 581 - RockboxCommand::Resume => { 582 - client 583 - .put(&format!("{}/player/resume", url)) 584 - .send() 585 - .unwrap(); 586 - } 587 - RockboxCommand::Next => { 588 - client.put(&format!("{}/player/next", url)).send().unwrap(); 589 - } 590 - RockboxCommand::Prev => { 591 - client 592 - .put(&format!("{}/player/previous", url)) 593 - .send() 594 - .unwrap(); 595 - } 596 - RockboxCommand::FfRewind(newtime) => { 597 - client 598 - .put(&format!("{}/player/ff-rewind?newtime={}", url, newtime)) 599 - .send() 600 - .unwrap(); 601 - } 602 - RockboxCommand::FlushAndReloadTracks => { 603 - client 604 - .put(&format!("{}/player/flush-and-reload-tracks", url)) 605 - .send() 606 - .unwrap(); 607 - } 608 - RockboxCommand::Stop => { 609 - client.put(&format!("{}/player/stop", url)).send().unwrap(); 610 - } 611 - RockboxCommand::PlaylistResume => { 612 - client 613 - .put(&format!("{}/playlists/resume", url)) 614 - .send() 615 - .unwrap(); 616 - } 617 - RockboxCommand::PlaylistResumeTrack => { 618 - client 619 - .put(&format!("{}/playlists/resume-track", url)) 620 - .send() 621 - .unwrap(); 622 - } 623 - } 624 - } 625 - }); 626 - 627 - let cloned_cmd_tx = cmd_tx.clone(); 628 - 629 551 thread::spawn(move || { 630 552 let runtime = tokio::runtime::Builder::new_current_thread() 631 553 .enable_all() 632 554 .build() 633 555 .unwrap(); 634 - match runtime.block_on(rockbox_rpc::server::start(cmd_tx.clone())) { 556 + match runtime.block_on(rockbox_rpc::server::start()) { 635 557 Ok(_) => {} 636 558 Err(e) => { 637 559 error!("Error starting server: {}", e); ··· 644 566 .enable_all() 645 567 .build() 646 568 .unwrap(); 647 - match runtime.block_on(rockbox_graphql::server::start(cloned_cmd_tx.clone())) { 569 + match runtime.block_on(rockbox_graphql::server::start()) { 648 570 Ok(_) => {} 649 571 Err(e) => { 650 572 error!("Error starting server: {}", e); ··· 709 631 let mut last_stats_elapsed: u64 = 0; 710 632 let mut last_stats_length: u64 = 0; 711 633 712 - // Take ownership of the firmware-command bus receiver. We're a real 713 - // Rockbox kernel thread (created via apps/broker_thread.c::create_thread), 714 - // so any FFI we run from drain() resolves __running_self_entry() to 715 - // OUR thread_entry — safe to mutate kernel state. 716 - let fw_rx = fw_bus::take_receiver(); 717 - 718 634 loop { 719 - // Drain the bus first so handler-issued commands run with minimal 720 - // latency (~ broker tick period, ~10 ms idle). 721 - if let Some(rx) = fw_rx.as_ref() { 722 - fw_bus::drain(rx); 723 - } 724 - 725 635 let mutex = GLOBAL_MUTEX.lock().unwrap(); 726 636 if *mutex == 1 { 727 637 drop(mutex); 728 - if let Some(rx) = fw_rx.as_ref() { 729 - fw_bus::drain_blocking(rx, std::time::Duration::from_millis(100)); 730 - } else { 731 - thread::sleep(std::time::Duration::from_millis(100)); 732 - } 638 + thread::sleep(std::time::Duration::from_millis(100)); 733 639 rb::system::sleep(rb::HZ); 734 640 continue; 735 641 } ··· 901 807 902 808 if !index_changed && !content_changed { 903 809 drop(player_mutex); 904 - if let Some(rx) = fw_rx.as_ref() { 905 - fw_bus::drain_blocking(rx, std::time::Duration::from_millis(100)); 906 - } else { 907 - thread::sleep(std::time::Duration::from_millis(100)); 908 - } 810 + thread::sleep(std::time::Duration::from_millis(100)); 909 811 rb::system::sleep(rb::HZ); 910 812 continue; 911 813 } ··· 1008 910 tracks: entries.into_iter().map(|t| t.into()).collect(), 1009 911 }); 1010 912 1011 - if let Some(rx) = fw_rx.as_ref() { 1012 - fw_bus::drain_blocking(rx, std::time::Duration::from_millis(100)); 1013 - } else { 1014 - thread::sleep(std::time::Duration::from_millis(100)); 1015 - } 913 + thread::sleep(std::time::Duration::from_millis(100)); 1016 914 rb::system::sleep(rb::HZ); 1017 915 } 1018 916 }
+18
crates/sys/src/lib.rs
··· 1388 1388 fn plugin_get_buffer(); 1389 1389 fn plugin_get_current_filename(); 1390 1390 fn plugin_reserve_buffer(); 1391 + 1392 + // Kernel lock for Rust OS threads (headless hosted only; no-op on SDL) 1393 + pub fn rb_kernel_lock(); 1394 + pub fn rb_kernel_unlock(); 1395 + } 1396 + 1397 + /// Execute `f` under the Rockbox cooperative kernel lock and return its value. 1398 + /// 1399 + /// Any OS thread (e.g. a tokio/actix worker) may call Rockbox firmware FFI 1400 + /// safely by wrapping the call with this function. On the headless target 1401 + /// this acquires g_mutex so no Rockbox kernel thread runs concurrently, then 1402 + /// installs g_external_entry as the current thread. On SDL builds 1403 + /// `rb_kernel_lock` is a no-op (weak-symbol stub in broker_thread.c). 1404 + pub fn with_kernel_lock<T, F: FnOnce() -> T>(f: F) -> T { 1405 + unsafe { rb_kernel_lock() }; 1406 + let result = f(); 1407 + unsafe { rb_kernel_unlock() }; 1408 + result 1391 1409 }
+54
firmware/target/hosted/headless/thread-posix.c
··· 123 123 124 124 static jmp_buf thread_jmpbufs[MAXTHREADS]; 125 125 static pthread_mutex_t g_mutex; 126 + static struct thread_entry *g_external_entry = NULL; 126 127 127 128 #define THREADS_RUN 0 128 129 #define THREADS_EXIT 1 ··· 379 380 return; 380 381 } 381 382 383 + /* Allocate a stub entry for external (Rust OS thread) callers. 384 + * rb_kernel_lock() sets __running_self_entry() to this entry while the 385 + * caller holds g_mutex, so the kernel always sees a valid STATE_RUNNING 386 + * thread_entry without needing a dedicated broker thread. */ 387 + g_external_entry = thread_alloc(); 388 + if (!g_external_entry) { 389 + fprintf(stderr, "posix: external entry alloc failed\n"); 390 + return; 391 + } 392 + g_external_entry->name = "rb_external"; 393 + g_external_entry->state = STATE_RUNNING; 394 + g_external_entry->context.s = psem_create(0); 395 + g_external_entry->context.t = NULL; 396 + g_external_entry->context.told = NULL; 397 + 398 + if (!g_external_entry->context.s) { 399 + fprintf(stderr, "posix: external semaphore alloc failed\n"); 400 + return; 401 + } 402 + 382 403 /* setjmp here is the exit trampoline: if thread_exit() longjmps here 383 404 * for the main thread we fall through and exit the process. */ 384 405 if (setjmp(thread_jmpbufs[THREAD_ID_SLOT(thread->id)]) == 0) ··· 386 407 387 408 pthread_mutex_unlock(&g_mutex); 388 409 exit(0); 410 + } 411 + 412 + /* ── Rust OS thread kernel-lock API ─────────────────────────────────────────── 413 + * 414 + * Any OS thread (e.g. a tokio/actix worker) may call Rockbox firmware FFI 415 + * safely by bracketing the call with rb_kernel_lock() / rb_kernel_unlock(): 416 + * 417 + * rb_kernel_lock(); 418 + * audio_play(elapsed, offset); 419 + * rb_kernel_unlock(); 420 + * 421 + * rb_kernel_lock() acquires the global cooperative mutex (g_mutex) so no 422 + * Rockbox kernel thread can run concurrently, then installs g_external_entry 423 + * as the current thread so __running_self_entry() always returns a valid 424 + * STATE_RUNNING entry. rb_kernel_unlock() reverses both steps. 425 + * 426 + * These replace the fw_bus broker channel: direct in-line calls with O(1) 427 + * overhead instead of mpsc round-trips with a 30 s timeout safety net. 428 + * 429 + * macOS: pthread_mutex_lock/unlock work identically on Monterey x86_64 and 430 + * Sequoia arm64 — same as the rest of this file. 431 + */ 432 + 433 + void rb_kernel_lock(void) 434 + { 435 + pthread_mutex_lock(&g_mutex); 436 + __running_self_entry() = g_external_entry; 437 + } 438 + 439 + void rb_kernel_unlock(void) 440 + { 441 + __running_self_entry() = NULL; 442 + pthread_mutex_unlock(&g_mutex); 389 443 } 390 444 391 445 /* ── Priority scheduling stub ─────────────────────────────────────────────────
macos/Rockbox.xcodeproj/project.xcworkspace/xcuserdata/tsirysandratraina.xcuserdatad/UserInterfaceState.xcuserstate

This is a binary file and will not be displayed.

+37 -13
tools/configure
··· 517 517 518 518 thread_support= 519 519 if [ -z "$ARG_THREAD_SUPPORT" ] || [ "$ARG_THREAD_SUPPORT" = "0" ]; then 520 - if [ "$sigaltstack" = "0" ]; then 521 - thread_support="HAVE_SIGALTSTACK_THREADS" 522 - LDOPTS="$LDOPTS -lpthread" # pthread needed 523 - echo "Selected sigaltstack threads" 524 - elif [ "$fibers" = "0" ]; then 525 - thread_support="HAVE_WIN32_FIBER_THREADS" 526 - echo "Selected Win32 Fiber threads" 527 - fi 520 + # Prefer HAVE_POSIX_THREADS on POSIX platforms (Darwin/Linux): one pthread 521 + # per Rockbox thread with a global cooperative mutex, enabling 522 + # rb_kernel_lock/rb_kernel_unlock for safe FFI from Rust OS threads. 523 + # Replaces HAVE_SIGALTSTACK_THREADS (green coroutines, no global mutex). 524 + case `uname -s` in 525 + Darwin|Linux) 526 + thread_support="HAVE_POSIX_THREADS" 527 + thread_support_comment="/* the threading backend we use. 528 + * HAVE_POSIX_THREADS: one pthread per Rockbox thread, g_mutex for cooperative 529 + * exclusion, rb_kernel_lock/rb_kernel_unlock for safe FFI from Rust OS threads. 530 + * Replaces HAVE_SIGALTSTACK_THREADS (green coroutines in a single OS thread, 531 + * no global mutex, incompatible with rb_kernel_lock). */" 532 + LDOPTS="$LDOPTS -lpthread" 533 + echo "Selected POSIX threads" 534 + ;; 535 + *) 536 + if [ "$sigaltstack" = "0" ]; then 537 + thread_support="HAVE_SIGALTSTACK_THREADS" 538 + LDOPTS="$LDOPTS -lpthread" # pthread needed 539 + echo "Selected sigaltstack threads" 540 + elif [ "$fibers" = "0" ]; then 541 + thread_support="HAVE_WIN32_FIBER_THREADS" 542 + echo "Selected Win32 Fiber threads" 543 + fi 544 + ;; 545 + esac 528 546 fi 529 547 530 548 if [ -n `echo $app_type | grep "sdl"` ] && [ -z "$thread_support" ] \ ··· 818 836 GLOBAL_LDOPTS="" 819 837 LDOPTS="-llog -landroid -laaudio" 820 838 821 - # firmware/asm/thread.c handles HAVE_SIGALTSTACK_THREADS via thread-unix.c 822 - # which works on bionic. HAVE_PTHREAD_THREADS is set by androidcc but not 823 - # actually consumed by anything in firmware/ — would land us at the 824 - # "Missing thread impl" error. 825 - thread_support="HAVE_SIGALTSTACK_THREADS" 839 + # HAVE_POSIX_THREADS: one pthread per Rockbox thread + g_mutex for cooperative 840 + # exclusion. rb_kernel_lock/rb_kernel_unlock let any Rust OS thread call 841 + # firmware FFI safely. Android bionic has full pthread support (API 21+). 842 + # Replaces HAVE_SIGALTSTACK_THREADS (green coroutines, no global mutex, 843 + # incompatible with rb_kernel_lock). 844 + thread_support="HAVE_POSIX_THREADS" 845 + thread_support_comment="/* the threading backend we use. 846 + * HAVE_POSIX_THREADS: one pthread per Rockbox thread, g_mutex for cooperative 847 + * exclusion, rb_kernel_lock/rb_kernel_unlock for safe FFI from Rust OS threads. 848 + * Replaces HAVE_SIGALTSTACK_THREADS (green coroutines in a single OS thread, 849 + * no global mutex, incompatible with rb_kernel_lock). */" 826 850 gccchoice="" 827 851 gcctarget="${cc_triple}-" 828 852