Skip to content

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

Open

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!

Activity

  1. added
    C-BugAn unexpected or incorrect behavior
    S-Needs-TriageThis issue needs to be labelled
    on Aug 1, 2026
  2. added
    A-RenderingDrawing game state to the screen
    I-CrashA sudden unexpected crash
    O-MacOSSpecific to the MacOS (Apple) desktop operating system
    S-Needs-InvestigationThis issue requires detective work to figure out what's going wrong
    and removed
    S-Needs-TriageThis issue needs to be labelled
    on Aug 6, 2026
  3. moved this to Needs SME Triage in Renderingon Aug 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    A-RenderingDrawing game state to the screenC-BugAn unexpected or incorrect behaviorI-CrashA sudden unexpected crashO-MacOSSpecific to the MacOS (Apple) desktop operating systemS-Needs-InvestigationThis issue requires detective work to figure out what's going wrong

    Type

    No type

    Projects

    • Status
      Needs SME Triage

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions