Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Show error dialog if WebView fails to load #2781

Merged
merged 1 commit into from
Aug 23, 2024

Conversation

rmartin16
Copy link
Member

@rmartin16 rmartin16 commented Aug 21, 2024

Changes

PR Checklist:

  • All new features have been tested
  • All new features have been documented
  • I have read the CONTRIBUTING.md file
  • I will abide by the code of conduct

@@ -75,14 +75,16 @@ def winforms_initialization_completed(self, sender, args):
settings = self.native.CoreWebView2.Settings
self.default_user_agent = settings.UserAgent

debug = True
debug = False
Copy link
Member Author

Choose a reason for hiding this comment

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

I disabled debug mode. Was it intentionally left enabled? Does this deserve a changenote?

Copy link
Member

Choose a reason for hiding this comment

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

It was intentionally enabled, mostly to ensure that the developer tools are available. I think it makes sense that these are disabled by default; but we do need a better way to enable debug functionality for development purposes.

An additional removal changenote is probably called for, just in case anyone was depending on the behaviour.

Copy link
Member Author

@rmartin16 rmartin16 Aug 22, 2024

Choose a reason for hiding this comment

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

Soo, this doesn't appear to be limited to WinForms. I see that a "debug mode" is enabled for macOS, iOS, and Gtk as well. (I think Android drives access to DevTools with the nature of the build; debug builds enable it while release builds disable it).

I think I'll descope this for a separate effort to make this configurable.

@rmartin16 rmartin16 marked this pull request as ready for review August 21, 2024 21:40
Copy link
Member

@freakboy3742 freakboy3742 left a comment

Choose a reason for hiding this comment

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

Looks good; the extra changenote is the only thing needed to land this PR, unless you want to tackle improving making the failure more user-visible as part of this PR.

I agree a more user-visible mechanism would be preferable, but I'm not sure what that would be. Some ideas:

  1. On error, replace the webview container with a textview that contains the error message,
  2. Pop up a dialog with the error condition
  3. Post an error to the app's event loop that raises the exception in the main app context.

Assuming (3) works, that's probably the best case, because it would surface as a text log when there's a console, but should be caught by the Briefcase stub when it's a GUI app.

@@ -75,14 +75,16 @@ def winforms_initialization_completed(self, sender, args):
settings = self.native.CoreWebView2.Settings
self.default_user_agent = settings.UserAgent

debug = True
debug = False
Copy link
Member

Choose a reason for hiding this comment

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

It was intentionally enabled, mostly to ensure that the developer tools are available. I think it makes sense that these are disabled by default; but we do need a better way to enable debug functionality for development purposes.

An additional removal changenote is probably called for, just in case anyone was depending on the behaviour.

@rmartin16
Copy link
Member Author

3. Post an error to the app's event loop that raises the exception in the main app context.

Assuming (3) works, that's probably the best case, because it would surface as a text log when there's a console, but should be caught by the Briefcase stub when it's a GUI app.

So, I actually tried to do this with something like this in the callback:

async def raise_exception():
    raise RuntimeError("failed to load WebView")

asyncio.create_task(raise_exception())

This didn't work for me, though; can you imagine a different approach that might work?

@freakboy3742
Copy link
Member

So, I actually tried to do this with something like this in the callback:
...
This didn't work for me, though; can you imagine a different approach that might work?

My original thought was to go non-async, with app.loop.call_soon_threadsafe()... not sure if that will change anything by ensuring that you're getting a method on the app's specific loop, rather than potentially spawning a thread-specific loop in the .NET context.

Out of interest - what is the failure mode? Does it not process the async handler, or process it but in a way that isn't fatal to the app?

@rmartin16
Copy link
Member Author

rmartin16 commented Aug 21, 2024

My original thought was to go non-async, with app.loop.call_soon_threadsafe()... not sure if that will change anything by ensuring that you're getting a method on the app's specific loop, rather than potentially spawning a thread-specific loop in the .NET context.

This did print an exception to the console; however, the app continued running.

def winforms_initialization_completed(self, sender, args):
    def raise_exception():
        raise RuntimeError("failed to load WebView")

    self.interface.app.loop.call_soon_threadsafe(raise_exception)

Adding this callback as well stopped the app though....

self.interface.app.loop.call_soon_threadsafe(self.interface.app.exit)

Out of interest - what is the failure mode? Does it not process the async handler, or process it but in a way that isn't fatal to the app?

With asyncio.create_task(), nothing seemed to happen; at least nothing obvious. Maybe the exception just gets associated with the Task and you can raise it once the task is completed or something...

@rmartin16
Copy link
Member Author

I guess whether we exit the app or not, I don't think pushing the Exception on to the loop really does us any good....unless we only expect this issue to happen during development. Otherwise, users are just going to have an app that opens and immediately closes; even if the dev asks the user to fish the log out of AppData, I'm not sure this RuntimeError actually makes it there.

So, maybe showing a dialog like how the missing Edge Runtime is handled is best; at least then the user can tell something is obviously failing and it involves something called a WebView.

@freakboy3742
Copy link
Member

My original thought was to go non-async, with app.loop.call_soon_threadsafe()... not sure if that will change anything by ensuring that you're getting a method on the app's specific loop, rather than potentially spawning a thread-specific loop in the .NET context.

This did print an exception to the console; however, the app continued running.

That's not entirely unexpected. The Winforms asyncio integration is a bit weird, because of the winforms STA apartment state. The app itself runs in a thread; exceptions are caught and the stack trace is manually unwound for printing purposes, but won't necessarily terminate the app itself.

I'm not 100% convinced this implementation is correct - the intention was that an exception in an event handler would be reported, but wouldn't kill the app; but it's possible this is being a little too enthusiastic with surpassing exit conditions, and actual errors in the app mainline aren't being surfaced.

Explicitly calling exit() in the call_soon_threadsafe() will definitely kill the app, and the stack may be printed, but I'm not sure if the stack trace being surfaced in the Briefcase-wrapped case because the exit is "clean", not the result of an an actual raised exception. The critical code is all in _run_app and main_loop of the winforms app, if you're interested.

Out of interest - what is the failure mode? Does it not process the async handler, or process it but in a way that isn't fatal to the app?

With asyncio.create_task(), nothing seemed to happen; at least nothing obvious. Maybe the exception just gets associated with the Task and you can raise it once the task is completed or something...

It's more likely that the task is being created, but it's being put on an event loop that is never started. The default asyncio.create_task() relies on some default behavior for finding the event loop; and if it's finding the wrong one (presumably because of winforms app threading setup) then the task exists, but is never executed because it's on an event loop that won't ever be started.

@freakboy3742
Copy link
Member

So, maybe showing a dialog like how the missing Edge Runtime is handled is best; at least then the user can tell something is obviously failing and it involves something called a WebView.

That's a good point - the existing dialog behavior is a good precedent for how "This app ain't working" should be handled int this case, so we might as well lean into that.

@rmartin16
Copy link
Member Author

rmartin16 commented Aug 22, 2024

I added a dialog explaining the failure. Open to wording critique.

Since I haven't rebased on main, you can see this dialog by using Windows Store Python.

image

This obviously isn't immediately useful to users...but I did find it interesting if you press CTRL+C, it puts this on the clipboard:

---------------------------
Error
---------------------------
A critical error has occurred and functionality may be impaired.

The WebView2 initialization failed with an exception:

Unable to load DLL 'WebView2Loader.dll': The specified module could not be found. (Exception from HRESULT: 0x8007007E)
   at Microsoft.Web.WebView2.Core.CoreWebView2Environment.CreateCoreWebView2EnvironmentWithOptions(String browserExecutableFolder, String userDataFolder, ICoreWebView2EnvironmentOptions options, ICoreWebView2CreateCoreWebView2EnvironmentCompletedHandler environment_created_handler)
   at Microsoft.Web.WebView2.Core.CoreWebView2Environment.<CreateAsync>d__3.MoveNext()
--- End of stack trace from previous location where exception was thrown ---
   at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
   at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
   at Microsoft.Web.WebView2.WinForms.WebView2.<InitCoreWebView2Async>d__18.MoveNext()
---------------------------
OK   
---------------------------

@rmartin16 rmartin16 changed the title Log an error instead of raising if WebView fails to load Show error dialog if WebView fails to load Aug 22, 2024
- On Windows, if the callback for WebView initialization raises an
  exception, that exception is swallowed in .NET and not reported to the
  user. Now, the user is presented with a dialog box explaining the
  failure.
Copy link
Member

@freakboy3742 freakboy3742 left a comment

Choose a reason for hiding this comment

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

Looks great! And the clipboard thing is a nice trick... I guess the question is whether users know that feature exists.

@freakboy3742 freakboy3742 merged commit 0a14beb into beeware:main Aug 23, 2024
35 checks passed
@rmartin16 rmartin16 deleted the log-errors-in-callback branch August 23, 2024 04:17
@rmartin16
Copy link
Member Author

My original thought was to go non-async, with app.loop.call_soon_threadsafe()... not sure if that will change anything by ensuring that you're getting a method on the app's specific loop, rather than potentially spawning a thread-specific loop in the .NET context.

This did print an exception to the console; however, the app continued running.

That's not entirely unexpected. The Winforms asyncio integration is a bit weird, because of the winforms STA apartment state. The app itself runs in a thread; exceptions are caught and the stack trace is manually unwound for printing purposes, but won't necessarily terminate the app itself.

I'm not 100% convinced this implementation is correct - the intention was that an exception in an event handler would be reported, but wouldn't kill the app; but it's possible this is being a little too enthusiastic with surpassing exit conditions, and actual errors in the app mainline aren't being surfaced.

So, my interest remain piqued since you said this.

To recap, I had put a task on the event loop that would raise a RuntimeError....and all that happened was the stacktrace was printed to the console.

I tracked down this behavior to BaseEventLoop.default_exception_handler(). If I add a default_exception_handler() to WinformsProactorEventLoop, I see it run for the RuntimeError.

    def default_exception_handler(self, context):
        print("this is your friendly exception handler")
        super().default_exception_handler(context)
[webview] Starting in dev mode...
===========================================================================
this is your friendly exception handler
Exception in callback WebView.winforms_initialization_completed.<locals>.raise_exception() at C:\Users\user\github\beeware\toga\winforms\src\toga_winforms\widgets\webview.py:72
handle: <Handle WebView.winforms_initialization_completed.<locals>.raise_exception() at C:\Users\user\github\beeware\toga\winforms\src\toga_winforms\widgets\webview.py:72 created at C:\Users\user\github\beeware\toga\winforms\src\toga_winforms\widgets\webview.py:75>
source_traceback: Object created at (most recent call last):
  File "C:\Users\user\github\beeware\toga\winforms\src\toga_winforms\app.py", line 167, in _run_app
    self.loop.run_forever(self)
  File "C:\Users\user\github\beeware\toga\winforms\src\toga_winforms\libs\proactor.py", line 76, in run_forever
    WinForms.Application.Run(self.app.app_context)
  File "C:\Users\user\github\beeware\toga\winforms\src\toga_winforms\libs\wrapper.py", line 22, in __call__
    return function(*args, **kwargs)
  File "C:\Users\user\github\beeware\toga\winforms\src\toga_winforms\widgets\webview.py", line 75, in winforms_initialization_completed
    self.interface.app.loop.call_soon_threadsafe(raise_exception)
Traceback (most recent call last):
  File "C:\Users\user\.pyenv\pyenv-win\versions\3.12.1\Lib\asyncio\events.py", line 84, in _run
    self._context.run(self._callback, *self._args)
  File "C:\Users\user\github\beeware\toga\winforms\src\toga_winforms\widgets\webview.py", line 73, in raise_exception
    raise RuntimeError("failed to load WebView")
RuntimeError: failed to load WebView

So, given this, what is the desired behavior when Toga itself raises an exception?

If I simulate raising the RuntimeError for WebView on Gtk, a console error is logged but the app window never renders.

[webview] Starting in dev mode...
===========================================================================
Traceback (most recent call last):
  File "/home/russell/github/beeware/toga/gtk/src/toga_gtk/app.py", line 45, in gtk_startup
    self.interface._startup()
  File "/home/russell/.pyenv/versions/briefcase-3.10/lib/python3.10/site-packages/toga/app.py", line 619, in _startup
    self.startup()
  File "/home/russell/github/beeware/toga/examples/webview/webview/app.py", line 86, in startup
    self.webview = toga.WebView(
  File "/home/russell/.pyenv/versions/briefcase-3.10/lib/python3.10/site-packages/toga/widgets/webview.py", line 47, in __init__
    self._impl = self.factory.WebView(interface=self)
  File "/home/russell/github/beeware/toga/gtk/src/toga_gtk/widgets/base.py", line 16, in __init__
    self.create()
  File "/home/russell/github/beeware/toga/gtk/src/toga_gtk/widgets/webview.py", line 15, in create
    raise RuntimeError(
RuntimeError: Unable to import WebKit2. Ensure that the system package providing WebKit2 and its GTK bindings have been installed. See https://toga.readthedocs.io/en/stable/reference/api/widgets/webview.html#system-requirements for details.

@freakboy3742
Copy link
Member

@rmartin16 I've opened #2806 to continue this discussion, rather than burying it on a resolved Webview ticket.

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.

Callbacks into Toga invoked from .NET do not respect failure modes
2 participants