bevyengine / bevyengine/bevy

`initialize_renderer` can panic on macOS when safely polled from a worker thread

Open
#25,255 0 comments 0 reactions 0 assignees View on GitHub
A-Rendering C-Bug I-Crash O-MacOS S-Needs-Investigation
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.