Bevy version and features
- Bevy
main at commit 87a24bf, plus the PoC-only workflow commits on my fork.
- The reproducer uses the workspace crates directly rather than the
bevy meta-crate:
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 the unsafe RawHandleWrapper::get_handle() method and passes the result to wgpu::Instance::create_surface:
// 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:
<raw_window_metal::Layer>::from_ns_view
<wgpu_hal::metal::Instance as wgpu_hal::Instance>::create_surface
<wgpu::api::instance::Instance>::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:
<bevy_window::raw_handle::ThreadLockedRawWindowHandleWrapper as raw_window_handle::borrowed::HasWindowHandle>::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:
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!
Bevy version and features
mainat commit87a24bf, plus the PoC-only workflow commits on my fork.bevymeta-crate:[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:
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:
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:
What actually happened
initialize_rendererinternally calls the unsafeRawHandleWrapper::get_handle()method and passes the result towgpu::Instance::create_surface: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:
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:
<bevy_window::raw_handle::ThreadLockedRawWindowHandleWrapper as raw_window_handle::borrowed::HasWindowHandle>::window_handle at crates/bevy_window/src/raw_handle.rs:148
At that point:
This confirms that Bevy exposes the AppKit handle from a non-main thread.
The backtrace shows the complete path:
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
unsafeand explicitly mention the safety requirement?Thank you for your suggestion and reply!