`initialize_renderer` can panic on macOS when safely polled from a worker thread
- Dominant language
- Rust
- Stars
- 48.2k
- Forks
- 4.8k
- Avg merge
- 3d 22h
- Merged PRs (30d)
- 161
Description
## Bevy version and features
- Bevy `main` at commit [`87a24bf`](https://github.com/bevyengine/bevy/commit/87a24bf), plus the PoC-only workflow commits on my fork.
- The reproducer uses the workspace crates directly rather than the `bevy` meta-crate:
```toml
bevy_render = { path = "../bevy/crates/bevy_render" }
bevy_window = { path = "../bevy/crates/bevy_window" }
bevy_platform = { path = "../bevy/crates/bevy_platform" }
bevy_tasks = { path = "../bevy/crates/bevy_tasks" }
```
- No non-default Bevy feature combination was intentionally enabled by the reproducer.
- The reproducer also depends on winit = "0.30.13" to create a native macOS window.
## \[Optional\] Relevant system information
The issue was reproduced on a GitHub-hosted macos-14 ARM64 runner.
The failure occurs while creating a Metal surface from an AppKit NSView, before normal renderer initialization completes. Therefore, there is no useful AdapterInfo output.
The relevant upstream components are:
- raw-window-handle 0.6.2, which documents that AppKit NSView handles may only be accessed on the application main thread.
- raw-window-metal 1.1.0, which checks this requirement with MainThreadMarker::new() and panics when it is violated.
- wgpu 30.0.0 / wgpu-hal 30.0.0, which create the Metal surface from the provided window handle.
## What you did
I created a valid winit window on the macOS process main thread, converted it into Bevy's RawHandleWrapperHolder, and then moved that holder into a normal std::thread::spawn worker thread.
The worker thread safely polls the public bevy_render::renderer::initialize_renderer future:
```
use std::{sync::Arc, thread};
use bevy_platform::sync::Mutex;
use bevy_render::{
renderer::initialize_renderer,
settings::{Backends, WgpuSettings},
};
use bevy_window::{RawHandleWrapper, RawHandleWrapperHolder, WindowWrapper};
use winit::{event_loop::EventLoop, window::Window};
fn main() {
// This runs on macOS's process main thread.
let event_loop = EventLoop::new().expect("create event loop");
let window = event_loop
.create_window(Window::default_attributes())
.expect("create window");
let window = WindowWrapper::new(window);
let raw_handle = RawHandleWrapper::new(&window).expect("obtain raw handles");
let holder = RawHandleWrapperHolder(Arc::new(Mutex::new(Some(raw_handle))));
let settings = WgpuSettings::default();
// The following path uses no application-side unsafe code.
thread::spawn(move || {
let _renderer = bevy_tasks::block_on(initialize_renderer(
Backends::all(),
Some(holder),
&settings,
));
})
.join()
.expect("worker thread panicked");
}
```
## What went wrong
### What I expected
Because initialize_renderer is a safe public API and its future can be moved to a worker thread, I expected one of the following:
- the API to preserve the platform requirement that the primary window handle is used on the correct UI/main thread; or
- the thread requirement to be represented by the API/type system; or
- initialization to fail in a controlled way rather than allow a main-thread-only AppKit handle to reach a worker thread.
### What actually happened
`initialize_renderer` [internally calls](https://github.com/bevyengine/bevy/blob/main/crates/bevy_render/src/renderer/mod.rs#L242) the unsafe `RawHandleWrapper::get_handle()` method and passes the result to `wgpu::Instance::create_surface`:
```rust
// crates/bevy_render/src/renderer/mod.rs
let surface = primary_window.and_then(|wrapper| {
let maybe_handle = wrapper
.0
.lock()
.expect("Couldn't get the window handle in time for renderer initialization");
if let Some(wrapper) = maybe_handle.as_ref() {
// SAFETY: Plugins should be set up on the main thread.
let handle = unsafe { wrapper.get_handle() };
Some(
instance
.create_surface(handle)
.expect("Failed to create wgpu surface"),
)
} else {
None
}
});
```
`RawHandleWrapper::get_handle()` documents that its caller must ensure that the native handle is used in a valid platform-specific context, including the main thread where required.
However, the safe initialize_renderer API does not establish or check that precondition. The comment assumes that plugins are set up on the main thread, but the public API itself permits the future to be polled from another thread.
On macOS, this deterministically panics during Metal surface creation:
thread '' panicked: can only access NSView on the main thread
The relevant backtrace is:
```text
::from_ns_view
::create_surface
::create_surface
bevy_render::renderer::initialize_renderer
bevy_initialize_renderer_thread_poc::main::{closure}
```
The main thread then panics because it joins the worker thread with expect("worker thread panicked").
## Additional information
I also verified the call site under LLDB on a real macos-14 runner.
LLDB stopped at Bevy's raw-handle conversion:
::window_handle at crates/bevy_window/src/raw_handle.rs:148
At that point:
```
(lldb) expr -- (int)pthread_main_np()
(int) $0 = 0
```
This confirms that Bevy exposes the AppKit handle from a non-main thread.
The backtrace shows the complete path:
```text
std::thread::spawn worker
→ bevy_tasks::block_on(initialize_renderer(...))
→ bevy_render::renderer::initialize_renderer
→ wgpu::Instance::create_surface
→ ThreadLockedRawWindowHandleWrapper::window_handle
```
The process main thread is simultaneously blocked in JoinHandle::join.
The complete macOS GitHub Actions reproduction is available here: https://github.com/yilin0518/bevy/actions/runs/30707012194
The current observable impact is a deterministic application panic / denial of service during renderer initialization on macOS. I have not demonstrated memory corruption or arbitrary code execution.
So I think this API has some potential problem needed to be fixed. One of my question is: Whether this function should be marked `unsafe` and explicitly mention the safety requirement?
Thank you for your suggestion and reply!
Contributor guide
Research direction
Start in crates/bevy_render/src/renderer/mod.rs at initialize_renderer and inspect the unsafe RawHandleWrapper::get_handle() call, then read crates/bevy_window/src/raw_handle.rs around line 148. Run the macOS reproduction described in the issue and verify how the API should represent or enforce the main-thread requirement. Done means worker-thread polling no longer silently reaches macOS surface creation and panics.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- macos, rust
- Domain
- game-dev, operating-systems
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100