Summary
On the EdgeChromium (WinForms) backend, calling window.on_top = True or window.create_file_dialog(...) from a non-GUI thread performs a raw cross-thread operation on the underlying native Form object with no Control.Invoke/BeginInvoke marshaling. Under specific window-manager load — a WinForms message-pump busy in native activation work while a JS-poll thread concurrently sets TopMost or shows a modal dialog — the two threads enter a mutual wait and the message pump deadlocks permanently ("Not Responding"). There is no exception; try/except cannot catch a deadlock.
Other Window methods (restore, show, hide, minimize) do marshal correctly via self.Invoke in the WinForms backend. set_on_top and the dialog path do not.
Environment
|
|
| pywebview |
6.2.1 (latest release on PyPI at time of filing) |
| Backend |
EdgeChromium (WebView2 / WinForms) |
| OS |
Windows 11 |
| Python |
3.14 (CPython, via pythonnet for CLR interop) |
| Deployment |
Frozen with PyInstaller (onedir) |
| .NET |
WinForms — Form, Control.BeginInvoke/Invoke |
Affected API
window.on_top = <bool> — sets Form.TopMost directly on the calling thread
window.create_file_dialog(...) — constructs OpenFileDialog and calls dialog.ShowDialog(form) directly on the calling thread, with the GUI form as owner
Description
A desktop companion app coordinates with Microsoft Excel. It runs a ~500 ms JS-API poll loop on a background thread (js_api call from JS → Python). That thread:
- Calls
window.restore() and window.show() — both marshaled internally via self.Invoke in the WinForms backend; these are safe.
- Then sets
window.on_top = True — not marshaled — raw Form.TopMost = True on the polling thread.
Separately, window.create_file_dialog(...) is called from the same js_api worker thread when the user picks a file.
The deadlock trigger is a window-activation storm: Excel calls AppActivate targeting the companion window at the same moment WebView2 is waking from heavy throttling (the window had been fully occluded for 5+ minutes — WebView2 cuts JS timers to roughly 1/min under sustained occlusion, so the first poll after re-activation arrives while the GUI thread is busy in native activation work). This produces:
- Poll thread: blocked in
SetWindowPos (the WinForms TopMost set), waiting for the GUI thread to process the cross-thread message.
- GUI thread: blocked in native activation work (processing the
AppActivate-triggered focus/activation sequence), unable to pump messages.
- → Mutual wait; pump permanently dead.
Reproducibility
Honest assessment: narrow native race. Four synthetic reproduction attempts did not reproduce the deadlock, including a faithful scenario with a 5.5-minute occluded/throttled window followed by a simulated AppActivate storm. The live py-spy stack dump (from the frozen PyInstaller exe — see below) is the primary evidence.
py-spy evidence (frozen exe)
py-spy attaches to frozen PyInstaller executables (py-spy dump --pid <pid>); note the thread named Dummy-N is the .NET-spawned GUI thread — pywebview's actual message pump (MainThread only join-loops). The dump captured at the hang showed:
- The js_api poll thread sitting inside the application's bring-to-front helper at the
window.on_top = True line — i.e. blocked inside the cross-thread Form.TopMost set — and never returning.
- The GUI thread (
Dummy-N) showing no Python frames making progress: stuck below the Python level in native window-activation work, unable to pump messages.
(The summary above is from a live production hang; the dump was taken in the field, so the stacks are described rather than pasted verbatim.)
Root Cause
WinForms enforces a strict threading model: only the thread that created a Control may access its members (Control.InvokeRequired returns True from any other thread). All GUI operations from non-GUI threads must be dispatched via Control.Invoke (blocking) or Control.BeginInvoke (fire-and-forget).
The WinForms backend marshals most pywebview Window operations correctly — webview/platforms/winforms.py (6.2.1) has InvokeRequired guards at lines 499, 545, 595, and 850 for other operations. However:
set_on_top (winforms.py:1003): i.TopMost = on_top directly on the calling thread — no InvokeRequired check, no Invoke.
create_file_dialog (winforms.py:866): constructs the dialog, then calls dialog.ShowDialog(i) (lines 893 and 911) directly on the calling thread, where i is the GUI-thread-owned WinForms Form. A modal dialog whose owner is the GUI form synchronously disables the owner and handles activation — both operations that require the GUI thread's message pump.
The behavior is intermittent because it depends on a narrow race between the cross-thread call and the GUI thread being in non-pumpable native activation work. Under light load it usually "works" by accident (the GUI thread pumps before the cross-thread set blocks), masking the bug.
Workaround
Marshal both operations onto the GUI thread using the native form's BeginInvoke/Invoke, using the same Func[Type] delegate pattern pywebview's WinForms backend already uses for its own marshaled calls.
Helper (mirrors pywebview's own pattern)
def _clr_delegate(fn):
"""Wrap a zero-arg callable for WinForms BeginInvoke/Invoke."""
try:
from System import Func, Type # pythonnet, CLR-only
except Exception:
return fn # non-Windows / test environment — return as-is
return Func[Type](fn)
on_top — fire-and-forget via BeginInvoke
The caller does not need to wait for the result, so BeginInvoke (asynchronous, non-blocking) is sufficient. The poll thread can never deadlock by construction because it posts and returns immediately.
# Instead of: window.on_top = True / False
form = getattr(window, "native", None) # the WinForms Form
begin_invoke = getattr(form, "BeginInvoke", None)
if begin_invoke is not None:
def _set_on_top():
window.on_top = True
window.on_top = False
begin_invoke(_clr_delegate(_set_on_top))
else:
# Non-WinForms backend or test: direct call
window.on_top = True
window.on_top = False
create_file_dialog — blocking Invoke (result needed)
The caller needs the picked path, so Invoke (synchronous, blocks the caller until the GUI thread finishes) is required. A modal dialog is legal on the GUI thread, and Invoke from the owning thread runs inline — so this cannot deadlock by construction. The result/exception is carried back via closure.
# Instead of: result = window.create_file_dialog(...)
form = getattr(window, "native", None)
invoke = getattr(form, "Invoke", None)
if invoke is None:
return window.create_file_dialog(...) # non-WinForms / test path
result_box: list = []
error_box: list = []
def _show_dialog():
try:
result_box.append(window.create_file_dialog(...))
except BaseException as exc:
error_box.append(exc)
invoke(_clr_delegate(_show_dialog)) # blocks caller until GUI thread finishes
if error_box:
raise error_box[0]
return result_box[0] if result_box else None
Suggested Fix
Inside the WinForms backend (webview/platforms/winforms.py), apply the same self.Invoke/self.BeginInvoke marshaling already used by restore, show, hide, and minimize to:
set_on_top — dispatch the Form.TopMost assignment via BeginInvoke (or Invoke).
create_file_dialog — dispatch dialog.ShowDialog(form) (or the entire dialog construction + show) via Invoke, returning the result to the caller.
This is consistent with WinForms threading requirements and with pywebview's own existing patterns for the same class of operation.
Notes
- The
window.native attribute (access to the underlying Form) is used in the workaround above; if this is not a stable/public API, an alternative is to add explicit InvokeRequired guards inside the WinForms backend directly.
- The issue was diagnosed using py-spy on a frozen PyInstaller exe — this is a valid technique for pywebview apps shipped as standalone executables.
- WebView2's JS-timer throttling (timers cut to ~1/min after 5+ minutes of sustained window occlusion) is a contributing environmental factor that causes the cross-thread call and GUI-thread activation work to arrive simultaneously after a long idle period. The underlying threading bug exists regardless of this factor.
Summary
On the EdgeChromium (WinForms) backend, calling
window.on_top = Trueorwindow.create_file_dialog(...)from a non-GUI thread performs a raw cross-thread operation on the underlying nativeFormobject with noControl.Invoke/BeginInvokemarshaling. Under specific window-manager load — a WinForms message-pump busy in native activation work while a JS-poll thread concurrently setsTopMostor shows a modal dialog — the two threads enter a mutual wait and the message pump deadlocks permanently ("Not Responding"). There is no exception;try/exceptcannot catch a deadlock.Other
Windowmethods (restore,show,hide,minimize) do marshal correctly viaself.Invokein the WinForms backend.set_on_topand the dialog path do not.Environment
Form,Control.BeginInvoke/InvokeAffected API
window.on_top = <bool>— setsForm.TopMostdirectly on the calling threadwindow.create_file_dialog(...)— constructsOpenFileDialogand callsdialog.ShowDialog(form)directly on the calling thread, with the GUI form as ownerDescription
A desktop companion app coordinates with Microsoft Excel. It runs a ~500 ms JS-API poll loop on a background thread (
js_apicall from JS → Python). That thread:window.restore()andwindow.show()— both marshaled internally viaself.Invokein the WinForms backend; these are safe.window.on_top = True— not marshaled — rawForm.TopMost = Trueon the polling thread.Separately,
window.create_file_dialog(...)is called from the same js_api worker thread when the user picks a file.The deadlock trigger is a window-activation storm: Excel calls
AppActivatetargeting the companion window at the same moment WebView2 is waking from heavy throttling (the window had been fully occluded for 5+ minutes — WebView2 cuts JS timers to roughly 1/min under sustained occlusion, so the first poll after re-activation arrives while the GUI thread is busy in native activation work). This produces:SetWindowPos(the WinFormsTopMostset), waiting for the GUI thread to process the cross-thread message.AppActivate-triggered focus/activation sequence), unable to pump messages.Reproducibility
Honest assessment: narrow native race. Four synthetic reproduction attempts did not reproduce the deadlock, including a faithful scenario with a 5.5-minute occluded/throttled window followed by a simulated
AppActivatestorm. The live py-spy stack dump (from the frozen PyInstaller exe — see below) is the primary evidence.py-spy evidence (frozen exe)
py-spy attaches to frozen PyInstaller executables (
py-spy dump --pid <pid>); note the thread namedDummy-Nis the .NET-spawned GUI thread — pywebview's actual message pump (MainThreadonly join-loops). The dump captured at the hang showed:window.on_top = Trueline — i.e. blocked inside the cross-threadForm.TopMostset — and never returning.Dummy-N) showing no Python frames making progress: stuck below the Python level in native window-activation work, unable to pump messages.(The summary above is from a live production hang; the dump was taken in the field, so the stacks are described rather than pasted verbatim.)
Root Cause
WinForms enforces a strict threading model: only the thread that created a
Controlmay access its members (Control.InvokeRequiredreturnsTruefrom any other thread). All GUI operations from non-GUI threads must be dispatched viaControl.Invoke(blocking) orControl.BeginInvoke(fire-and-forget).The WinForms backend marshals most pywebview
Windowoperations correctly —webview/platforms/winforms.py(6.2.1) hasInvokeRequiredguards at lines 499, 545, 595, and 850 for other operations. However:set_on_top(winforms.py:1003):i.TopMost = on_topdirectly on the calling thread — noInvokeRequiredcheck, noInvoke.create_file_dialog(winforms.py:866): constructs the dialog, then callsdialog.ShowDialog(i)(lines 893 and 911) directly on the calling thread, whereiis the GUI-thread-owned WinFormsForm. A modal dialog whose owner is the GUI form synchronously disables the owner and handles activation — both operations that require the GUI thread's message pump.The behavior is intermittent because it depends on a narrow race between the cross-thread call and the GUI thread being in non-pumpable native activation work. Under light load it usually "works" by accident (the GUI thread pumps before the cross-thread set blocks), masking the bug.
Workaround
Marshal both operations onto the GUI thread using the native form's
BeginInvoke/Invoke, using the sameFunc[Type]delegate pattern pywebview's WinForms backend already uses for its own marshaled calls.Helper (mirrors pywebview's own pattern)
on_top— fire-and-forget viaBeginInvokeThe caller does not need to wait for the result, so
BeginInvoke(asynchronous, non-blocking) is sufficient. The poll thread can never deadlock by construction because it posts and returns immediately.create_file_dialog— blockingInvoke(result needed)The caller needs the picked path, so
Invoke(synchronous, blocks the caller until the GUI thread finishes) is required. A modal dialog is legal on the GUI thread, andInvokefrom the owning thread runs inline — so this cannot deadlock by construction. The result/exception is carried back via closure.Suggested Fix
Inside the WinForms backend (
webview/platforms/winforms.py), apply the sameself.Invoke/self.BeginInvokemarshaling already used byrestore,show,hide, andminimizeto:set_on_top— dispatch theForm.TopMostassignment viaBeginInvoke(orInvoke).create_file_dialog— dispatchdialog.ShowDialog(form)(or the entire dialog construction + show) viaInvoke, returning the result to the caller.This is consistent with WinForms threading requirements and with pywebview's own existing patterns for the same class of operation.
Notes
window.nativeattribute (access to the underlyingForm) is used in the workaround above; if this is not a stable/public API, an alternative is to add explicitInvokeRequiredguards inside the WinForms backend directly.