From cc9471f9b4dc8554719fe3d28782c049f3e23af8 Mon Sep 17 00:00:00 2001 From: Seungsoo Lee Date: Fri, 26 Jun 2026 12:32:43 +0900 Subject: [PATCH 01/13] [webview_flutter_tizen] Implement clearLocalStorage and onHttpError Add Tizen native implementations for two previously unimplemented APIs: - clearLocalStorage: clears web local storage via ewk_context_web_storage_delete_all. - onHttpError: reports HTTP error status codes (>= 400) to the navigation delegate via the policy,response,decide callback. Bump version to 0.10.1. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/webview_flutter/CHANGELOG.md | 5 ++++ .../webview_flutter/example/lib/main.dart | 10 +++---- .../lib/src/tizen_webview.dart | 4 +++ .../lib/src/tizen_webview_controller.dart | 27 ++++++++++------- packages/webview_flutter/pubspec.yaml | 2 +- packages/webview_flutter/tizen/src/webview.cc | 30 +++++++++++++++++++ packages/webview_flutter/tizen/src/webview.h | 1 + 7 files changed, 62 insertions(+), 17 deletions(-) diff --git a/packages/webview_flutter/CHANGELOG.md b/packages/webview_flutter/CHANGELOG.md index aa5a97dde..98fddfed2 100644 --- a/packages/webview_flutter/CHANGELOG.md +++ b/packages/webview_flutter/CHANGELOG.md @@ -1,3 +1,8 @@ +## 0.10.1 + +* Implement `clearLocalStorage`. +* Implement `onHttpError` for the navigation delegate. + ## 0.10.0 * Update minimum supported SDK version to Flutter 3.32/Dart 3.8. diff --git a/packages/webview_flutter/example/lib/main.dart b/packages/webview_flutter/example/lib/main.dart index 36e248fb6..7899aa526 100644 --- a/packages/webview_flutter/example/lib/main.dart +++ b/packages/webview_flutter/example/lib/main.dart @@ -190,10 +190,9 @@ Page resource error: debugPrint('allowing navigation to ${request.url}'); return NavigationDecision.navigate; }, - // Note: onHttpError is not implemented by TizenWebview. - // onHttpError: (HttpResponseError error) { - // debugPrint('Error occurred on page: ${error.response?.statusCode}'); - // }, + onHttpError: (HttpResponseError error) { + debugPrint('Error occurred on page: ${error.response?.statusCode}'); + }, onUrlChange: (UrlChange change) { debugPrint('url change to ${change.url}'); }, @@ -491,8 +490,7 @@ class SampleMenu extends StatelessWidget { Future _onClearCache(BuildContext context) async { await webViewController.clearCache(); - // This is unimplemented in webview_flutter_tizen. - // await webViewController.clearLocalStorage(); + await webViewController.clearLocalStorage(); if (context.mounted) { ScaffoldMessenger.of( context, diff --git a/packages/webview_flutter/lib/src/tizen_webview.dart b/packages/webview_flutter/lib/src/tizen_webview.dart index 236dd4821..ca3a0758b 100644 --- a/packages/webview_flutter/lib/src/tizen_webview.dart +++ b/packages/webview_flutter/lib/src/tizen_webview.dart @@ -151,6 +151,10 @@ class TizenWebView { /// Clears all caches used by the [WebView]. Future clearCache() => _invokeChannelMethod('clearCache'); + /// Clears the local storage used by the [WebView]. + Future clearLocalStorage() => + _invokeChannelMethod('clearLocalStorage'); + /// Sets the JavaScript execution mode to be used by the webview. Future setJavaScriptMode(int javaScriptMode) => _invokeChannelMethod('javaScriptMode', javaScriptMode); diff --git a/packages/webview_flutter/lib/src/tizen_webview_controller.dart b/packages/webview_flutter/lib/src/tizen_webview_controller.dart index b364fa7a1..b6909e1bc 100644 --- a/packages/webview_flutter/lib/src/tizen_webview_controller.dart +++ b/packages/webview_flutter/lib/src/tizen_webview_controller.dart @@ -218,12 +218,7 @@ class TizenWebViewController extends PlatformWebViewController { Future clearCache() => _webview.clearCache(); @override - Future clearLocalStorage() { - throw UnimplementedError( - 'This version of `TizenWebViewController` currently has no ' - 'implementation.', - ); - } + Future clearLocalStorage() => _webview.clearLocalStorage(); @override Future setPlatformNavigationDelegate( @@ -477,6 +472,7 @@ class TizenNavigationDelegate extends PlatformNavigationDelegate { WebResourceErrorCallback? _onWebResourceError; NavigationRequestCallback? _onNavigationRequest; UrlChangeCallback? _onUrlChange; + HttpResponseErrorCallback? _onHttpError; /// Called when [TizenView] is created. void createNavigationDelegateChannel(int viewId) { @@ -525,6 +521,20 @@ class TizenNavigationDelegate extends PlatformNavigationDelegate { _onUrlChange!(UrlChange(url: arguments['url']! as String)); } return null; + case 'onHttpError': + if (_onHttpError != null) { + final Uri uri = Uri.parse(arguments['url']! as String); + _onHttpError!( + HttpResponseError( + request: WebResourceRequest(uri: uri), + response: WebResourceResponse( + uri: uri, + statusCode: arguments['statusCode']! as int, + ), + ), + ); + } + return null; } throw MissingPluginException( @@ -580,10 +590,7 @@ class TizenNavigationDelegate extends PlatformNavigationDelegate { @override Future setOnHttpError(HttpResponseErrorCallback onHttpError) async { - throw UnimplementedError( - 'This version of `TizenNavigationDelegate` currently has no ' - 'implementation for `setOnHttpError`', - ); + _onHttpError = onHttpError; } @override diff --git a/packages/webview_flutter/pubspec.yaml b/packages/webview_flutter/pubspec.yaml index fc461b335..d619ea013 100644 --- a/packages/webview_flutter/pubspec.yaml +++ b/packages/webview_flutter/pubspec.yaml @@ -2,7 +2,7 @@ name: webview_flutter_tizen description: Tizen implementation of the webview_flutter plugin. homepage: https://github.com/flutter-tizen/plugins repository: https://github.com/flutter-tizen/plugins/tree/master/packages/webview_flutter -version: 0.10.0 +version: 0.10.1 environment: sdk: ^3.8.0 diff --git a/packages/webview_flutter/tizen/src/webview.cc b/packages/webview_flutter/tizen/src/webview.cc index 9bc0e73ad..f51a95e1d 100644 --- a/packages/webview_flutter/tizen/src/webview.cc +++ b/packages/webview_flutter/tizen/src/webview.cc @@ -429,6 +429,8 @@ bool WebView::InitWebView() { &WebView::OnConsoleMessage, this); evas_object_smart_callback_add(webview_instance_, "policy,navigation,decide", &WebView::OnNavigationPolicy, this); + evas_object_smart_callback_add(webview_instance_, "policy,response,decide", + &WebView::OnResponsePolicy, this); evas_object_smart_callback_add(webview_instance_, "url,changed", &WebView::OnUrlChange, this); @@ -580,6 +582,10 @@ void WebView::HandleWebViewMethodCall(const FlMethodCall& method_call, Ewk_Context* context = ewk_view_context_get(webview_instance_); ewk_context_resource_cache_clear(context); result->Success(); + } else if (method_name == "clearLocalStorage") { + Ewk_Context* context = ewk_view_context_get(webview_instance_); + ewk_context_web_storage_delete_all(context); + result->Success(); } else if (method_name == "getTitle") { result->Success(flutter::EncodableValue( std::string(ewk_view_title_get(webview_instance_)))); @@ -871,6 +877,30 @@ void WebView::OnNavigationPolicy(void* data, Evas_Object* obj, std::move(result)); } +void WebView::OnResponsePolicy(void* data, Evas_Object* obj, + void* event_info) { + WebView* webview = static_cast(data); + Ewk_Policy_Decision* policy_decision = + static_cast(event_info); + int status_code = + ewk_policy_decision_response_status_code_get(policy_decision); + const char* url = ewk_policy_decision_url_get(policy_decision); + ewk_policy_decision_use(policy_decision); + + // HTTP error status codes (4xx, 5xx) are reported to the navigation delegate. + if (!webview->has_navigation_delegate_ || status_code < 400) { + return; + } + flutter::EncodableMap args = { + {flutter::EncodableValue("url"), + flutter::EncodableValue(url ? url : "")}, + {flutter::EncodableValue("statusCode"), + flutter::EncodableValue(status_code)}, + }; + webview->navigation_delegate_channel_->InvokeMethod( + "onHttpError", std::make_unique(args)); +} + void WebView::OnUrlChange(void* data, Evas_Object* obj, void* event_info) { WebView* webview = static_cast(data); std::string url = std::string(ewk_view_url_get(webview->webview_instance_)); diff --git a/packages/webview_flutter/tizen/src/webview.h b/packages/webview_flutter/tizen/src/webview.h index 688f97ddc..ac47a9bd6 100644 --- a/packages/webview_flutter/tizen/src/webview.h +++ b/packages/webview_flutter/tizen/src/webview.h @@ -82,6 +82,7 @@ class WebView : public PlatformView { static void OnConsoleMessage(void* data, Evas_Object* obj, void* event_info); static void OnNavigationPolicy(void* data, Evas_Object* obj, void* event_info); + static void OnResponsePolicy(void* data, Evas_Object* obj, void* event_info); static void OnUrlChange(void* data, Evas_Object* obj, void* event_info); static void OnEvaluateJavaScript(Evas_Object* obj, const char* result_value, void* user_data); From 900c878a076d993925a798d3fae25d01d1a2716e Mon Sep 17 00:00:00 2001 From: Seungsoo Lee Date: Fri, 26 Jun 2026 12:32:49 +0900 Subject: [PATCH 02/13] [webview_flutter_tizen] Add integration tests based on upstream v4.13.1 Port the remaining runnable upstream test cases from webview_flutter v4.13.1: - NavigationDelegate > onHttpError - NavigationDelegate > onHttpError is not called when no HTTP error is received - clearLocalStorage These pass thanks to the new clearLocalStorage and onHttpError implementations. The other upstream test cases remain omitted because they cannot run on Tizen: window.open/new-window behavior, HTTP basic auth, and media playback policy are not supported by the engine. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../webview_flutter_test.dart | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/packages/webview_flutter/example/integration_test/webview_flutter_test.dart b/packages/webview_flutter/example/integration_test/webview_flutter_test.dart index d861f9994..cd28a8410 100644 --- a/packages/webview_flutter/example/integration_test/webview_flutter_test.dart +++ b/packages/webview_flutter/example/integration_test/webview_flutter_test.dart @@ -443,6 +443,68 @@ Future main() async { expect(currentUrl, isNot(contains('youtube.com'))); }); + testWidgets('onHttpError', (WidgetTester tester) async { + final Completer errorCompleter = + Completer(); + + final WebViewController controller = WebViewController(); + unawaited(controller.setJavaScriptMode(JavaScriptMode.unrestricted)); + unawaited( + controller.setNavigationDelegate( + NavigationDelegate( + onHttpError: (HttpResponseError error) { + errorCompleter.complete(error); + }, + ), + ), + ); + + unawaited(controller.loadRequest(Uri.parse('$prefixUrl/favicon.ico'))); + + await tester.pumpWidget(WebViewWidget(controller: controller)); + + final HttpResponseError error = await errorCompleter.future; + + expect(error, isNotNull); + expect(error.response?.statusCode, 404); + }); + + testWidgets('onHttpError is not called when no HTTP error is received', ( + WidgetTester tester, + ) async { + const String testPage = ''' + + + + + + '''; + + final Completer errorCompleter = + Completer(); + final Completer pageFinishCompleter = Completer(); + + final WebViewController controller = WebViewController(); + unawaited(controller.setJavaScriptMode(JavaScriptMode.unrestricted)); + unawaited( + controller.setNavigationDelegate( + NavigationDelegate( + onPageFinished: (_) => pageFinishCompleter.complete(), + onHttpError: (HttpResponseError error) { + errorCompleter.complete(error); + }, + ), + ), + ); + + unawaited(controller.loadHtmlString(testPage)); + + await tester.pumpWidget(WebViewWidget(controller: controller)); + + expect(errorCompleter.future, doesNotComplete); + await pageFinishCompleter.future; + }); + testWidgets('supports asynchronous decisions', (WidgetTester tester) async { Completer pageLoaded = Completer(); @@ -541,6 +603,41 @@ Future main() async { await expectLater(urlChangeCompleter.future, completion(secondaryUrl)); }); }); + + testWidgets('clearLocalStorage', (WidgetTester tester) async { + Completer pageLoadCompleter = Completer(); + + final WebViewController controller = WebViewController(); + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + await controller.setNavigationDelegate( + NavigationDelegate(onPageFinished: (_) => pageLoadCompleter.complete()), + ); + await controller.loadRequest(Uri.parse(primaryUrl)); + + await tester.pumpWidget(WebViewWidget(controller: controller)); + + await pageLoadCompleter.future; + pageLoadCompleter = Completer(); + + await controller.runJavaScript('localStorage.setItem("myCat", "Tom");'); + final String myCatItem = + await controller.runJavaScriptReturningResult( + 'localStorage.getItem("myCat");', + ) + as String; + expect(myCatItem, 'Tom'); + + await controller.clearLocalStorage(); + + // Reload page to have changes take effect. + await controller.reload(); + await pageLoadCompleter.future; + + final Object nullItem = await controller.runJavaScriptReturningResult( + 'localStorage.getItem("myCat");', + ); + expect(nullItem, ''); + }); } class ResizableWebView extends StatefulWidget { From cf702017c333b7b68eaef2c9269f25e25edf189c Mon Sep 17 00:00:00 2001 From: Seungsoo Lee Date: Fri, 26 Jun 2026 14:09:54 +0900 Subject: [PATCH 03/13] [webview_flutter_tizen] Align integration test async style with upstream The test file wrapped most controller setup calls (setJavaScriptMode, setNavigationDelegate, loadRequest, etc.) in unawaited(), a leftover from an older upstream version. Match the upstream v4.13.1 style by awaiting those calls instead, keeping unawaited() only where upstream does (the request server loop and the two onHttpError tests). No behavior change; the full suite still passes on the device. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../webview_flutter_test.dart | 206 ++++++++---------- 1 file changed, 85 insertions(+), 121 deletions(-) diff --git a/packages/webview_flutter/example/integration_test/webview_flutter_test.dart b/packages/webview_flutter/example/integration_test/webview_flutter_test.dart index cd28a8410..7f40a6a1a 100644 --- a/packages/webview_flutter/example/integration_test/webview_flutter_test.dart +++ b/packages/webview_flutter/example/integration_test/webview_flutter_test.dart @@ -44,12 +44,10 @@ Future main() async { final Completer pageFinished = Completer(); final WebViewController controller = WebViewController(); - unawaited( - controller.setNavigationDelegate( - NavigationDelegate(onPageFinished: (_) => pageFinished.complete()), - ), + await controller.setNavigationDelegate( + NavigationDelegate(onPageFinished: (_) => pageFinished.complete()), ); - unawaited(controller.loadRequest(Uri.parse(primaryUrl))); + await controller.loadRequest(Uri.parse(primaryUrl)); await tester.pumpWidget(WebViewWidget(controller: controller)); @@ -63,13 +61,11 @@ Future main() async { final Completer pageFinished = Completer(); final WebViewController controller = WebViewController(); - unawaited(controller.setJavaScriptMode(JavaScriptMode.unrestricted)); - unawaited( - controller.setNavigationDelegate( - NavigationDelegate(onPageFinished: (_) => pageFinished.complete()), - ), + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + await controller.setNavigationDelegate( + NavigationDelegate(onPageFinished: (_) => pageFinished.complete()), ); - unawaited(controller.loadRequest(Uri.parse(primaryUrl))); + await controller.loadRequest(Uri.parse(primaryUrl)); await tester.pumpWidget(WebViewWidget(controller: controller)); @@ -89,16 +85,14 @@ Future main() async { final StreamController pageLoads = StreamController(); final WebViewController controller = WebViewController(); - unawaited(controller.setJavaScriptMode(JavaScriptMode.unrestricted)); - unawaited( - controller.setNavigationDelegate( - NavigationDelegate(onPageFinished: (String url) => pageLoads.add(url)), - ), + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + await controller.setNavigationDelegate( + NavigationDelegate(onPageFinished: (String url) => pageLoads.add(url)), ); await tester.pumpWidget(WebViewWidget(controller: controller)); - unawaited(controller.loadRequest(Uri.parse(headersUrl), headers: headers)); + await controller.loadRequest(Uri.parse(headersUrl), headers: headers); await pageLoads.stream.firstWhere((String url) => url == headersUrl); @@ -113,11 +107,9 @@ Future main() async { testWidgets('JavascriptChannel', (WidgetTester tester) async { final Completer pageFinished = Completer(); final WebViewController controller = WebViewController(); - unawaited(controller.setJavaScriptMode(JavaScriptMode.unrestricted)); - unawaited( - controller.setNavigationDelegate( - NavigationDelegate(onPageFinished: (_) => pageFinished.complete()), - ), + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + await controller.setNavigationDelegate( + NavigationDelegate(onPageFinished: (_) => pageFinished.complete()), ); final Completer channelCompleter = Completer(); @@ -178,14 +170,12 @@ Future main() async { final Completer pageFinished = Completer(); final WebViewController controller = WebViewController(); - unawaited(controller.setJavaScriptMode(JavaScriptMode.unrestricted)); - unawaited( - controller.setNavigationDelegate( - NavigationDelegate(onPageFinished: (_) => pageFinished.complete()), - ), + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + await controller.setNavigationDelegate( + NavigationDelegate(onPageFinished: (_) => pageFinished.complete()), ); - unawaited(controller.setUserAgent('Custom_User_Agent1')); - unawaited(controller.loadRequest(Uri.parse('about:blank'))); + await controller.setUserAgent('Custom_User_Agent1'); + await controller.loadRequest(Uri.parse('about:blank')); await tester.pumpWidget(WebViewWidget(controller: controller)); @@ -210,16 +200,12 @@ Future main() async { final Completer pageLoaded = Completer(); final WebViewController controller = WebViewController(); - unawaited(controller.setJavaScriptMode(JavaScriptMode.unrestricted)); - unawaited( - controller.setNavigationDelegate( - NavigationDelegate(onPageFinished: (_) => pageLoaded.complete()), - ), + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + await controller.setNavigationDelegate( + NavigationDelegate(onPageFinished: (_) => pageLoaded.complete()), ); - unawaited( - controller.loadRequest( - Uri.parse('data:text/html;charset=utf-8;base64,$getTitleTestBase64'), - ), + await controller.loadRequest( + Uri.parse('data:text/html;charset=utf-8;base64,$getTitleTestBase64'), ); await tester.pumpWidget(WebViewWidget(controller: controller)); @@ -265,18 +251,12 @@ Future main() async { final Completer pageLoaded = Completer(); final WebViewController controller = WebViewController(); - unawaited(controller.setJavaScriptMode(JavaScriptMode.unrestricted)); - unawaited( - controller.setNavigationDelegate( - NavigationDelegate(onPageFinished: (_) => pageLoaded.complete()), - ), + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + await controller.setNavigationDelegate( + NavigationDelegate(onPageFinished: (_) => pageLoaded.complete()), ); - unawaited( - controller.loadRequest( - Uri.parse( - 'data:text/html;charset=utf-8;base64,$scrollTestPageBase64', - ), - ), + await controller.loadRequest( + Uri.parse('data:text/html;charset=utf-8;base64,$scrollTestPageBase64'), ); await tester.pumpWidget(WebViewWidget(controller: controller)); @@ -319,23 +299,21 @@ Future main() async { Completer pageLoaded = Completer(); final WebViewController controller = WebViewController(); - unawaited(controller.setJavaScriptMode(JavaScriptMode.unrestricted)); - unawaited( - controller.setNavigationDelegate( - NavigationDelegate( - onPageFinished: (_) => pageLoaded.complete(), - onNavigationRequest: (NavigationRequest navigationRequest) { - return (navigationRequest.url.contains('youtube.com')) - ? NavigationDecision.prevent - : NavigationDecision.navigate; - }, - ), + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + await controller.setNavigationDelegate( + NavigationDelegate( + onPageFinished: (_) => pageLoaded.complete(), + onNavigationRequest: (NavigationRequest navigationRequest) { + return (navigationRequest.url.contains('youtube.com')) + ? NavigationDecision.prevent + : NavigationDecision.navigate; + }, ), ); await tester.pumpWidget(WebViewWidget(controller: controller)); - unawaited(controller.loadRequest(Uri.parse(blankPageEncoded))); + await controller.loadRequest(Uri.parse(blankPageEncoded)); await pageLoaded.future; // Wait for initial page load. @@ -352,19 +330,15 @@ Future main() async { Completer(); final WebViewController controller = WebViewController(); - unawaited(controller.setJavaScriptMode(JavaScriptMode.unrestricted)); - unawaited( - controller.setNavigationDelegate( - NavigationDelegate( - onWebResourceError: (WebResourceError error) { - errorCompleter.complete(error); - }, - ), + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + await controller.setNavigationDelegate( + NavigationDelegate( + onWebResourceError: (WebResourceError error) { + errorCompleter.complete(error); + }, ), ); - unawaited( - controller.loadRequest(Uri.parse('https://www.notawebsite..com')), - ); + await controller.loadRequest(Uri.parse('https://www.notawebsite..com')); await tester.pumpWidget(WebViewWidget(controller: controller)); @@ -380,21 +354,17 @@ Future main() async { final Completer pageFinishCompleter = Completer(); final WebViewController controller = WebViewController(); - unawaited(controller.setJavaScriptMode(JavaScriptMode.unrestricted)); - unawaited( - controller.setNavigationDelegate( - NavigationDelegate( - onPageFinished: (_) => pageFinishCompleter.complete(), - onWebResourceError: (WebResourceError error) { - errorCompleter.complete(error); - }, - ), + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + await controller.setNavigationDelegate( + NavigationDelegate( + onPageFinished: (_) => pageFinishCompleter.complete(), + onWebResourceError: (WebResourceError error) { + errorCompleter.complete(error); + }, ), ); - unawaited( - controller.loadRequest( - Uri.parse('data:text/html;charset=utf-8;base64,PCFET0NUWVBFIGh0bWw+'), - ), + await controller.loadRequest( + Uri.parse('data:text/html;charset=utf-8;base64,PCFET0NUWVBFIGh0bWw+'), ); await tester.pumpWidget(WebViewWidget(controller: controller)); @@ -407,23 +377,21 @@ Future main() async { Completer pageLoaded = Completer(); final WebViewController controller = WebViewController(); - unawaited(controller.setJavaScriptMode(JavaScriptMode.unrestricted)); - unawaited( - controller.setNavigationDelegate( - NavigationDelegate( - onPageFinished: (_) => pageLoaded.complete(), - onNavigationRequest: (NavigationRequest navigationRequest) { - return (navigationRequest.url.contains('youtube.com')) - ? NavigationDecision.prevent - : NavigationDecision.navigate; - }, - ), + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + await controller.setNavigationDelegate( + NavigationDelegate( + onPageFinished: (_) => pageLoaded.complete(), + onNavigationRequest: (NavigationRequest navigationRequest) { + return (navigationRequest.url.contains('youtube.com')) + ? NavigationDecision.prevent + : NavigationDecision.navigate; + }, ), ); await tester.pumpWidget(WebViewWidget(controller: controller)); - unawaited(controller.loadRequest(Uri.parse(blankPageEncoded))); + await controller.loadRequest(Uri.parse(blankPageEncoded)); await pageLoaded.future; // Wait for initial page load. @@ -509,26 +477,24 @@ Future main() async { Completer pageLoaded = Completer(); final WebViewController controller = WebViewController(); - unawaited(controller.setJavaScriptMode(JavaScriptMode.unrestricted)); - unawaited( - controller.setNavigationDelegate( - NavigationDelegate( - onPageFinished: (_) => pageLoaded.complete(), - onNavigationRequest: (NavigationRequest navigationRequest) async { - NavigationDecision decision = NavigationDecision.prevent; - decision = await Future.delayed( - const Duration(milliseconds: 10), - () => NavigationDecision.navigate, - ); - return decision; - }, - ), + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + await controller.setNavigationDelegate( + NavigationDelegate( + onPageFinished: (_) => pageLoaded.complete(), + onNavigationRequest: (NavigationRequest navigationRequest) async { + NavigationDecision decision = NavigationDecision.prevent; + decision = await Future.delayed( + const Duration(milliseconds: 10), + () => NavigationDecision.navigate, + ); + return decision; + }, ), ); await tester.pumpWidget(WebViewWidget(controller: controller)); - unawaited(controller.loadRequest(Uri.parse(blankPageEncoded))); + await controller.loadRequest(Uri.parse(blankPageEncoded)); await pageLoaded.future; // Wait for initial page load. @@ -545,13 +511,11 @@ Future main() async { final WebViewController controller = WebViewController(); final Completer urlChangeCompleter = Completer(); - unawaited(controller.setJavaScriptMode(JavaScriptMode.unrestricted)); - unawaited( - controller.setNavigationDelegate( - NavigationDelegate(onPageFinished: (_) => pageLoaded.complete()), - ), + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + await controller.setNavigationDelegate( + NavigationDelegate(onPageFinished: (_) => pageLoaded.complete()), ); - unawaited(controller.loadRequest(Uri.parse(blankPageEncoded))); + await controller.loadRequest(Uri.parse(blankPageEncoded)); await tester.pumpWidget(WebViewWidget(controller: controller)); @@ -579,9 +543,9 @@ Future main() async { ); final WebViewController controller = WebViewController(); - unawaited(controller.setJavaScriptMode(JavaScriptMode.unrestricted)); - unawaited(controller.setNavigationDelegate(navigationDelegate)); - unawaited(controller.loadRequest(Uri.parse(primaryUrl))); + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + await controller.setNavigationDelegate(navigationDelegate); + await controller.loadRequest(Uri.parse(primaryUrl)); await tester.pumpWidget(WebViewWidget(controller: controller)); From bc1d884f3c956cae063c98873fbece6128bb4728 Mon Sep 17 00:00:00 2001 From: Seungsoo Lee Date: Mon, 29 Jun 2026 11:20:19 +0900 Subject: [PATCH 04/13] [webview_flutter_tizen] Stabilize scroll position integration test getScrollPosition() settles asynchronously after scrollTo/scrollBy, so reading it once right after the call was flaky (more so on software-GL rendering such as emulators). Poll the scroll position until it reaches the expected value, with a timeout, so the test waits for the value to settle instead of failing on a transient stale read. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../webview_flutter_test.dart | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/webview_flutter/example/integration_test/webview_flutter_test.dart b/packages/webview_flutter/example/integration_test/webview_flutter_test.dart index 7f40a6a1a..1068d5898 100644 --- a/packages/webview_flutter/example/integration_test/webview_flutter_test.dart +++ b/packages/webview_flutter/example/integration_test/webview_flutter_test.dart @@ -276,14 +276,30 @@ Future main() async { expect(scrollPos.dx, isNot(X_SCROLL)); expect(scrollPos.dy, isNot(Y_SCROLL)); + // The scroll position settles asynchronously, so poll until it reaches + // the expected value (with a timeout) instead of reading it once. This + // keeps the test stable on slower software-GL rendering (e.g. emulators). + Future pollScrollPosition(int expectedX, int expectedY) async { + Offset pos = await controller.getScrollPosition(); + for ( + int i = 0; + i < 20 && (pos.dx != expectedX || pos.dy != expectedY); + i++ + ) { + await Future.delayed(const Duration(milliseconds: 100)); + pos = await controller.getScrollPosition(); + } + return pos; + } + await controller.scrollTo(X_SCROLL, Y_SCROLL); - scrollPos = await controller.getScrollPosition(); + scrollPos = await pollScrollPosition(X_SCROLL, Y_SCROLL); expect(scrollPos.dx, X_SCROLL); expect(scrollPos.dy, Y_SCROLL); // Check scrollBy() (on top of scrollTo()) await controller.scrollBy(X_SCROLL, Y_SCROLL); - scrollPos = await controller.getScrollPosition(); + scrollPos = await pollScrollPosition(X_SCROLL * 2, Y_SCROLL * 2); expect(scrollPos.dx, X_SCROLL * 2); expect(scrollPos.dy, Y_SCROLL * 2); }); From 8b765e355bfadd01da1a1e534fba3b0c5dd0fb9b Mon Sep 17 00:00:00 2001 From: Seungsoo Lee Date: Thu, 9 Jul 2026 19:49:21 +0900 Subject: [PATCH 05/13] [webview_flutter_tizen] Fix disposal races and TV emulator teardown crash Rework WebView::Dispose() to tear resources down safely: - Detach all engine callbacks (including the missing "policy,response,decide") and defer evas_object_del() until the embedder's UnregisterTexture completion callback, so the engine-owned TBM surfaces are not freed while a raster-thread frame is still reading them (flutter-tizen/embedder#182). - Add is_alive_/is_disposing_ guards so async callbacks arriving after disposal no longer touch the destroyed WebView. - On the Tizen 10.0 TV emulator (TV_PROFILE + x86_64), hide the stopped view instead of deleting it to avoid a SIGSEGV in chromium-efl's ~SelectionControllerEfl(); revert once the engine fix ships. Verified on the TV 10.0 emulator: example integration tests previously crashed on WebView disposal and now pass 19/19. --- packages/webview_flutter/CHANGELOG.md | 2 + packages/webview_flutter/README.md | 2 +- packages/webview_flutter/tizen/src/webview.cc | 165 +++++++++++++++--- packages/webview_flutter/tizen/src/webview.h | 8 + 4 files changed, 152 insertions(+), 25 deletions(-) diff --git a/packages/webview_flutter/CHANGELOG.md b/packages/webview_flutter/CHANGELOG.md index 98fddfed2..13febe4ce 100644 --- a/packages/webview_flutter/CHANGELOG.md +++ b/packages/webview_flutter/CHANGELOG.md @@ -2,6 +2,8 @@ * Implement `clearLocalStorage`. * Implement `onHttpError` for the navigation delegate. +* Fix races and use-after-frees on WebView disposal, and avoid a web engine + teardown crash on the Tizen 10.0 TV emulator. ## 0.10.0 diff --git a/packages/webview_flutter/README.md b/packages/webview_flutter/README.md index 3dd1b9a1d..6dc6ef0dc 100644 --- a/packages/webview_flutter/README.md +++ b/packages/webview_flutter/README.md @@ -23,7 +23,7 @@ This package is not an _endorsed_ implementation of `webview_flutter`. Therefore ```yaml dependencies: webview_flutter: ^4.13.1 - webview_flutter_tizen: ^0.10.0 + webview_flutter_tizen: ^0.10.1 ``` ## Example diff --git a/packages/webview_flutter/tizen/src/webview.cc b/packages/webview_flutter/tizen/src/webview.cc index f51a95e1d..d1be2db7f 100644 --- a/packages/webview_flutter/tizen/src/webview.cc +++ b/packages/webview_flutter/tizen/src/webview.cc @@ -4,6 +4,7 @@ #include "webview.h" +#include #include #include #include @@ -44,9 +45,19 @@ std::string ConvertLogLevelToString(Ewk_Console_Message_Level level) { class NavigationRequestResult : public FlMethodResult { public: - NavigationRequestResult(WebView* webview) : webview_(webview) {} + // |alive| is the WebView's is_alive_ flag. Dart resolves the + // "navigationRequest" method call asynchronously (it round-trips through + // the Dart navigation delegate), so this result's completion can run well + // after the WebView that created it has been disposed; |webview_| would + // then be a dangling pointer. Checking |alive| before dereferencing it + // avoids a use-after-free in that case. + NavigationRequestResult(WebView* webview, std::shared_ptr alive) + : webview_(webview), alive_(std::move(alive)) {} void SuccessInternal(const flutter::EncodableValue* should_load) override { + if (!*alive_) { + return; + } if (std::holds_alternative(*should_load)) { if (std::get(*should_load)) { webview_->Resume(); @@ -60,16 +71,23 @@ class NavigationRequestResult : public FlMethodResult { const std::string& error_message, const flutter::EncodableValue* error_details) override { LOG_ERROR("The request unexpectedly completed with an error."); + if (!*alive_) { + return; + } webview_->Stop(); } void NotImplementedInternal() override { LOG_ERROR("The target method was unexpectedly unimplemented."); + if (!*alive_) { + return; + } webview_->Stop(); } private: WebView* webview_; + std::shared_ptr alive_; }; template @@ -173,33 +191,111 @@ void WebView::Dispose() { if (disposed_) { return; } + disposed_ = true; - texture_registrar_->UnregisterTexture(GetTextureId(), nullptr); - - if (webview_instance_) { - evas_object_smart_callback_del(webview_instance_, - "offscreen,frame,rendered", + // A Dart "navigationRequest" reply can still arrive after Dispose() has + // run. The reply handler checks this flag and returns early, instead of + // using a WebView that no longer exists. + *is_alive_ = false; + + Evas_Object* instance = webview_instance_; + webview_instance_ = nullptr; + + if (instance) { + // Detach every callback registered in InitWebView() and + // RegisterJavaScriptChannelName(). The engine instance lives until the + // deferred evas_object_del() below, while this WebView is destroyed + // right after Dispose(). Without detaching, the engine could invoke + // these callbacks on the already-destroyed WebView during that window. + evas_object_smart_callback_del(instance, "offscreen,frame,rendered", &WebView::OnFrameRendered); - evas_object_smart_callback_del(webview_instance_, "load,started", + evas_object_smart_callback_del(instance, "load,started", &WebView::OnLoadStarted); - evas_object_smart_callback_del(webview_instance_, "load,finished", + evas_object_smart_callback_del(instance, "load,finished", &WebView::OnLoadFinished); - evas_object_smart_callback_del(webview_instance_, "load,progress", + evas_object_smart_callback_del(instance, "load,progress", &WebView::OnProgress); - evas_object_smart_callback_del(webview_instance_, "load,error", + evas_object_smart_callback_del(instance, "load,error", &WebView::OnLoadError); - evas_object_smart_callback_del(webview_instance_, "console,message", + evas_object_smart_callback_del(instance, "console,message", &WebView::OnConsoleMessage); - evas_object_smart_callback_del(webview_instance_, - "policy,navigation,decide", + evas_object_smart_callback_del(instance, "policy,navigation,decide", &WebView::OnNavigationPolicy); - evas_object_smart_callback_del(webview_instance_, "url,changed", + evas_object_smart_callback_del(instance, "policy,response,decide", + &WebView::OnResponsePolicy); + evas_object_smart_callback_del(instance, "url,changed", &WebView::OnUrlChange); - evas_object_del(webview_instance_); + EwkInternalApiBinding::GetInstance().view.OnJavaScriptAlert( + instance, nullptr, nullptr); + EwkInternalApiBinding::GetInstance().view.OnJavaScriptConfirm( + instance, nullptr, nullptr); + EwkInternalApiBinding::GetInstance().view.OnJavaScriptPrompt( + instance, nullptr, nullptr); + evas_object_data_del(instance, kEwkInstance); + + // Cancel any in-flight load and pause the page so it stops running while + // the deferred teardown below is pending. + ewk_view_stop(instance); + ewk_view_suspend(instance); } + // Stop handing out engine-owned TBM surfaces to the raster thread, and + // detach the buffer pool so its GPU surface descriptors outlive this + // object for any raster-thread frame still in flight. + std::unique_ptr pool; + { + std::lock_guard lock(mutex_); + is_disposing_ = true; + working_surface_ = nullptr; + candidate_surface_ = nullptr; + rendered_surface_ = nullptr; + pool = std::move(tbm_pool_); + } + + // The TBM surfaces backing the texture are owned by the web engine and are + // freed when the engine view is deleted. Deleting the view while an + // in-flight raster-thread frame is still reading one of those surfaces is a + // use-after-free (the emulator's SW rendering path reads the buffer on the + // CPU), crashing with SIGSEGV during EWebView teardown. The embedder tears + // the external texture down on the render thread only after any in-flight + // frame callback has completed and then invokes this completion callback, + // so evas_object_del() (and the release of the descriptor-owning buffer + // pool) is deferred until then. The callback fires on the render thread; + // evas_object_del() must run on the main thread, so hop back via + // ecore_main_loop_thread_safe_call_async(). + struct TeardownContext { + Evas_Object* instance; + std::unique_ptr pool; + }; + auto* context = new TeardownContext{instance, std::move(pool)}; + texture_registrar_->UnregisterTexture(GetTextureId(), [context]() { + ecore_main_loop_thread_safe_call_async( + [](void* data) { + auto* context = static_cast(data); + if (context->instance) { +#if defined(TV_PROFILE) && (defined(__x86_64__) || defined(__i386__)) + // On the Tizen 10.0 TV emulator image (the only TV + x86_64 + // target), deleting the ewk view crashes with SIGSEGV inside + // chromium-efl's ~SelectionControllerEfl(): after unsubscribing + // VCONFKEY_LANGSET it calls HideHandleAndContextMenu() -> + // CancelContextMenu(), which dereferences the WebContents that is + // already being destructed. That is engine code this plugin + // cannot fix, so hide the (already stopped and suspended) view + // and intentionally leak it instead of crashing. All other + // targets (arm/arm64 devices, the 32-bit x86 emulator, and the + // common-profile x86_64 emulator, whose engines are unaffected) + // delete normally. + evas_object_hide(context->instance); +#else + evas_object_del(context->instance); +#endif + } + delete context; + }, + context); + }); + // ewk_shutdown(); - disposed_ = true; } void WebView::Offset(double left, double top) { @@ -331,9 +427,17 @@ bool WebView::SendKey(const char* key, const char* string, const char* compose, return true; } -void WebView::Resume() { ewk_view_resume(webview_instance_); } +void WebView::Resume() { + if (webview_instance_) { + ewk_view_resume(webview_instance_); + } +} -void WebView::Stop() { ewk_view_stop(webview_instance_); } +void WebView::Stop() { + if (webview_instance_) { + ewk_view_stop(webview_instance_); + } +} void WebView::SetDirection(int direction) { // TODO: Implement if necessary. @@ -454,6 +558,12 @@ void WebView::HandleWebViewMethodCall(const FlMethodCall& method_call, const std::string& method_name = method_call.method_name(); const flutter::EncodableValue* arguments = method_call.arguments(); + if (disposed_) { + result->Error("Invalid operation", + "The webview instance has been disposed."); + return; + } + if (method_name == "setEnginePolicy") { const auto* engine_policy = std::get_if(arguments); if (engine_policy) { @@ -751,6 +861,9 @@ void WebView::HandleCookieMethodCall(const FlMethodCall& method_call, FlutterDesktopGpuSurfaceDescriptor* WebView::ObtainGpuSurface(size_t width, size_t height) { std::lock_guard lock(mutex_); + if (is_disposing_ || !tbm_pool_) { + return nullptr; + } if (!candidate_surface_) { if (rendered_surface_) { return rendered_surface_->GpuSurface(); @@ -770,6 +883,9 @@ void WebView::OnFrameRendered(void* data, Evas_Object* obj, void* event_info) { WebView* webview = static_cast(data); std::lock_guard lock(webview->mutex_); + if (webview->is_disposing_ || !webview->tbm_pool_) { + return; + } if (!webview->working_surface_) { webview->working_surface_ = webview->tbm_pool_->GetAvailableBuffer(); webview->working_surface_->UseExternalBuffer(); @@ -871,14 +987,14 @@ void WebView::OnNavigationPolicy(void* data, Evas_Object* obj, {flutter::EncodableValue("isForMainFrame"), flutter::EncodableValue(true)}, }; - auto result = std::make_unique(webview); + auto result = + std::make_unique(webview, webview->is_alive_); webview->navigation_delegate_channel_->InvokeMethod( "navigationRequest", std::make_unique(args), std::move(result)); } -void WebView::OnResponsePolicy(void* data, Evas_Object* obj, - void* event_info) { +void WebView::OnResponsePolicy(void* data, Evas_Object* obj, void* event_info) { WebView* webview = static_cast(data); Ewk_Policy_Decision* policy_decision = static_cast(event_info); @@ -892,8 +1008,7 @@ void WebView::OnResponsePolicy(void* data, Evas_Object* obj, return; } flutter::EncodableMap args = { - {flutter::EncodableValue("url"), - flutter::EncodableValue(url ? url : "")}, + {flutter::EncodableValue("url"), flutter::EncodableValue(url ? url : "")}, {flutter::EncodableValue("statusCode"), flutter::EncodableValue(status_code)}, }; @@ -926,7 +1041,9 @@ void WebView::OnJavaScriptMessage(Evas_Object* obj, if (obj) { WebView* webview = static_cast(evas_object_data_get(obj, kEwkInstance)); - if (webview->webview_channel_) { + // The data key is removed in Dispose(), so a message arriving during the + // deferred teardown yields nullptr here rather than a dangling pointer. + if (webview && webview->webview_channel_) { std::string channel_name(message.name); std::string message_body(static_cast(message.body)); diff --git a/packages/webview_flutter/tizen/src/webview.h b/packages/webview_flutter/tizen/src/webview.h index ac47a9bd6..e0999f3ee 100644 --- a/packages/webview_flutter/tizen/src/webview.h +++ b/packages/webview_flutter/tizen/src/webview.h @@ -118,6 +118,14 @@ class WebView : public PlatformView { std::mutex mutex_; std::unique_ptr tbm_pool_; bool disposed_ = false; + // Set under mutex_ at the start of Dispose(). The raster thread checks it + // in ObtainGpuSurface() and stops being handed engine-owned TBM surfaces + // that are about to be freed by the deferred evas_object_del(). + bool is_disposing_ = false; + // Set to false at the start of Dispose(). A pending "navigationRequest" + // reply from Dart (resolved asynchronously) captures a copy and checks it + // before dereferencing this WebView, avoiding a use-after-free. + std::shared_ptr is_alive_ = std::make_shared(true); Ewk_Mouse_Button_Type mouse_button_type_ = (Ewk_Mouse_Button_Type)0; bool scrollbar_enabled_ = true; }; From 19820966b9caded74cd56c9ef35febeab2ccf62c Mon Sep 17 00:00:00 2001 From: Seungsoo Lee Date: Mon, 27 Jul 2026 13:40:24 +0900 Subject: [PATCH 06/13] [webview_flutter_tizen] Fix WebView disposal races, including a buffer-pool UAF - Fix a use-after-free in BufferPool: the engine's release_callback for an in-flight frame can fire on the raster thread after the owning BufferUnit has already been destroyed on the platform thread, dereferencing freed memory. Track live BufferUnits in a mutex-guarded registry and have the callback check it before touching the buffer. tbm_pool_ is now a shared_ptr so its lifetime extends through the deferred teardown below. - Replace ecore_main_loop_thread_safe_call_async() with GLib, following the Ecore removal in #1033 / #1045 / #1046. Must be g_timeout_add_full() at G_PRIORITY_HIGH, not g_idle_add(): an idle source runs too late and lets the delete race the raster thread. - Narrow the TV_PROFILE compile-time macro to a runtime getenv("ELM_PROFILE") check for the same evas_object_hide()-instead-of-del() workaround. Still needed: even with the buffer-pool fix above, evas_object_del() can intermittently crash the raster thread on the Tizen 10.0 TV emulator, and this replaces the compile-time check the review flagged. - Trim the disposal comments down to the constraints; the ordering rationale moves to the PR description. Verified via flutter-tizen drive: - TV 10.0 x86_64 emulator: 8/8 consecutive runs green (0 crashes). - Real TV device (armv7l): one full clean run (19/19); further repeats hit app-launch failures unrelated to this change. I will create a new issue for this situation. --- packages/webview_flutter/CHANGELOG.md | 6 +- .../webview_flutter/tizen/src/buffer_pool.cc | 27 +++++- packages/webview_flutter/tizen/src/webview.cc | 90 ++++++++----------- packages/webview_flutter/tizen/src/webview.h | 12 ++- 4 files changed, 69 insertions(+), 66 deletions(-) diff --git a/packages/webview_flutter/CHANGELOG.md b/packages/webview_flutter/CHANGELOG.md index 13febe4ce..aa832b8cd 100644 --- a/packages/webview_flutter/CHANGELOG.md +++ b/packages/webview_flutter/CHANGELOG.md @@ -2,8 +2,10 @@ * Implement `clearLocalStorage`. * Implement `onHttpError` for the navigation delegate. -* Fix races and use-after-frees on WebView disposal, and avoid a web engine - teardown crash on the Tizen 10.0 TV emulator. +* Fix races and use-after-frees on WebView disposal, including a buffer-pool + use-after-free on the raster thread. +* Replace the Ecore main loop API with GLib. +* Narrow the TV emulator teardown workaround to a runtime profile check. ## 0.10.0 diff --git a/packages/webview_flutter/tizen/src/buffer_pool.cc b/packages/webview_flutter/tizen/src/buffer_pool.cc index ed8fde021..28a2dd82f 100644 --- a/packages/webview_flutter/tizen/src/buffer_pool.cc +++ b/packages/webview_flutter/tizen/src/buffer_pool.cc @@ -4,11 +4,31 @@ #include "buffer_pool.h" +#include +#include + #include "log.h" -BufferUnit::BufferUnit(int32_t width, int32_t height) { Reset(width, height); } +namespace { +// Tracks live BufferUnits so the engine's release_callback (below) can detect +// one that was already destroyed instead of dereferencing freed memory. +std::set active_buffers; +std::mutex active_buffers_mutex; +} // namespace + +BufferUnit::BufferUnit(int32_t width, int32_t height) { + { + std::lock_guard lock(active_buffers_mutex); + active_buffers.insert(this); + } + Reset(width, height); +} BufferUnit::~BufferUnit() { + { + std::lock_guard lock(active_buffers_mutex); + active_buffers.erase(this); + } if (tbm_surface_ && !use_external_buffer_) { tbm_surface_destroy(tbm_surface_); tbm_surface_ = nullptr; @@ -76,7 +96,10 @@ void BufferUnit::Reset(int32_t width, int32_t height) { gpu_surface_->handle = tbm_surface_; gpu_surface_->release_callback = [](void* release_context) { BufferUnit* buffer = reinterpret_cast(release_context); - buffer->UnmarkInUse(); + std::lock_guard lock(active_buffers_mutex); + if (active_buffers.find(buffer) != active_buffers.end()) { + buffer->UnmarkInUse(); + } }; gpu_surface_->release_context = this; } diff --git a/packages/webview_flutter/tizen/src/webview.cc b/packages/webview_flutter/tizen/src/webview.cc index d1be2db7f..cc589ca9a 100644 --- a/packages/webview_flutter/tizen/src/webview.cc +++ b/packages/webview_flutter/tizen/src/webview.cc @@ -4,13 +4,16 @@ #include "webview.h" -#include #include #include #include #include +#include #include +#include +#include + #include "buffer_pool.h" #include "log.h" #include "webview_factory.h" @@ -45,12 +48,8 @@ std::string ConvertLogLevelToString(Ewk_Console_Message_Level level) { class NavigationRequestResult : public FlMethodResult { public: - // |alive| is the WebView's is_alive_ flag. Dart resolves the - // "navigationRequest" method call asynchronously (it round-trips through - // the Dart navigation delegate), so this result's completion can run well - // after the WebView that created it has been disposed; |webview_| would - // then be a dangling pointer. Checking |alive| before dereferencing it - // avoids a use-after-free in that case. + // Dart resolves "navigationRequest" asynchronously, so completion can run + // after |webview| is destroyed; |alive| gates every dereference. NavigationRequestResult(WebView* webview, std::shared_ptr alive) : webview_(webview), alive_(std::move(alive)) {} @@ -121,7 +120,7 @@ WebView::WebView(flutter::PluginRegistrar* registrar, int view_id, return; } - tbm_pool_ = std::make_unique(width, height); + tbm_pool_ = std::make_shared(width, height); texture_variant_ = std::make_unique(flutter::GpuSurfaceTexture( @@ -192,21 +191,14 @@ void WebView::Dispose() { return; } disposed_ = true; - - // A Dart "navigationRequest" reply can still arrive after Dispose() has - // run. The reply handler checks this flag and returns early, instead of - // using a WebView that no longer exists. *is_alive_ = false; Evas_Object* instance = webview_instance_; webview_instance_ = nullptr; if (instance) { - // Detach every callback registered in InitWebView() and - // RegisterJavaScriptChannelName(). The engine instance lives until the - // deferred evas_object_del() below, while this WebView is destroyed - // right after Dispose(). Without detaching, the engine could invoke - // these callbacks on the already-destroyed WebView during that window. + // The engine instance outlives this WebView until the deferred + // evas_object_del() below, so every callback must be detached here. evas_object_smart_callback_del(instance, "offscreen,frame,rendered", &WebView::OnFrameRendered); evas_object_smart_callback_del(instance, "load,started", @@ -233,16 +225,12 @@ void WebView::Dispose() { instance, nullptr, nullptr); evas_object_data_del(instance, kEwkInstance); - // Cancel any in-flight load and pause the page so it stops running while - // the deferred teardown below is pending. + // Stop the page so it cannot run while the deferred teardown is pending. ewk_view_stop(instance); ewk_view_suspend(instance); } - // Stop handing out engine-owned TBM surfaces to the raster thread, and - // detach the buffer pool so its GPU surface descriptors outlive this - // object for any raster-thread frame still in flight. - std::unique_ptr pool; + std::shared_ptr pool; { std::lock_guard lock(mutex_); is_disposing_ = true; @@ -252,47 +240,36 @@ void WebView::Dispose() { pool = std::move(tbm_pool_); } - // The TBM surfaces backing the texture are owned by the web engine and are - // freed when the engine view is deleted. Deleting the view while an - // in-flight raster-thread frame is still reading one of those surfaces is a - // use-after-free (the emulator's SW rendering path reads the buffer on the - // CPU), crashing with SIGSEGV during EWebView teardown. The embedder tears - // the external texture down on the render thread only after any in-flight - // frame callback has completed and then invokes this completion callback, - // so evas_object_del() (and the release of the descriptor-owning buffer - // pool) is deferred until then. The callback fires on the render thread; - // evas_object_del() must run on the main thread, so hop back via - // ecore_main_loop_thread_safe_call_async(). + // evas_object_del() frees TBM surfaces the raster thread may still be + // reading, so defer it until the embedder confirms the texture is torn + // down. That confirmation fires on the render thread, hence the hop below. struct TeardownContext { Evas_Object* instance; - std::unique_ptr pool; + std::shared_ptr pool; }; auto* context = new TeardownContext{instance, std::move(pool)}; texture_registrar_->UnregisterTexture(GetTextureId(), [context]() { - ecore_main_loop_thread_safe_call_async( - [](void* data) { + // Must stay a high-priority timeout: g_idle_add() runs too late and the + // delete then races the raster thread on the TV emulator. + g_timeout_add_full( + G_PRIORITY_HIGH, 0, + [](gpointer data) -> gboolean { auto* context = static_cast(data); if (context->instance) { -#if defined(TV_PROFILE) && (defined(__x86_64__) || defined(__i386__)) - // On the Tizen 10.0 TV emulator image (the only TV + x86_64 - // target), deleting the ewk view crashes with SIGSEGV inside - // chromium-efl's ~SelectionControllerEfl(): after unsubscribing - // VCONFKEY_LANGSET it calls HideHandleAndContextMenu() -> - // CancelContextMenu(), which dereferences the WebContents that is - // already being destructed. That is engine code this plugin - // cannot fix, so hide the (already stopped and suspended) view - // and intentionally leak it instead of crashing. All other - // targets (arm/arm64 devices, the 32-bit x86 emulator, and the - // common-profile x86_64 emulator, whose engines are unaffected) - // delete normally. - evas_object_hide(context->instance); -#else - evas_object_del(context->instance); -#endif + const char* profile = getenv("ELM_PROFILE"); + if (profile && strcmp(profile, "tv") == 0) { + // TODO: evas_object_del() still crashes the raster thread + // intermittently on the Tizen 10.0 TV emulator, so leak the view + // there instead until the engine is fixed. + evas_object_hide(context->instance); + } else { + evas_object_del(context->instance); + } } - delete context; + return G_SOURCE_REMOVE; }, - context); + context, + [](gpointer data) { delete static_cast(data); }); }); // ewk_shutdown(); @@ -468,6 +445,9 @@ bool WebView::InitWebView() { // temporarily comment out ewk_init() and ewk_shutdown(). It can be reverted // depending on updates to chromium-efl. // ewk_init(); + + // Not freed on disposal: ecore_evas_free() would eglTerminate() the EGL + // display shared with the Flutter renderer and kill the process. Ecore_Evas* evas = ecore_evas_new("wayland_egl", 0, 0, 1, 1, 0); webview_instance_ = ewk_view_add(ecore_evas_get(evas)); diff --git a/packages/webview_flutter/tizen/src/webview.h b/packages/webview_flutter/tizen/src/webview.h index e0999f3ee..33ee206a0 100644 --- a/packages/webview_flutter/tizen/src/webview.h +++ b/packages/webview_flutter/tizen/src/webview.h @@ -116,15 +116,13 @@ class WebView : public PlatformView { std::unique_ptr navigation_delegate_channel_; std::unique_ptr texture_variant_; std::mutex mutex_; - std::unique_ptr tbm_pool_; + std::shared_ptr tbm_pool_; bool disposed_ = false; - // Set under mutex_ at the start of Dispose(). The raster thread checks it - // in ObtainGpuSurface() and stops being handed engine-owned TBM surfaces - // that are about to be freed by the deferred evas_object_del(). + // Guarded by mutex_. Keeps the raster thread from being handed TBM surfaces + // that the deferred evas_object_del() is about to free. bool is_disposing_ = false; - // Set to false at the start of Dispose(). A pending "navigationRequest" - // reply from Dart (resolved asynchronously) captures a copy and checks it - // before dereferencing this WebView, avoiding a use-after-free. + // Copied into pending async Dart replies so they can detect a WebView that + // was destroyed before the reply arrived. std::shared_ptr is_alive_ = std::make_shared(true); Ewk_Mouse_Button_Type mouse_button_type_ = (Ewk_Mouse_Button_Type)0; bool scrollbar_enabled_ = true; From 1680b4b0e29c96770db1f4ecac0ccd5d54985493 Mon Sep 17 00:00:00 2001 From: Seungsoo Lee Date: Fri, 7 Aug 2026 17:34:20 +0900 Subject: [PATCH 07/13] [webview_flutter_tizen] Fix native crash on app exit and enable multiple WebViews chromium-efl's ewk_shutdown() fatally CHECKs if any Ewk_View is still alive ("Client didn't destroy all WebView objects before calling ewk_shutdown"). ewk_init()/ewk_shutdown() are now called once for the process lifetime from WebviewFlutterTizenPlugin's constructor/destructor, but WebView::Dispose() only queues its evas_object_del() behind an UnregisterTexture() callback and a g_timeout_add_full() hop onto the main loop, so a view could still be alive when the plugin destructor runs ewk_shutdown(). Add a PendingTeardown registry that tracks each deferred evas_object_del() from Dispose(), and WebView::FlushPendingTeardowns(), called from the plugin destructor right before ewk_shutdown(), which pumps the GLib main loop until the registry drains (or force-deletes any stragglers past a 2s deadline, since a leaked view is a guaranteed fatal CHECK). This also removes the TV emulator's evas_object_hide() workaround, which leaked the ewk_view and directly conflicted with ewk_shutdown()'s contract. That workaround no longer reproduces the raster-thread crash it was added for, and leaking now causes the exact crash this commit fixes. Removing it also resolves the "only one WebView at a time" limitation, since two disposed-but-leaked views could never both be torn down cleanly. Verified on the real TV target (10.113.112.246:26101) across 3 consecutive `flutter-tizen drive` runs of the full integration_test suite: all pass, no SIGSEGV/SIGTRAP, and no new crash report. --- packages/webview_flutter/CHANGELOG.md | 4 +- packages/webview_flutter/tizen/src/webview.cc | 104 +++++++++++++----- packages/webview_flutter/tizen/src/webview.h | 5 + .../tizen/src/webview_flutter_tizen_plugin.cc | 15 ++- 4 files changed, 98 insertions(+), 30 deletions(-) diff --git a/packages/webview_flutter/CHANGELOG.md b/packages/webview_flutter/CHANGELOG.md index aa832b8cd..14027c5b0 100644 --- a/packages/webview_flutter/CHANGELOG.md +++ b/packages/webview_flutter/CHANGELOG.md @@ -5,7 +5,9 @@ * Fix races and use-after-frees on WebView disposal, including a buffer-pool use-after-free on the raster thread. * Replace the Ecore main loop API with GLib. -* Narrow the TV emulator teardown workaround to a runtime profile check. +* Remove the TV emulator's evas_object_del() workaround; drain all pending + WebView teardowns before ewk_shutdown() instead, fixing a native crash on + app exit and enabling simultaneous use of multiple WebViews. ## 0.10.0 diff --git a/packages/webview_flutter/tizen/src/webview.cc b/packages/webview_flutter/tizen/src/webview.cc index cc589ca9a..d6b95d335 100644 --- a/packages/webview_flutter/tizen/src/webview.cc +++ b/packages/webview_flutter/tizen/src/webview.cc @@ -11,8 +11,11 @@ #include #include +#include +#include #include #include +#include #include "buffer_pool.h" #include "log.h" @@ -104,6 +107,42 @@ bool GetValueFromEncodableMap(const flutter::EncodableValue* arguments, return false; } +// Tracks Dispose()'s deferred evas_object_del() calls so +// FlushPendingTeardowns() can drain them to empty before ewk_shutdown(). +struct PendingTeardown { + Evas_Object* instance = nullptr; + // Guards against the queued callback and a force-delete both deleting + // the same instance. + std::atomic completed{false}; +}; + +std::mutex g_pending_teardown_mutex; +std::vector> g_pending_teardowns; + +std::shared_ptr RegisterPendingTeardown( + Evas_Object* instance) { + auto pending = std::make_shared(); + pending->instance = instance; + std::lock_guard lock(g_pending_teardown_mutex); + g_pending_teardowns.push_back(pending); + return pending; +} + +// Idempotent: safe to call more than once for the same |pending|. +void CompletePendingTeardown(const std::shared_ptr& pending) { + bool expected = false; + if (pending->completed.compare_exchange_strong(expected, true) && + pending->instance) { + evas_object_del(pending->instance); + } + std::lock_guard lock(g_pending_teardown_mutex); + auto it = std::find(g_pending_teardowns.begin(), g_pending_teardowns.end(), + pending); + if (it != g_pending_teardowns.end()) { + g_pending_teardowns.erase(it); + } +} + } // namespace WebView::WebView(flutter::PluginRegistrar* registrar, int view_id, @@ -244,10 +283,11 @@ void WebView::Dispose() { // reading, so defer it until the embedder confirms the texture is torn // down. That confirmation fires on the render thread, hence the hop below. struct TeardownContext { - Evas_Object* instance; + std::shared_ptr pending; std::shared_ptr pool; }; - auto* context = new TeardownContext{instance, std::move(pool)}; + auto* context = + new TeardownContext{RegisterPendingTeardown(instance), std::move(pool)}; texture_registrar_->UnregisterTexture(GetTextureId(), [context]() { // Must stay a high-priority timeout: g_idle_add() runs too late and the // delete then races the raster thread on the TV emulator. @@ -255,24 +295,39 @@ void WebView::Dispose() { G_PRIORITY_HIGH, 0, [](gpointer data) -> gboolean { auto* context = static_cast(data); - if (context->instance) { - const char* profile = getenv("ELM_PROFILE"); - if (profile && strcmp(profile, "tv") == 0) { - // TODO: evas_object_del() still crashes the raster thread - // intermittently on the Tizen 10.0 TV emulator, so leak the view - // there instead until the engine is fixed. - evas_object_hide(context->instance); - } else { - evas_object_del(context->instance); - } - } + CompletePendingTeardown(context->pending); return G_SOURCE_REMOVE; }, context, [](gpointer data) { delete static_cast(data); }); }); +} - // ewk_shutdown(); +// static +void WebView::FlushPendingTeardowns() { + constexpr gint64 kDeadlineUsec = 2 * G_USEC_PER_SEC; + const gint64 deadline = g_get_monotonic_time() + kDeadlineUsec; + for (;;) { + std::shared_ptr pending; + { + std::lock_guard lock(g_pending_teardown_mutex); + if (g_pending_teardowns.empty()) { + return; + } + pending = g_pending_teardowns.front(); + } + if (g_get_monotonic_time() >= deadline) { + // A leaked Ewk_View is a guaranteed fatal CHECK in ewk_shutdown(), so + // force the remaining deletes through rather than waiting any longer. + LOG_WARN("Forcing WebView teardown past the deadline before ewk_shutdown()"); + CompletePendingTeardown(pending); + continue; + } + // Pump the same GLib context the queued g_timeout_add_full hop (see + // Dispose()) is scheduled on, so it gets a chance to run and remove + // this entry itself. + g_main_context_iteration(g_main_context_default(), TRUE); + } } void WebView::Offset(double left, double top) { @@ -436,19 +491,14 @@ bool WebView::InitWebView() { EwkInternalApiBinding::GetInstance().main.SetArguments(chromium_argc, chromium_argv); - // TODO(jsuya): ewk_init() and ewk_shutdown() are designed to be called only - // once in a process.(If ewk_init() is called after ewk_shutdown() is - // called, SIGTRAP is called internally.) ewk_init() initializes the efl - // modules and web engine's arguments data. The efl modules are initialized - // by default in OS, and arguments data is also initialized through - // SetArguments() API, so calling ewk_init() is not necessary. Therefore, - // temporarily comment out ewk_init() and ewk_shutdown(). It can be reverted - // depending on updates to chromium-efl. - // ewk_init(); - - // Not freed on disposal: ecore_evas_free() would eglTerminate() the EGL - // display shared with the Flutter renderer and kill the process. - Ecore_Evas* evas = ecore_evas_new("wayland_egl", 0, 0, 1, 1, 0); + // ewk_init() is called once for the process lifetime by + // WebviewFlutterTizenPlugin's constructor, not per WebView instance. + + // "wayland_shm", not "wayland_egl": this canvas only hosts the ewk_view + // smart object (content arrives via tbm_surface), and wayland_egl raced + // libtpl-egl's wl_egl_thread teardown on disposal. Intentionally leaked + // (not ecore_evas_free()'d) on disposal, as before. + Ecore_Evas* evas = ecore_evas_new("wayland_shm", 0, 0, 1, 1, 0); webview_instance_ = ewk_view_add(ecore_evas_get(evas)); if (!webview_instance_) { diff --git a/packages/webview_flutter/tizen/src/webview.h b/packages/webview_flutter/tizen/src/webview.h index 33ee206a0..7c9b6f4fa 100644 --- a/packages/webview_flutter/tizen/src/webview.h +++ b/packages/webview_flutter/tizen/src/webview.h @@ -58,6 +58,11 @@ class WebView : public PlatformView { FlutterDesktopGpuSurfaceDescriptor* ObtainGpuSurface(size_t width, size_t height); + // Blocks until every WebView's deferred evas_object_del() (queued in + // Dispose()) has run, force-deleting stragglers after a timeout. Must be + // called before ewk_shutdown(), which fatally CHECKs on any live Ewk_View. + static void FlushPendingTeardowns(); + private: void HandleWebViewMethodCall(const FlMethodCall& method_call, std::unique_ptr result); diff --git a/packages/webview_flutter/tizen/src/webview_flutter_tizen_plugin.cc b/packages/webview_flutter/tizen/src/webview_flutter_tizen_plugin.cc index 749f51a9a..b4aaa7204 100644 --- a/packages/webview_flutter/tizen/src/webview_flutter_tizen_plugin.cc +++ b/packages/webview_flutter/tizen/src/webview_flutter_tizen_plugin.cc @@ -4,17 +4,23 @@ #include "webview_flutter_tizen_plugin.h" +#include #include #include #include +#include "webview.h" #include "webview_factory.h" namespace { constexpr char kViewType[] = "plugins.flutter.io/webview"; +// Tied to this plugin object's lifetime (constructed/destroyed exactly once +// by flutter-tizen's engine start/stop), not per-WebView: chromium-efl does +// not support re-initializing its browser process after ewk_shutdown() has +// run once. class WebviewFlutterTizenPlugin : public flutter::Plugin { public: static void RegisterWithRegistrar(flutter::PluginRegistrar* registrar) { @@ -22,9 +28,14 @@ class WebviewFlutterTizenPlugin : public flutter::Plugin { registrar->AddPlugin(std::move(plugin)); } - WebviewFlutterTizenPlugin() {} + WebviewFlutterTizenPlugin() { ewk_init(); } - virtual ~WebviewFlutterTizenPlugin() {} + virtual ~WebviewFlutterTizenPlugin() { + // ewk_shutdown() fatally CHECKs if any Ewk_View is still alive; drain + // Dispose()'s deferred deletes first. + WebView::FlushPendingTeardowns(); + ewk_shutdown(); + } }; } // namespace From cf91ddc44a70f5607023e437f85caa67be6110d5 Mon Sep 17 00:00:00 2001 From: Seungsoo Lee Date: Fri, 7 Aug 2026 17:34:25 +0900 Subject: [PATCH 08/13] [webview_flutter_tizen] Add test for using multiple WebViews simultaneously Mounts and disposes two WebViews at the same time, which used to hit a native disposal race fixed in the previous commit (see the now-removed "Using more than one WebView at the same time" note in README.md). Verified on the real TV target (10.113.112.246:26101) across 3 consecutive full-suite runs, all passing. --- .../webview_flutter_test.dart | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/packages/webview_flutter/example/integration_test/webview_flutter_test.dart b/packages/webview_flutter/example/integration_test/webview_flutter_test.dart index 1068d5898..e0c97918d 100644 --- a/packages/webview_flutter/example/integration_test/webview_flutter_test.dart +++ b/packages/webview_flutter/example/integration_test/webview_flutter_test.dart @@ -618,6 +618,60 @@ Future main() async { ); expect(nullItem, ''); }); + + // Tizen-specific: mounts and disposes two WebViews at the same time, which + // used to hit a native disposal race (see the "Using more than one WebView + // at the same time" note in README.md). + testWidgets('multiple WebViews can be used simultaneously', ( + WidgetTester tester, + ) async { + final Completer firstPageFinished = Completer(); + final Completer secondPageFinished = Completer(); + + final WebViewController firstController = WebViewController(); + await firstController.setJavaScriptMode(JavaScriptMode.unrestricted); + await firstController.setNavigationDelegate( + NavigationDelegate(onPageFinished: (_) => firstPageFinished.complete()), + ); + await firstController.loadRequest(Uri.parse(primaryUrl)); + + final WebViewController secondController = WebViewController(); + await secondController.setJavaScriptMode(JavaScriptMode.unrestricted); + await secondController.setNavigationDelegate( + NavigationDelegate(onPageFinished: (_) => secondPageFinished.complete()), + ); + await secondController.loadRequest(Uri.parse(secondaryUrl)); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Row( + children: [ + Expanded(child: WebViewWidget(controller: firstController)), + Expanded(child: WebViewWidget(controller: secondController)), + ], + ), + ), + ); + + await firstPageFinished.future; + await secondPageFinished.future; + + expect(await firstController.currentUrl(), primaryUrl); + expect(await secondController.currentUrl(), secondaryUrl); + + await expectLater( + firstController.runJavaScriptReturningResult('1 + 1'), + completion(2), + ); + await expectLater( + secondController.runJavaScriptReturningResult('2 + 2'), + completion(4), + ); + + // Unmount both WebViews together to exercise concurrent native teardown. + await tester.pumpWidget(Container()); + }); } class ResizableWebView extends StatefulWidget { From 916cea326767072134191647e3125035fa973911 Mon Sep 17 00:00:00 2001 From: Seungsoo Lee Date: Mon, 10 Aug 2026 14:34:57 +0900 Subject: [PATCH 09/13] [webview_flutter_tizen] Free the ewk_view canvas on plugin shutdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InitWebView() created a new Ecore_Evas per WebView instance and never freed it, leaking one canvas per WebView for the process lifetime. Following DALi's WebEngineManager pattern, WebView instances now share a single canvas created lazily and freed once, after all WebViews are torn down and before ewk_shutdown(). Verified on real TV (10.113.112.246, 8/8 consecutive runs, no crashes) and on the TV emulator (emulator-26111, 10/12, matching the pre-existing #1077 VizCompositorTh crash rate — no regression from this change). --- packages/webview_flutter/tizen/src/webview.cc | 31 +++++++++++++++---- packages/webview_flutter/tizen/src/webview.h | 6 ++++ .../tizen/src/webview_flutter_tizen_plugin.cc | 4 ++- 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/packages/webview_flutter/tizen/src/webview.cc b/packages/webview_flutter/tizen/src/webview.cc index d6b95d335..9726e0e83 100644 --- a/packages/webview_flutter/tizen/src/webview.cc +++ b/packages/webview_flutter/tizen/src/webview.cc @@ -119,6 +119,8 @@ struct PendingTeardown { std::mutex g_pending_teardown_mutex; std::vector> g_pending_teardowns; +Ecore_Evas* g_shared_canvas = nullptr; + std::shared_ptr RegisterPendingTeardown( Evas_Object* instance) { auto pending = std::make_shared(); @@ -303,6 +305,25 @@ void WebView::Dispose() { }); } +// static +Ecore_Evas* WebView::GetSharedCanvas() { + if (!g_shared_canvas) { + // "wayland_shm", not "wayland_egl": this canvas only hosts the ewk_view + // smart object (content arrives via tbm_surface), and wayland_egl raced + // libtpl-egl's wl_egl_thread teardown on disposal. + g_shared_canvas = ecore_evas_new("wayland_shm", 0, 0, 1, 1, 0); + } + return g_shared_canvas; +} + +// static +void WebView::FreeSharedCanvas() { + if (g_shared_canvas) { + ecore_evas_free(g_shared_canvas); + g_shared_canvas = nullptr; + } +} + // static void WebView::FlushPendingTeardowns() { constexpr gint64 kDeadlineUsec = 2 * G_USEC_PER_SEC; @@ -493,12 +514,10 @@ bool WebView::InitWebView() { // ewk_init() is called once for the process lifetime by // WebviewFlutterTizenPlugin's constructor, not per WebView instance. - - // "wayland_shm", not "wayland_egl": this canvas only hosts the ewk_view - // smart object (content arrives via tbm_surface), and wayland_egl raced - // libtpl-egl's wl_egl_thread teardown on disposal. Intentionally leaked - // (not ecore_evas_free()'d) on disposal, as before. - Ecore_Evas* evas = ecore_evas_new("wayland_shm", 0, 0, 1, 1, 0); + Ecore_Evas* evas = GetSharedCanvas(); + if (!evas) { + return false; + } webview_instance_ = ewk_view_add(ecore_evas_get(evas)); if (!webview_instance_) { diff --git a/packages/webview_flutter/tizen/src/webview.h b/packages/webview_flutter/tizen/src/webview.h index 7c9b6f4fa..a9055b5d0 100644 --- a/packages/webview_flutter/tizen/src/webview.h +++ b/packages/webview_flutter/tizen/src/webview.h @@ -27,6 +27,7 @@ typedef flutter::MethodChannel FlMethodChannel; class BufferPool; class BufferUnit; +typedef struct _Ecore_Evas Ecore_Evas; class WebView : public PlatformView { public: @@ -63,6 +64,11 @@ class WebView : public PlatformView { // called before ewk_shutdown(), which fatally CHECKs on any live Ewk_View. static void FlushPendingTeardowns(); + static Ecore_Evas* GetSharedCanvas(); + + // Must be called after FlushPendingTeardowns() and before ewk_shutdown(). + static void FreeSharedCanvas(); + private: void HandleWebViewMethodCall(const FlMethodCall& method_call, std::unique_ptr result); diff --git a/packages/webview_flutter/tizen/src/webview_flutter_tizen_plugin.cc b/packages/webview_flutter/tizen/src/webview_flutter_tizen_plugin.cc index b4aaa7204..82b778c1a 100644 --- a/packages/webview_flutter/tizen/src/webview_flutter_tizen_plugin.cc +++ b/packages/webview_flutter/tizen/src/webview_flutter_tizen_plugin.cc @@ -32,8 +32,10 @@ class WebviewFlutterTizenPlugin : public flutter::Plugin { virtual ~WebviewFlutterTizenPlugin() { // ewk_shutdown() fatally CHECKs if any Ewk_View is still alive; drain - // Dispose()'s deferred deletes first. + // Dispose()'s deferred deletes first, then free the canvas they were + // hosted on. WebView::FlushPendingTeardowns(); + WebView::FreeSharedCanvas(); ewk_shutdown(); } }; From 7d80cd9900e2ebde080a7554aee63c5fc5c0d6a5 Mon Sep 17 00:00:00 2001 From: Seungsoo Lee Date: Mon, 10 Aug 2026 14:44:42 +0900 Subject: [PATCH 10/13] [webview_flutter_tizen] Fix incorrect README reference in a test comment The "multiple WebViews can be used simultaneously" test's comment cited a "Using more than one WebView at the same time" note in README.md that never existed in the file's history. Point to the actual source instead: CHANGELOG.md's 0.10.1 entry, which documents the disposal-race fix. --- .../example/integration_test/webview_flutter_test.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/webview_flutter/example/integration_test/webview_flutter_test.dart b/packages/webview_flutter/example/integration_test/webview_flutter_test.dart index e0c97918d..5c15848f6 100644 --- a/packages/webview_flutter/example/integration_test/webview_flutter_test.dart +++ b/packages/webview_flutter/example/integration_test/webview_flutter_test.dart @@ -620,8 +620,7 @@ Future main() async { }); // Tizen-specific: mounts and disposes two WebViews at the same time, which - // used to hit a native disposal race (see the "Using more than one WebView - // at the same time" note in README.md). + // used to hit a native disposal race fixed in CHANGELOG.md's 0.10.1 entry. testWidgets('multiple WebViews can be used simultaneously', ( WidgetTester tester, ) async { From 6c777691c1131306d314f3339615e18f726e6296 Mon Sep 17 00:00:00 2001 From: Seungsoo Lee Date: Mon, 10 Aug 2026 15:08:40 +0900 Subject: [PATCH 11/13] [webview_flutter_tizen] Clean up evas_object_del() comments The deferred-delete mechanism (raster thread may still read TBM surfaces; ewk_shutdown() fatally CHECKs on a live Ewk_View) was re-explained with slightly different wording at seven separate spots across webview.cc, webview.h, and webview_flutter_tizen_plugin.cc. Consolidate the explanation into a single comment on PendingTeardown and trim the rest to short, non-redundant notes. Also fix CHANGELOG.md's 0.10.1 entry, which described the removed TV emulator workaround backwards: the workaround used evas_object_hide() to avoid calling evas_object_del(), it was not itself an "evas_object_del() workaround". --- packages/webview_flutter/CHANGELOG.md | 7 ++++--- packages/webview_flutter/tizen/src/webview.cc | 21 ++++++++++--------- packages/webview_flutter/tizen/src/webview.h | 8 +++---- .../tizen/src/webview_flutter_tizen_plugin.cc | 4 +--- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/packages/webview_flutter/CHANGELOG.md b/packages/webview_flutter/CHANGELOG.md index 14027c5b0..59f858277 100644 --- a/packages/webview_flutter/CHANGELOG.md +++ b/packages/webview_flutter/CHANGELOG.md @@ -5,9 +5,10 @@ * Fix races and use-after-frees on WebView disposal, including a buffer-pool use-after-free on the raster thread. * Replace the Ecore main loop API with GLib. -* Remove the TV emulator's evas_object_del() workaround; drain all pending - WebView teardowns before ewk_shutdown() instead, fixing a native crash on - app exit and enabling simultaneous use of multiple WebViews. +* Remove the TV emulator's evas_object_hide() workaround (which skipped + evas_object_del() to avoid a crash); drain all pending WebView teardowns + before ewk_shutdown() instead, fixing a native crash on app exit and + enabling simultaneous use of multiple WebViews. ## 0.10.0 diff --git a/packages/webview_flutter/tizen/src/webview.cc b/packages/webview_flutter/tizen/src/webview.cc index 9726e0e83..fb4ee1d38 100644 --- a/packages/webview_flutter/tizen/src/webview.cc +++ b/packages/webview_flutter/tizen/src/webview.cc @@ -107,8 +107,9 @@ bool GetValueFromEncodableMap(const flutter::EncodableValue* arguments, return false; } -// Tracks Dispose()'s deferred evas_object_del() calls so -// FlushPendingTeardowns() can drain them to empty before ewk_shutdown(). +// Deferred: the raster thread may still be reading TBM surfaces the engine +// owns, and ewk_shutdown() fatally CHECKs if any Ewk_View is still alive. +// FlushPendingTeardowns() drains this registry before that shutdown call. struct PendingTeardown { Evas_Object* instance = nullptr; // Guards against the queued callback and a force-delete both deleting @@ -238,8 +239,8 @@ void WebView::Dispose() { webview_instance_ = nullptr; if (instance) { - // The engine instance outlives this WebView until the deferred - // evas_object_del() below, so every callback must be detached here. + // |instance| outlives this WebView until its deferred delete runs (see + // PendingTeardown), so every callback bound to it must be detached now. evas_object_smart_callback_del(instance, "offscreen,frame,rendered", &WebView::OnFrameRendered); evas_object_smart_callback_del(instance, "load,started", @@ -281,9 +282,8 @@ void WebView::Dispose() { pool = std::move(tbm_pool_); } - // evas_object_del() frees TBM surfaces the raster thread may still be - // reading, so defer it until the embedder confirms the texture is torn - // down. That confirmation fires on the render thread, hence the hop below. + // UnregisterTexture()'s completion callback fires on the render thread, so + // hop back to the main loop before completing the deferred delete. struct TeardownContext { std::shared_ptr pending; std::shared_ptr pool; @@ -338,9 +338,10 @@ void WebView::FlushPendingTeardowns() { pending = g_pending_teardowns.front(); } if (g_get_monotonic_time() >= deadline) { - // A leaked Ewk_View is a guaranteed fatal CHECK in ewk_shutdown(), so - // force the remaining deletes through rather than waiting any longer. - LOG_WARN("Forcing WebView teardown past the deadline before ewk_shutdown()"); + // Force stragglers through past the deadline instead of blocking + // forever. + LOG_WARN( + "Forcing WebView teardown past the deadline before ewk_shutdown()"); CompletePendingTeardown(pending); continue; } diff --git a/packages/webview_flutter/tizen/src/webview.h b/packages/webview_flutter/tizen/src/webview.h index a9055b5d0..4e981e6a4 100644 --- a/packages/webview_flutter/tizen/src/webview.h +++ b/packages/webview_flutter/tizen/src/webview.h @@ -59,9 +59,9 @@ class WebView : public PlatformView { FlutterDesktopGpuSurfaceDescriptor* ObtainGpuSurface(size_t width, size_t height); - // Blocks until every WebView's deferred evas_object_del() (queued in - // Dispose()) has run, force-deleting stragglers after a timeout. Must be - // called before ewk_shutdown(), which fatally CHECKs on any live Ewk_View. + // Blocks until every WebView's deferred delete (queued in Dispose()) has + // run, force-deleting stragglers after a timeout. Must be called before + // ewk_shutdown(), which fatally CHECKs on any live Ewk_View. static void FlushPendingTeardowns(); static Ecore_Evas* GetSharedCanvas(); @@ -130,7 +130,7 @@ class WebView : public PlatformView { std::shared_ptr tbm_pool_; bool disposed_ = false; // Guarded by mutex_. Keeps the raster thread from being handed TBM surfaces - // that the deferred evas_object_del() is about to free. + // during the deferred teardown. bool is_disposing_ = false; // Copied into pending async Dart replies so they can detect a WebView that // was destroyed before the reply arrived. diff --git a/packages/webview_flutter/tizen/src/webview_flutter_tizen_plugin.cc b/packages/webview_flutter/tizen/src/webview_flutter_tizen_plugin.cc index 82b778c1a..431b228e5 100644 --- a/packages/webview_flutter/tizen/src/webview_flutter_tizen_plugin.cc +++ b/packages/webview_flutter/tizen/src/webview_flutter_tizen_plugin.cc @@ -31,9 +31,7 @@ class WebviewFlutterTizenPlugin : public flutter::Plugin { WebviewFlutterTizenPlugin() { ewk_init(); } virtual ~WebviewFlutterTizenPlugin() { - // ewk_shutdown() fatally CHECKs if any Ewk_View is still alive; drain - // Dispose()'s deferred deletes first, then free the canvas they were - // hosted on. + // See WebView::FlushPendingTeardowns() for why this must run first. WebView::FlushPendingTeardowns(); WebView::FreeSharedCanvas(); ewk_shutdown(); From 97ff977fef2ea661119ecb2b96067bcdcf2c8725 Mon Sep 17 00:00:00 2001 From: Seungsoo Lee Date: Mon, 10 Aug 2026 15:11:17 +0900 Subject: [PATCH 12/13] [webview_flutter_tizen] Drop the evas_object_hide() mention from CHANGELOG.md The evas_object_hide() workaround was added and removed within this same unreleased 0.10.1 section, so it never shipped. Mentioning its removal in the changelog only confuses readers who never saw it introduced; keep the bullet focused on the fix that actually ships. --- packages/webview_flutter/CHANGELOG.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/webview_flutter/CHANGELOG.md b/packages/webview_flutter/CHANGELOG.md index 59f858277..d4ccd8037 100644 --- a/packages/webview_flutter/CHANGELOG.md +++ b/packages/webview_flutter/CHANGELOG.md @@ -5,10 +5,8 @@ * Fix races and use-after-frees on WebView disposal, including a buffer-pool use-after-free on the raster thread. * Replace the Ecore main loop API with GLib. -* Remove the TV emulator's evas_object_hide() workaround (which skipped - evas_object_del() to avoid a crash); drain all pending WebView teardowns - before ewk_shutdown() instead, fixing a native crash on app exit and - enabling simultaneous use of multiple WebViews. +* Drain all pending WebView teardowns before ewk_shutdown(), fixing a native + crash on app exit and enabling simultaneous use of multiple WebViews. ## 0.10.0 From 81d5f9bd8db8a04aef10ade6517a81c07eede372 Mon Sep 17 00:00:00 2001 From: Seungsoo Lee Date: Mon, 10 Aug 2026 21:19:17 +0900 Subject: [PATCH 13/13] [webview_flutter_tizen] Bound the wait in FlushPendingTeardowns() g_main_context_iteration(ctx, TRUE) blocks until some GLib source becomes ready, with no bound of its own. If a pending teardown's render-thread hop never arrives (e.g. the render thread has already stalled or exited), and no other source is active on the default context, this call can block indefinitely, so the 2-second deadline check on the next loop iteration is never reached. Switch to a non-blocking iteration with a short sleep so the deadline is always re-checked on a bounded interval, regardless of whether the render thread ever responds. --- packages/webview_flutter/tizen/src/webview.cc | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/webview_flutter/tizen/src/webview.cc b/packages/webview_flutter/tizen/src/webview.cc index fb4ee1d38..97d1385eb 100644 --- a/packages/webview_flutter/tizen/src/webview.cc +++ b/packages/webview_flutter/tizen/src/webview.cc @@ -347,8 +347,12 @@ void WebView::FlushPendingTeardowns() { } // Pump the same GLib context the queued g_timeout_add_full hop (see // Dispose()) is scheduled on, so it gets a chance to run and remove - // this entry itself. - g_main_context_iteration(g_main_context_default(), TRUE); + // this entry itself. Non-blocking: if that hop never arrives (e.g. the + // render thread already stalled), a blocking iteration here would never + // return to let the deadline check above fire. + if (!g_main_context_iteration(g_main_context_default(), FALSE)) { + g_usleep(1000); + } } }