Skip to content

Window.on_top and create_file_dialog are unmarshaled cross-thread ops on EdgeChromium (WinForms) — deadlocks GUI thread under activation load #1823

Description

@ekissel7

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:

  1. Calls window.restore() and window.show()both marshaled internally via self.Invoke in the WinForms backend; these are safe.
  2. Then sets window.on_top = Truenot 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:

  1. set_on_top — dispatch the Form.TopMost assignment via BeginInvoke (or Invoke).
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions