diff --git a/flutter_inappwebview_windows/windows/in_app_webview/browser_process_gate.h b/flutter_inappwebview_windows/windows/in_app_webview/browser_process_gate.h new file mode 100644 index 000000000..b80cea2cb --- /dev/null +++ b/flutter_inappwebview_windows/windows/in_app_webview/browser_process_gate.h @@ -0,0 +1,130 @@ +#ifndef FLUTTER_INAPPWEBVIEW_PLUGIN_BROWSER_PROCESS_GATE_H_ +#define FLUTTER_INAPPWEBVIEW_PLUGIN_BROWSER_PROCESS_GATE_H_ + +#include +#include +#include +#include +#include + +namespace flutter_inappwebview_plugin +{ + // Coalesces browser-process responsiveness probing across every webview that + // shares a browser process: within one validity window at most one probe + // (bounded by its timeout) runs per process, no matter how many webviews + // have synchronous state waiting on it. Pure logic - the Win32 probe, clock + // and retry timer are injected, which also makes it unit-testable. + class BrowserProcessGateRegistry + { + public: + using Waiter = void*; + using ProbeFn = std::function; + using ClockFn = std::function; + using ScheduleRetryFn = std::function; + using CancelRetryFn = std::function; + + BrowserProcessGateRegistry(ProbeFn probe, ClockFn clock, + ScheduleRetryFn scheduleRetry, CancelRetryFn cancelRetry, + const unsigned long long probeValidityMs) + : probe_(std::move(probe)), clock_(std::move(clock)), + scheduleRetry_(std::move(scheduleRetry)), + cancelRetry_(std::move(cancelRetry)), + probeValidityMs_(probeValidityMs) + {} + + // True: the browser answered a probe at most probeValidityMs ago and the + // caller may issue its synchronous calls now. False: the caller is queued + // and handed back by takeWaitersIfResponsive on a retry tick. + bool tryAcquire(const unsigned long pid, const Waiter waiter) + { + auto& entry = entries_[pid]; + if (verdict(pid, entry)) { + return true; + } + if (std::find(entry.waiters.begin(), entry.waiters.end(), waiter) == + entry.waiters.end()) { + entry.waiters.push_back(waiter); + } + if (!entry.retryScheduled) { + entry.retryScheduled = true; + scheduleRetry_(pid); + } + return false; + } + + // Runs on the retry timer. Returns the drained waiters once the browser + // answers again (and stops the timer); empty while it stays unresponsive. + std::vector takeWaitersIfResponsive(const unsigned long pid) + { + const auto it = entries_.find(pid); + if (it == entries_.end() || it->second.waiters.empty()) { + stopRetry(pid); + return {}; + } + auto& entry = it->second; + if (!verdict(pid, entry)) { + return {}; + } + stopRetry(pid); + std::vector drained; + drained.swap(entry.waiters); + return drained; + } + + void removeWaiter(const Waiter waiter) + { + for (auto& [pid, entry] : entries_) { + entry.waiters.erase( + std::remove(entry.waiters.begin(), entry.waiters.end(), waiter), + entry.waiters.end()); + } + } + + // The browser process exited: its cached verdict no longer means anything. + void invalidate(const unsigned long pid) + { + const auto it = entries_.find(pid); + if (it != entries_.end()) { + it->second.lastVerdict.reset(); + } + } + + private: + struct Entry + { + std::optional lastVerdict; + unsigned long long verdictTick = 0; + std::vector waiters; + bool retryScheduled = false; + }; + + bool verdict(const unsigned long pid, Entry& entry) + { + const auto now = clock_(); + if (!entry.lastVerdict.has_value() || + now - entry.verdictTick >= probeValidityMs_) { + entry.lastVerdict = probe_(pid); + entry.verdictTick = now; + } + return entry.lastVerdict.value(); + } + + void stopRetry(const unsigned long pid) + { + const auto it = entries_.find(pid); + if (it != entries_.end() && it->second.retryScheduled) { + it->second.retryScheduled = false; + cancelRetry_(pid); + } + } + + ProbeFn probe_; + ClockFn clock_; + ScheduleRetryFn scheduleRetry_; + CancelRetryFn cancelRetry_; + unsigned long long probeValidityMs_; + std::map entries_; + }; +} + +#endif // FLUTTER_INAPPWEBVIEW_PLUGIN_BROWSER_PROCESS_GATE_H_ diff --git a/flutter_inappwebview_windows/windows/in_app_webview/in_app_webview.cpp b/flutter_inappwebview_windows/windows/in_app_webview/in_app_webview.cpp index 59d06e608..666b83465 100644 --- a/flutter_inappwebview_windows/windows/in_app_webview/in_app_webview.cpp +++ b/flutter_inappwebview_windows/windows/in_app_webview/in_app_webview.cpp @@ -49,6 +49,7 @@ #include "../web_notification/web_notification_controller.h" #include "../print_job/print_job_controller.h" #include "../print_job/print_job_manager.h" +#include "browser_process_gate.h" #include "in_app_webview.h" #include "in_app_webview_manager.h" @@ -76,7 +77,7 @@ namespace flutter_inappwebview_plugin registerSurfaceEventHandlers(); } else { - updateControllerVisibility(); + applyPendingBrowserState(); // Resize WebView to fit the bounds of the parent window RECT bounds; GetClientRect(parentWindow, &bounds); @@ -1077,6 +1078,7 @@ namespace flutter_inappwebview_plugin COREWEBVIEW2_PROCESS_FAILED_KIND kind; if (succeededOrLog(args->get_ProcessFailedKind(&kind))) { if (kind == COREWEBVIEW2_PROCESS_FAILED_KIND_BROWSER_PROCESS_EXITED) { + invalidateBrowserProcessCache(); auto didCrash = reason == COREWEBVIEW2_PROCESS_FAILED_REASON_CRASHED; auto detail = std::make_unique( didCrash @@ -3247,49 +3249,192 @@ namespace flutter_inappwebview_plugin } - bool InAppWebView::updateControllerVisibility() const + namespace { - if (!webViewController) { - return false; + constexpr UINT kBrowserProbeTimeoutMs = 50; + constexpr UINT kBrowserStateRetryIntervalMs = 250; + + // Probe-window and retry-timer bookkeeping, keyed by browser process id. + // Platform-thread only, like every other call into this file. + std::map gBrowserProbeWindows; + std::map gGateRetryTimers; + std::map gGateTimerPids; + + HWND findBrowserProbeWindow(const unsigned long pid) + { + struct FindContext { unsigned long pid; HWND found; } context = { pid, nullptr }; + EnumWindows([](HWND hwnd, LPARAM lparam) -> BOOL + { + auto* const ctx = reinterpret_cast(lparam); + DWORD windowPid = 0; + GetWindowThreadProcessId(hwnd, &windowPid); + if (windowPid != ctx->pid) { + return TRUE; + } + wchar_t className[64] = L""; + GetClassNameW(hwnd, className, 64); + if (wcsncmp(className, L"Chrome_WidgetWin", 16) == 0) { + ctx->found = hwnd; + return FALSE; + } + return TRUE; + }, reinterpret_cast(&context)); + return context.found; + } + + // Bounded by kBrowserProbeTimeoutMs. A missing probe window means the + // gate cannot judge; report responsive so behavior degrades to the old + // unconditional (possibly blocking) delivery rather than never delivering. + bool probeBrowserProcess(const unsigned long pid) + { + auto& window = gBrowserProbeWindows[pid]; + if (window) { + DWORD windowPid = 0; + GetWindowThreadProcessId(window, &windowPid); + if (!IsWindow(window) || windowPid != pid) { + window = nullptr; + } + } + if (!window) { + window = findBrowserProbeWindow(pid); + } + if (!window) { + return true; + } + DWORD_PTR ignored = 0; + return SendMessageTimeoutW(window, WM_NULL, 0, 0, + SMTO_ABORTIFHUNG | SMTO_BLOCK, kBrowserProbeTimeoutMs, &ignored) != 0; + } + + void CALLBACK GateRetryTimerProc(HWND, UINT, UINT_PTR timerId, DWORD); + + BrowserProcessGateRegistry& browserGate() + { + static BrowserProcessGateRegistry gate( + &probeBrowserProcess, + []() -> unsigned long long { return GetTickCount64(); }, + [](const unsigned long pid) + { + if (map_contains(gGateRetryTimers, pid)) { + return; + } + const auto timerId = SetTimer(nullptr, 0, kBrowserStateRetryIntervalMs, + &GateRetryTimerProc); + if (timerId) { + gGateRetryTimers[pid] = timerId; + gGateTimerPids[timerId] = pid; + } + }, + [](const unsigned long pid) + { + const auto it = gGateRetryTimers.find(pid); + if (it == gGateRetryTimers.end()) { + return; + } + KillTimer(nullptr, it->second); + gGateTimerPids.erase(it->second); + gGateRetryTimers.erase(it); + }, + kBrowserStateRetryIntervalMs); + return gate; + } + + void CALLBACK GateRetryTimerProc(HWND, UINT, UINT_PTR timerId, DWORD) + { + const auto it = gGateTimerPids.find(timerId); + if (it == gGateTimerPids.end()) { + KillTimer(nullptr, timerId); + return; + } + for (const auto waiter : browserGate().takeWaitersIfResponsive(it->second)) { + static_cast(waiter)->applyPendingBrowserState(); + } + } + } + + DWORD InAppWebView::cachedBrowserProcessId() + { + if (!browserProcessId_ && webView) { + UINT32 pid = 0; + if (SUCCEEDED(webView->get_BrowserProcessId(&pid))) { + browserProcessId_ = pid; + } + } + return browserProcessId_; + } + + void InAppWebView::invalidateBrowserProcessCache() + { + if (browserProcessId_) { + browserGate().invalidate(browserProcessId_); + gBrowserProbeWindows.erase(browserProcessId_); + } + browserProcessId_ = 0; + } + + void InAppWebView::applyPendingBrowserState() + { + if (webViewController && + (visibilityState_.needsApply() || pendingResume_ || pendingSuspend_)) { + const auto browserPid = cachedBrowserProcessId(); + if (browserPid && !browserGate().tryAcquire(browserPid, this)) { + return; + } + if (pendingResume_) { + pendingResume_ = false; + wil::com_ptr webView3; + if (webView && SUCCEEDED(webView->QueryInterface(IID_PPV_ARGS(&webView3)))) { + failedLog(webView3->Resume()); + } + } + if (visibilityState_.needsApply()) { + if (succeededOrLog(webViewController->put_IsVisible( + visibilityState_.shouldBeVisible() ? TRUE : FALSE))) { + visibilityState_.markApplied(); + } + else { + pendingSuspend_ = false; + } + } + if (pendingSuspend_) { + pendingSuspend_ = false; + if (!visibilityState_.shouldBeVisible() && !visibilityState_.needsApply()) { + wil::com_ptr webView3; + if (webView && SUCCEEDED(webView->QueryInterface(IID_PPV_ARGS(&webView3)))) { + failedLog(webView3->TrySuspend(Callback( + [this](HRESULT errorCode, BOOL isSuccessful) -> HRESULT + { + failedLog(errorCode); + return S_OK; + }) + .Get())); + } + } + } } - return succeededOrLog(webViewController->put_IsVisible( - visibilityState_.shouldBeVisible() ? TRUE : FALSE)); + browserGate().removeWaiter(this); } void InAppWebView::setHostWindowMinimized(const bool minimized) { visibilityState_.setHostWindowMinimized(minimized); - updateControllerVisibility(); + applyPendingBrowserState(); } void InAppWebView::pause() { visibilityState_.setPaused(true); - if (!updateControllerVisibility()) { - return; - } - - wil::com_ptr webView3; - if (webView && SUCCEEDED(webView->QueryInterface(IID_PPV_ARGS(&webView3)))) { - failedLog(webView3->TrySuspend(Callback( - [this](HRESULT errorCode, BOOL isSuccessful) -> HRESULT - { - failedLog(errorCode); - return S_OK; - }) - .Get())); - } + pendingResume_ = false; + pendingSuspend_ = true; + applyPendingBrowserState(); } void InAppWebView::resume() { visibilityState_.setPaused(false); - - wil::com_ptr webView3; - if (webView && SUCCEEDED(webView->QueryInterface(IID_PPV_ARGS(&webView3)))) { - failedLog(webView3->Resume()); - } - updateControllerVisibility(); + pendingSuspend_ = false; + pendingResume_ = true; + applyPendingBrowserState(); } @@ -4023,6 +4168,7 @@ namespace flutter_inappwebview_plugin surface_ = nullptr; return false; } + visibilityState_.markApplied(); // The Flutter view may detach while surface creation is still in flight. if (plugin && plugin->registrar) { @@ -4291,6 +4437,7 @@ namespace flutter_inappwebview_plugin // Expire before tearing anything down: a Dart reply arriving from here on // must find a dead token and drop the call. aliveToken_.reset(); + browserGate().removeWaiter(this); WebViewDropTarget::UnregisterWebView(this); userContentController = nullptr; if (webView) { diff --git a/flutter_inappwebview_windows/windows/in_app_webview/in_app_webview.h b/flutter_inappwebview_windows/windows/in_app_webview/in_app_webview.h index d2b32fd9a..c709986ea 100644 --- a/flutter_inappwebview_windows/windows/in_app_webview/in_app_webview.h +++ b/flutter_inappwebview_windows/windows/in_app_webview/in_app_webview.h @@ -261,6 +261,11 @@ namespace flutter_inappwebview_plugin return aliveToken_; } + // Delivers a pending visibility flip / Resume / TrySuspend, or defers it + // through the shared per-browser-process gate while that process's UI + // thread is not answering. Re-entered by the gate's retry timer. + void applyPendingBrowserState(); + static bool isSslError(const COREWEBVIEW2_WEB_ERROR_STATUS& webErrorStatus); private: // custom_platform_view @@ -290,9 +295,20 @@ namespace flutter_inappwebview_plugin std::map> printJobControllers_; std::shared_ptr aliveToken_ = std::make_shared(0); + // put_IsVisible and Resume are synchronous IPC served by the browser + // process UI thread; issuing them while it is not pumping (suspended, + // starved after long backgrounding) blocks the host platform thread for + // as long as that thread takes to wake. + bool pendingSuspend_ = false; + bool pendingResume_ = false; + // Queried once and reused; refreshed only after the browser process exits, + // so no per-flip round-trip depends on it. + DWORD browserProcessId_ = 0; + void registerEventHandlers(); void registerSurfaceEventHandlers(); - bool updateControllerVisibility() const; + DWORD cachedBrowserProcessId(); + void invalidateBrowserProcessCache(); HRESULT onCallJsHandler(const bool& isMainFrame, ICoreWebView2WebMessageReceivedEventArgs* args); }; } diff --git a/flutter_inappwebview_windows/windows/in_app_webview/webview_visibility_state.h b/flutter_inappwebview_windows/windows/in_app_webview/webview_visibility_state.h index 24e0f6e48..df9f73544 100644 --- a/flutter_inappwebview_windows/windows/in_app_webview/webview_visibility_state.h +++ b/flutter_inappwebview_windows/windows/in_app_webview/webview_visibility_state.h @@ -1,6 +1,8 @@ #ifndef FLUTTER_INAPPWEBVIEW_PLUGIN_WEBVIEW_VISIBILITY_STATE_H_ #define FLUTTER_INAPPWEBVIEW_PLUGIN_WEBVIEW_VISIBILITY_STATE_H_ +#include + namespace flutter_inappwebview_plugin { // Separates the visibility requested by the WebView API from temporary @@ -27,9 +29,18 @@ namespace flutter_inappwebview_plugin hostWindowMinimized_ = minimized; } + // Delivery of put_IsVisible can be deferred while the browser process is + // unresponsive; these track what the controller last actually received. + bool needsApply() const + { + return !applied_.has_value() || applied_.value() != shouldBeVisible(); + } + void markApplied() { applied_ = shouldBeVisible(); } + private: bool paused_ = false; bool hostWindowMinimized_ = false; + std::optional applied_; }; } diff --git a/flutter_inappwebview_windows/windows/test/flutter_inappwebview_windows_plugin_test.cpp b/flutter_inappwebview_windows/windows/test/flutter_inappwebview_windows_plugin_test.cpp index 6d0f870a9..780c67e2f 100644 --- a/flutter_inappwebview_windows/windows/test/flutter_inappwebview_windows_plugin_test.cpp +++ b/flutter_inappwebview_windows/windows/test/flutter_inappwebview_windows_plugin_test.cpp @@ -2,6 +2,7 @@ #include +#include "in_app_webview/browser_process_gate.h" #include "in_app_webview/webview_visibility_state.h" #include "types/base_callback_result.h" @@ -9,6 +10,115 @@ namespace flutter_inappwebview_plugin::test { namespace { +// Injects a counting fake probe, a manual clock and recording timer hooks. +struct GateHarness { + int probeCount = 0; + bool probeResult = false; + unsigned long long now = 0; + int scheduled = 0; + int canceled = 0; + BrowserProcessGateRegistry gate; + + GateHarness() + : gate([this](unsigned long) { ++probeCount; return probeResult; }, + [this] { return now; }, + [this](unsigned long) { ++scheduled; }, + [this](unsigned long) { ++canceled; }, + 250) {} +}; + +} // namespace + +TEST(BrowserProcessGateRegistry, OneProbeSharedByAllWaitersInInterval) { + GateHarness h; + int a = 0, b = 0, c = 0; + EXPECT_FALSE(h.gate.tryAcquire(7, &a)); + EXPECT_FALSE(h.gate.tryAcquire(7, &b)); + EXPECT_FALSE(h.gate.tryAcquire(7, &c)); + EXPECT_EQ(h.probeCount, 1); + EXPECT_EQ(h.scheduled, 1); +} + +TEST(BrowserProcessGateRegistry, RetryTickProbesOncePerInterval) { + GateHarness h; + int a = 0; + h.gate.tryAcquire(7, &a); + h.now += 250; + EXPECT_TRUE(h.gate.takeWaitersIfResponsive(7).empty()); + EXPECT_EQ(h.probeCount, 2); +} + +TEST(BrowserProcessGateRegistry, DrainsAllWaitersWhenBrowserAnswers) { + GateHarness h; + int a = 0, b = 0; + h.gate.tryAcquire(7, &a); + h.gate.tryAcquire(7, &b); + h.probeResult = true; + h.now += 250; + const auto drained = h.gate.takeWaitersIfResponsive(7); + EXPECT_EQ(drained.size(), 2u); + EXPECT_EQ(h.canceled, 1); + // A drained waiter re-enters tryAcquire on the cached verdict, probe-free. + const int probesBefore = h.probeCount; + EXPECT_TRUE(h.gate.tryAcquire(7, &a)); + EXPECT_EQ(h.probeCount, probesBefore); +} + +TEST(BrowserProcessGateRegistry, ResponsiveVerdictCachedWithinInterval) { + GateHarness h; + h.probeResult = true; + int a = 0, b = 0; + EXPECT_TRUE(h.gate.tryAcquire(7, &a)); + EXPECT_TRUE(h.gate.tryAcquire(7, &b)); + EXPECT_EQ(h.probeCount, 1); +} + +TEST(BrowserProcessGateRegistry, SeparateBrowsersProbeIndependently) { + GateHarness h; + int a = 0; + h.gate.tryAcquire(7, &a); + h.gate.tryAcquire(8, &a); + EXPECT_EQ(h.probeCount, 2); + EXPECT_EQ(h.scheduled, 2); +} + +TEST(BrowserProcessGateRegistry, RemovedWaiterIsNotDrained) { + GateHarness h; + int a = 0, b = 0; + h.gate.tryAcquire(7, &a); + h.gate.tryAcquire(7, &b); + h.gate.removeWaiter(&a); + h.probeResult = true; + h.now += 250; + const auto drained = h.gate.takeWaitersIfResponsive(7); + ASSERT_EQ(drained.size(), 1u); + EXPECT_EQ(drained[0], &b); +} + +TEST(BrowserProcessGateRegistry, EmptyQueueStopsRetryWithoutProbing) { + GateHarness h; + int a = 0; + h.gate.tryAcquire(7, &a); + h.gate.removeWaiter(&a); + const int probesBefore = h.probeCount; + h.now += 250; + EXPECT_TRUE(h.gate.takeWaitersIfResponsive(7).empty()); + EXPECT_EQ(h.probeCount, probesBefore); + EXPECT_EQ(h.canceled, 1); +} + +TEST(BrowserProcessGateRegistry, InvalidateForcesFreshProbe) { + GateHarness h; + h.probeResult = true; + int a = 0; + h.gate.tryAcquire(7, &a); + h.gate.invalidate(7); + h.gate.tryAcquire(7, &a); + EXPECT_EQ(h.probeCount, 2); +} + +namespace { + std::unique_ptr> makeCallback(bool* ran) { auto callback = std::make_unique>(); callback->decodeResult = [](const flutter::EncodableValue* value) { @@ -146,4 +256,34 @@ TEST(WebViewVisibilityState, DuplicateWindowTransitionsAreIdempotent) { EXPECT_TRUE(state.shouldBeVisible()); } +TEST(WebViewVisibilityState, NeedsApplyUntilFirstDelivery) { + WebViewVisibilityState state; + EXPECT_TRUE(state.needsApply()); + state.markApplied(); + EXPECT_FALSE(state.needsApply()); +} + +TEST(WebViewVisibilityState, RedundantTransitionNeedsNoDelivery) { + WebViewVisibilityState state; + state.markApplied(); + state.setPaused(true); + state.markApplied(); + state.setPaused(true); + EXPECT_FALSE(state.needsApply()); +} + +TEST(WebViewVisibilityState, DeferredDeliveryCoalescesToLatestState) { + WebViewVisibilityState state; + state.markApplied(); + // Visible -> paused -> resumed while delivery was deferred: nothing to send. + state.setPaused(true); + state.setPaused(false); + EXPECT_FALSE(state.needsApply()); + // A net state change still needs one delivery. + state.setHostWindowMinimized(true); + EXPECT_TRUE(state.needsApply()); + state.markApplied(); + EXPECT_FALSE(state.needsApply()); +} + } // namespace flutter_inappwebview_plugin::test