Skip to content

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

Description

@yilin0518

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!

Metadata

Metadata

Assignees

No one assigned

    Labels

    C-BugAn unexpected or incorrect behaviorS-Needs-TriageThis issue needs to be labelled

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions