Skip to content

fix: keep tao window_target in main thread#15411

Draft
Legend-Master wants to merge 2 commits into
tauri-apps:devfrom
Legend-Master:keep-event-target-in-main-thread
Draft

fix: keep tao window_target in main thread#15411
Legend-Master wants to merge 2 commits into
tauri-apps:devfrom
Legend-Master:keep-event-target-in-main-thread

Conversation

@Legend-Master
Copy link
Copy Markdown
Contributor

@Legend-Master Legend-Master commented May 18, 2026

An attempt to fix #15408

By keeping an Arc in Wry and only keep an Weak in Context, we make sure we only ever upgrade/use/drop the window_target on main thread. (previously, the clone/drop can be made in another thread from cloning and sending to another thread)

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 18, 2026

Package Changes Through e4c2c60

There are 3 changes which include tauri-utils with patch, tauri-bundler with patch, tauri with patch

Planned Package Versions

The following package releases are the planned based on the context of changes in this pull request.

package current next
tauri-utils 2.9.2 2.9.3
tauri-bundler 2.9.2 2.9.3
tauri-runtime 2.11.2 2.11.3
tauri-runtime-wry 2.11.2 2.11.3
tauri-codegen 2.6.2 2.6.3
tauri-macros 2.6.2 2.6.3
tauri-plugin 2.6.2 2.6.3
tauri-build 2.6.2 2.6.3
tauri 2.11.2 2.11.3
@tauri-apps/cli 2.11.2 2.11.3
tauri-cli 2.11.2 2.11.3

Add another change file through the GitHub UI by following this link.


Read about change files or the docs at github.com/jbolda/covector

&self,
) -> std::result::Result<DisplayHandle<'_>, raw_window_handle::HandleError> {
self.context.main_thread.window_target.display_handle()
todo!();
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't know how can we deal with this life time issue though...

proxy: event_loop.create_proxy(),
main_thread: DispatcherMainThreadContext {
window_target: event_loop.deref().clone(),
window_target: Arc::downgrade(&window_target),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we actually need Arc? Since keep in main thread isn't cross thread. Or something like run_on_main ?

https://github.com/tauri-apps/tao/blob/1080241d20d6bd0044d1cdb5633a25a3560f31cb/src/platform_impl/macos/util/async.rs#L43-L49

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The thing here is this context can be cloned and moved to other threads, and the inner rc from tao can't be cloned/dropped from multiple threads

@RyanJamesStewart
Copy link
Copy Markdown

Took the branch for a spin on Windows (x86_64-pc-windows-msvc, WebView2 148). It builds clean. For the display_handle lifetime question you flagged, the piece that compiles is filling the todo!() by upgrading the weak window_target and re-borrowing the raw handle:

fn display_handle(
  &self,
) -> std::result::Result<DisplayHandle<'_>, raw_window_handle::HandleError> {
  let window_target = self
    .context
    .main_thread
    .window_target
    .upgrade()
    .ok_or(raw_window_handle::HandleError::Unavailable)?;
  let raw = window_target.display_handle()?.as_raw();
  // SAFETY: `raw` is a plain `Copy` `RawDisplayHandle` pointing at the event
  // loop's display connection, which stays valid for as long as the runtime
  // (and therefore `&self`) is alive.
  Ok(unsafe { DisplayHandle::borrow_raw(raw) })
}

The RawDisplayHandle is Copy, so this sidesteps tying the returned DisplayHandle<'_> to the temporary Arc from upgrade(); the borrow is anchored to &self instead. (Restores the HasDisplayHandle import the branch had dropped.) Happy to push it as a commit if useful.

@Legend-Master
Copy link
Copy Markdown
Contributor Author

Well, with my change, the lifetime of the EventLoopWindowTarget is no longer related to the WryHandle which means this can cause dangling handles

That being said, even though the old lifetime was correct, it carried the EventLoopWindowTarget to the wrong thread to clone and drop

@RyanJamesStewart
Copy link
Copy Markdown

You're right, and thanks for the catch. The upgrade()-then-borrow_raw was unsound: &self on a Weak-only WryHandle doesn't keep the EventLoopWindowTarget alive, so on X11/Wayland the borrowed connection pointer can dangle if the main thread drops the loop. The Windows-empty handle hid it in my test.

Digging into what the todo!() replaced: before this PR it was window_target.display_handle() on the owned target, which returned a working handle on every platform, and AppHandle's public HasDisplayHandle impl routes here (it holds a RuntimeHandle, not the Runtime). So a blanket Err(Unavailable) would regress AppHandle::display_handle() on Windows/macOS, where the empty handle is the correct value and the HWND/NSView comes off the window handle.

So I think the real choice is binary:

  1. Empty WindowsDisplayHandle/AppKitDisplayHandle on Windows/macOS, Err(HandleError::Unavailable) on X11/Wayland. Those variants carry no pointer, so borrowing them for any lifetime is sound; this restores prior behavior where it can and degrades only the off-thread Linux case.
  2. Split HasDisplayHandle off RuntimeHandle so only main-thread holders (which keep the strong Arc) implement it, and serve X11/Wayland soundly there. That's a separate, larger refactor touching the public trait surface.

I'd lean toward (1), scoped to this PR: it's the smallest sound change that keeps the public API working where it can, and the X11/Wayland off-thread path is the one this PR is already making safe, so degrading it to a clean Err is honest rather than a real capability loss. (2) feels like its own PR if you want the Linux off-thread handle back.

Either way, scratch my earlier offer to push the borrow_raw version as a commit. How would you prefer to shape it?

@Legend-Master
Copy link
Copy Markdown
Contributor Author

HasWindowHandle requires the handle to be valid as long as the object implementing it which can't be achieved with this weak pointer approach.

I think the direction would be either try another solution for the implicit window_target access, or just break the promise (go with the one you proposed) if the trade off is worth it

@RyanJamesStewart
Copy link
Copy Markdown

Thanks, agreed the weak pointer can't satisfy the rwh lifetime contract in general.

The split I'd suggest: Windows and macOS return the empty WindowsDisplayHandle/AppKitDisplayHandle, which carry no pointer, so they're sound for any lifetime and keep the prior behavior with no promise broken. X11/Wayland are the only arm with a real connection pointer.

So the actual decision is just that one arm:

  1. Err(HandleError::Unavailable) on X11/Wayland: fully sound, off-thread display_handle stops working there (it was already unsound pre-PR via the cross-thread Rc path this fixes).
  2. upgrade() + borrow_raw on X11/Wayland with a SAFETY note that the handle must not outlive the runtime: restores prior behavior, breaks the lifetime promise only on Linux off-thread, which is the case you noted the old code was already relying on.

I lean toward (2): it keeps Windows/macOS sound and serves Linux as before, rather than dropping the Linux off-thread path to Unavailable when Windows/macOS can stay working. But that's a documented-unsoundness call on a thin slice (off-thread X11/Wayland surface init is rare), so if you'd rather stay strictly sound, (1) is reasonable too. Which would you prefer?

@Legend-Master
Copy link
Copy Markdown
Contributor Author

Yeah, a small unsoundness would out weight breaking everyone using that API (your 2), if we decide to still use the weak pointer approach

@RyanJamesStewart
Copy link
Copy Markdown

Here's what I'd push. Flagging the SAFETY note for your review since it's the load-bearing part. Good to commit?

fn display_handle(&self) -> Result<DisplayHandle<'_>, HandleError> {
  // Windows/macOS: the display handle carries no pointer, so borrowing
  // one is sound for any lifetime regardless of the Weak event loop.
  #[cfg(windows)]
  return Ok(unsafe { DisplayHandle::borrow_raw(
    RawDisplayHandle::Windows(WindowsDisplayHandle::new())) });
  #[cfg(target_os = "macos")]
  return Ok(unsafe { DisplayHandle::borrow_raw(
    RawDisplayHandle::AppKit(AppKitDisplayHandle::new())) });

  #[cfg(not(any(windows, target_os = "macos")))]
  {
    let window_target = self
      .context
      .main_thread
      .window_target
      .upgrade()
      .ok_or(HandleError::Unavailable)?;
    let raw = window_target.display_handle()?.as_raw();
    // SAFETY: `raw` borrows the X11/Wayland connection pointer owned by
    // the main-thread EventLoopWindowTarget. The Weak above can't keep
    // that target alive, so this technically violates the rwh lifetime
    // contract: callers must not retain or use this handle past a point
    // where the main thread may have dropped the runtime. In practice
    // this holds for typical surface-init consumers, which use the
    // handle synchronously and don't outlive the runtime. Going via the
    // Weak (rather than dropping to Err) preserves prior behavior on
    // Linux, which was already de-facto relied on.
    Ok(unsafe { DisplayHandle::borrow_raw(raw) })
  }
}

Plus a one-line restoration of the HasDisplayHandle import the branch dropped, and WindowsDisplayHandle / AppKitDisplayHandle imports for the Windows/macOS arms.

One thing I want to be straight about on the SAFETY note: "callers must not retain or use this handle past a point where the main thread may have dropped the runtime" is a precondition a wgpu/softbuffer/glutin caller can't actually verify on its own. That's why this arm is documented-unsound rather than sound; there's no formulation that closes the gap without restructuring the trait. The note acknowledges the gap rather than pretending to close it, which is the trade we settled on.

@Legend-Master
Copy link
Copy Markdown
Contributor Author

Just so we're clear, I would like to hold onto this one see if we can come up with another method (not the weak pointer approach used here) before preceding with this unsound implemention
And the original one posted in #15411 (comment) would be good enough just with some more documentations

@RyanJamesStewart
Copy link
Copy Markdown

I went looking for a non-weak method before adding docs, and the useful finding is that two axes are getting conflated: the drop fix and display_handle soundness are orthogonal. A non-weak redesign of window_target ownership is a legitimate way to fix #15408 and is your call, but it won't make display_handle sound on X11/Wayland, because the wall there is independent of weak-vs-strong: the method returns a Send-anchored borrow of a display connection owned by the !Send main-thread EventLoopWindowTarget. Anything reachable from the Send + Sync off-thread handle inherits that lifetime gap.

For the off-thread X11/Wayland arm specifically, there are two in-repo endings:

  1. upgrade() + borrow_raw the live connection: documented-unsound, restores prior behavior, confined to that path.
  2. Return Err(HandleError::Unavailable): sound, no compile break, keeps the trait impl. This isn't a regression on that arm. The pre-PR behavior there was the cross-thread Rc clone/drop that [bug] WebView2 custom protocol can clone DispatcherMainThreadContext off the main thread #15408 exists to fix, i.e. live UB, so there were no sound off-thread X11/Wayland callers to lose. Err strictly removes unsoundness from a path that had it, with zero behavior loss for any sound caller.

And two that are out of scope for this PR: a tao/winit refactor that splits the connection into a Send + Sync object the off-thread handle can hold strongly (sound, non-breaking, but upstream), or dropping the trait impl entirely (sound but a compile break).

My recommendation is Err now, with the sound capability tracked as a tao follow-up. The "don't break callers" concern from earlier was about not regressing sound Windows/macOS surface-init, and neither arm touches that, since both keep the empty handle while the runtime is alive. It was never about an off-thread X11/Wayland consumer, because the UB means there isn't a sound one to protect. So borrow_raw is only worth it if you can name a workflow that needs the handle off-thread on X11/Wayland today, and for what it's worth I can't from my side: I'm on Windows/WebView2 with no off-thread X11/Wayland consumer.

On the Win/Mac arm, either ending returns the empty handle while the runtime is alive. One behavior delta worth making intentional rather than incidental: routing Win/Mac through upgrade() means a dead runtime returns Err where the owned target previously returned a valid-but-empty handle. Arguably more correct, but it's a change outside the X11/Wayland scope, so worth stating on purpose.

If you take Err, the PR text just needs a capability note: off-thread X11/Wayland display_handle declines pending a tao follow-up. If you'd rather keep borrow_raw, I'd instead state plainly that it reintroduces a known, pre-existing unsoundness confined to that path, linked to the follow-up, so the SAFETY note isn't later read as a resolution. Glad to draft whichever wording your call selects.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[bug] WebView2 custom protocol can clone DispatcherMainThreadContext off the main thread

3 participants