diff --git a/engine/src/flutter/BUILD.gn b/engine/src/flutter/BUILD.gn index 33a338a8dc3..ef09368d7bb 100644 --- a/engine/src/flutter/BUILD.gn +++ b/engine/src/flutter/BUILD.gn @@ -269,7 +269,10 @@ group("unittests") { } if (is_mac) { - public_deps += [ "//flutter/shell/platform/darwin/macos:flutter_desktop_darwin_unittests" ] + public_deps += [ + "//flutter/shell/platform/darwin/macos:flutter_desktop_darwin_swift_unittests", + "//flutter/shell/platform/darwin/macos:flutter_desktop_darwin_unittests", + ] } if (is_win) { diff --git a/engine/src/flutter/ci/licenses_golden/licenses_flutter b/engine/src/flutter/ci/licenses_golden/licenses_flutter index 7bb4a2fbcb1..c692a0383cb 100644 --- a/engine/src/flutter/ci/licenses_golden/licenses_flutter +++ b/engine/src/flutter/ci/licenses_golden/licenses_flutter @@ -52962,9 +52962,6 @@ ORIGIN: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterPla ORIGIN: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterPlatformViewControllerTest.mm + ../../../flutter/LICENSE ORIGIN: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterRenderer.h + ../../../flutter/LICENSE ORIGIN: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterRenderer.mm + ../../../flutter/LICENSE -ORIGIN: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterResizeSynchronizer.h + ../../../flutter/LICENSE -ORIGIN: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterResizeSynchronizer.mm + ../../../flutter/LICENSE -ORIGIN: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterResizeSynchronizerTest.mm + ../../../flutter/LICENSE ORIGIN: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterRunLoop.swift + ../../../flutter/LICENSE ORIGIN: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterSurface.h + ../../../flutter/LICENSE ORIGIN: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterSurface.mm + ../../../flutter/LICENSE @@ -52999,6 +52996,8 @@ ORIGIN: ../../../flutter/shell/platform/darwin/macos/framework/Source/KeyCodeMap ORIGIN: ../../../flutter/shell/platform/darwin/macos/framework/Source/KeyCodeMapTest.mm + ../../../flutter/LICENSE ORIGIN: ../../../flutter/shell/platform/darwin/macos/framework/Source/KeyCodeMap_Internal.h + ../../../flutter/LICENSE ORIGIN: ../../../flutter/shell/platform/darwin/macos/framework/Source/NSView+ClipsToBounds.h + ../../../flutter/LICENSE +ORIGIN: ../../../flutter/shell/platform/darwin/macos/framework/Source/ResizeSynchronizer.swift + ../../../flutter/LICENSE +ORIGIN: ../../../flutter/shell/platform/darwin/macos/framework/Source/ResizeSynchronizerTest.swift + ../../../flutter/LICENSE ORIGIN: ../../../flutter/shell/platform/darwin/macos/framework/Source/TestFlutterPlatformView.h + ../../../flutter/LICENSE ORIGIN: ../../../flutter/shell/platform/darwin/macos/framework/Source/TestFlutterPlatformView.mm + ../../../flutter/LICENSE ORIGIN: ../../../flutter/shell/platform/embedder/embedder.cc + ../../../flutter/LICENSE @@ -55994,9 +55993,6 @@ FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterPlatf FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterPlatformViewControllerTest.mm FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterRenderer.h FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterRenderer.mm -FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterResizeSynchronizer.h -FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterResizeSynchronizer.mm -FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterResizeSynchronizerTest.mm FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterRunLoop.swift FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterSurface.h FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterSurface.mm @@ -56033,6 +56029,8 @@ FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/KeyCodeMap.g FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/KeyCodeMapTest.mm FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/KeyCodeMap_Internal.h FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/NSView+ClipsToBounds.h +FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/ResizeSynchronizer.swift +FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/ResizeSynchronizerTest.swift FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/TestFlutterPlatformView.h FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/TestFlutterPlatformView.mm FILE: ../../../flutter/shell/platform/darwin/macos/framework/module.modulemap diff --git a/engine/src/flutter/shell/platform/darwin/macos/BUILD.gn b/engine/src/flutter/shell/platform/darwin/macos/BUILD.gn index 642909add6d..dbfec091477 100644 --- a/engine/src/flutter/shell/platform/darwin/macos/BUILD.gn +++ b/engine/src/flutter/shell/platform/darwin/macos/BUILD.gn @@ -62,7 +62,10 @@ source_set("InternalFlutterSwift") { "//flutter/shell/platform/darwin/macos/framework", # For module.modulemap ] bridge_header = "InternalFlutterSwift-Bridging-Header.h" - sources = [ "framework/Source/FlutterRunLoop.swift" ] + sources = [ + "framework/Source/FlutterRunLoop.swift", + "framework/Source/ResizeSynchronizer.swift", + ] deps = [ "//flutter/shell/platform/darwin/common:framework_common" ] public = _flutter_framework_headers + framework_common_headers } @@ -109,8 +112,6 @@ source_set("flutter_framework_source") { "framework/Source/FlutterPlatformViewController.mm", "framework/Source/FlutterRenderer.h", "framework/Source/FlutterRenderer.mm", - "framework/Source/FlutterResizeSynchronizer.h", - "framework/Source/FlutterResizeSynchronizer.mm", "framework/Source/FlutterSurface.h", "framework/Source/FlutterSurface.mm", "framework/Source/FlutterSurfaceManager.h", @@ -188,6 +189,21 @@ test_fixtures("flutter_desktop_darwin_fixtures") { fixtures = [ "//flutter/third_party/icu/common/icudtl.dat" ] } +executable("flutter_desktop_darwin_swift_unittests") { + testonly = true + public_configs = [ + "//flutter/shell/platform/darwin/common:config", + "//flutter/shell/platform/darwin/common:test_config", + "//flutter/shell/platform/darwin/common:swift_testing_config", + ] + sources = [ "framework/Source/ResizeSynchronizerTest.swift" ] + deps = [ + ":flutter_framework_source", + "//flutter/shell/platform/darwin/common:swift_testing_main", + "//flutter/shell/platform/darwin/common:test_utils_swift", + ] +} + executable("flutter_desktop_darwin_unittests") { testonly = true cflags_objc = flutter_cflags_objc @@ -210,7 +226,6 @@ executable("flutter_desktop_darwin_unittests") { "framework/Source/FlutterMutatorViewTest.mm", "framework/Source/FlutterPlatformNodeDelegateMacTest.mm", "framework/Source/FlutterPlatformViewControllerTest.mm", - "framework/Source/FlutterResizeSynchronizerTest.mm", "framework/Source/FlutterSurfaceManagerTest.mm", "framework/Source/FlutterTextInputPluginTest.mm", "framework/Source/FlutterTextInputSemanticsObjectTest.mm", diff --git a/engine/src/flutter/shell/platform/darwin/macos/README.md b/engine/src/flutter/shell/platform/darwin/macos/README.md index 8d74d685d7b..e54688d489d 100644 --- a/engine/src/flutter/shell/platform/darwin/macos/README.md +++ b/engine/src/flutter/shell/platform/darwin/macos/README.md @@ -44,9 +44,9 @@ Builds are architecture-specific, and can be controlled by specifying ## Testing The macOS-specific embedder tests are built as the -`flutter_desktop_darwin_unittests` binary. Like all gtest-based test binaries, a -subset of tests can be run by applying a filter such as -`--gtest_filter='FlutterViewControllerTest.*Key*'`. +`flutter_desktop_darwin_unittests` and `flutter_desktop_darwin_swift_unittests` +binaries. Like all gtest-based test binaries, a subset of tests can be run by +applying a filter such as `--gtest_filter='FlutterViewControllerTest.*Key*'`. More general details on testing can be found on the [Wiki][wiki_engine_testing]. diff --git a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterEngineTest.mm b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterEngineTest.mm index 80b1e549730..b2fc041d977 100644 --- a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterEngineTest.mm +++ b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterEngineTest.mm @@ -4,7 +4,6 @@ #import "flutter/shell/platform/darwin/macos/framework/Headers/FlutterEngine.h" #import "flutter/shell/platform/darwin/macos/framework/Source/FlutterEngine_Internal.h" -#include "shell/platform/darwin/macos/framework/Source/FlutterResizeSynchronizer.h" #include @@ -19,6 +18,7 @@ #import "flutter/shell/platform/darwin/common/framework/Headers/FlutterChannels.h" #import "flutter/shell/platform/darwin/common/framework/Source/FlutterBinaryMessengerRelay.h" #import "flutter/shell/platform/darwin/common/test_utils_swift/test_utils_swift.h" +#import "flutter/shell/platform/darwin/macos/InternalFlutterSwift/InternalFlutterSwift.h" #import "flutter/shell/platform/darwin/macos/framework/Headers/FlutterAppDelegate.h" #import "flutter/shell/platform/darwin/macos/framework/Headers/FlutterAppLifecycleDelegate.h" #import "flutter/shell/platform/darwin/macos/framework/Headers/FlutterPluginMacOS.h" @@ -909,9 +909,9 @@ TEST_F(FlutterEngineTest, ResizeSynchronizerNotBlockingRasterThreadAfterShutdown std::thread rasterThread([&threadSynchronizer] { [threadSynchronizer performCommitForSize:CGSizeMake(100, 100) + afterDelay:0 notify:^{ - } - delay:0]; + }]; }); rasterThread.join(); diff --git a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterResizeSynchronizer.h b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterResizeSynchronizer.h deleted file mode 100644 index c61fd5e5d4b..00000000000 --- a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterResizeSynchronizer.h +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -#ifndef FLUTTER_SHELL_PLATFORM_DARWIN_MACOS_FRAMEWORK_SOURCE_FLUTTERRESIZESYNCHRONIZER_H_ -#define FLUTTER_SHELL_PLATFORM_DARWIN_MACOS_FRAMEWORK_SOURCE_FLUTTERRESIZESYNCHRONIZER_H_ - -#import - -#import "flutter/shell/platform/darwin/macos/framework/Headers/FlutterViewController.h" - -/** - * Class responsible for coordinating window resize with content update. - */ -@interface FlutterResizeSynchronizer : NSObject - -/** - * Begins a resize operation for the given size. Block the thread until - * performCommitForSize: with the same size is called. - * While the thread is blocked Flutter messages are being pumped. - * See [FlutterRunLoop pollFlutterMessagesOnce]. - */ -- (void)beginResizeForSize:(CGSize)size notify:(nonnull dispatch_block_t)notify; - -/** - * Called from raster thread. Schedules the given block on platform thread - * at given delay and unblocks the platform thread if waiting for the surface - * during resize. - */ -- (void)performCommitForSize:(CGSize)size - notify:(nonnull dispatch_block_t)notify - delay:(NSTimeInterval)delay; - -/** - * Called when the view is shut down. Unblocks platform thread if blocked - * during resize. - */ -- (void)shutDown; - -@end - -#endif // FLUTTER_SHELL_PLATFORM_DARWIN_MACOS_FRAMEWORK_SOURCE_FLUTTERRESIZESYNCHRONIZER_H_ diff --git a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterResizeSynchronizer.mm b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterResizeSynchronizer.mm deleted file mode 100644 index f7577ebebf7..00000000000 --- a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterResizeSynchronizer.mm +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -#import "flutter/shell/platform/darwin/macos/framework/Source/FlutterResizeSynchronizer.h" - -#include - -#import "flutter/shell/platform/darwin/common/InternalFlutterSwiftCommon/InternalFlutterSwiftCommon.h" -#import "flutter/shell/platform/darwin/macos/InternalFlutterSwift/InternalFlutterSwift.h" - -@implementation FlutterResizeSynchronizer { - std::atomic_bool _inResize; - BOOL _shuttingDown; - BOOL _didReceiveFrame; - CGSize _contentSize; -} - -- (void)beginResizeForSize:(CGSize)size notify:(nonnull dispatch_block_t)notify { - if (!_didReceiveFrame || _shuttingDown) { - notify(); - return; - } - - _inResize = true; - _contentSize = CGSizeMake(-1, -1); - notify(); - CFAbsoluteTime start = CFAbsoluteTimeGetCurrent(); - while (true) { - if (CGSizeEqualToSize(_contentSize, size) || _shuttingDown) { - break; - } - if (CFAbsoluteTimeGetCurrent() - start > 1.0) { - [FlutterLogger logError:@"Resize timed out."]; - break; - } - [FlutterRunLoop.mainRunLoop pollFlutterMessagesOnce]; - } - _inResize = false; -} - -- (void)performCommitForSize:(CGSize)size - notify:(nonnull dispatch_block_t)notify - delay:(NSTimeInterval)delay { - if (_inResize) { - delay = 0; - } - [FlutterRunLoop.mainRunLoop performAfterDelay:delay - block:^{ - _didReceiveFrame = YES; - _contentSize = size; - notify(); - }]; -} - -- (void)shutDown { - [FlutterRunLoop.mainRunLoop performBlock:^{ - _shuttingDown = YES; - }]; -} - -@end diff --git a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterResizeSynchronizerTest.mm b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterResizeSynchronizerTest.mm deleted file mode 100644 index bfffc841ad3..00000000000 --- a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterResizeSynchronizerTest.mm +++ /dev/null @@ -1,122 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -#include "flutter/fml/synchronization/waitable_event.h" -#import "flutter/shell/platform/darwin/macos/InternalFlutterSwift/InternalFlutterSwift.h" -#import "flutter/shell/platform/darwin/macos/framework/Source/FlutterResizeSynchronizer.h" -#import "flutter/testing/testing.h" - -TEST(FlutterThreadSynchronizerTest, NotBlocked) { - [FlutterRunLoop ensureMainLoopInitialized]; - - FlutterResizeSynchronizer* synchronizer = [[FlutterResizeSynchronizer alloc] init]; - __block BOOL performed = NO; - - [NSThread detachNewThreadWithBlock:^{ - [synchronizer performCommitForSize:CGSizeMake(100, 100) - notify:^{ - performed = YES; - } - delay:0]; - }]; - - CFTimeInterval start = CFAbsoluteTimeGetCurrent(); - - while (!performed && CFAbsoluteTimeGetCurrent() - start < 1.0) { - [FlutterRunLoop.mainRunLoop pollFlutterMessagesOnce]; - } - EXPECT_EQ(performed, YES); -} - -TEST(FlutterThreadSynchronizerTest, WaitForResize) { - [FlutterRunLoop ensureMainLoopInitialized]; - - FlutterResizeSynchronizer* synchronizer = [[FlutterResizeSynchronizer alloc] init]; - - __block BOOL commit1 = NO; - __block BOOL commit2 = NO; - - // Capturing c++ objects in blocks requires copy constructor, that also applies - // to __block variables where the copy is made on heap. - fml::AutoResetWaitableEvent latch_; - fml::AutoResetWaitableEvent& latch = latch_; - - // Resize synchronizer must have at received one frame in order to block. - __block BOOL didReceiveFrame = NO; - [synchronizer performCommitForSize:CGSizeMake(10, 10) - notify:^{ - didReceiveFrame = YES; - } - delay:0]; - - CFTimeInterval start = CFAbsoluteTimeGetCurrent(); - while (!didReceiveFrame && CFAbsoluteTimeGetCurrent() - start < 1.0) { - [FlutterRunLoop.mainRunLoop pollFlutterMessagesOnce]; - } - - // Now resize should block until it has received expected size. - - [NSThread detachNewThreadWithBlock:^{ - latch.Wait(); - - [synchronizer performCommitForSize:CGSizeMake(50, 100) - notify:^{ - commit1 = YES; - } - delay:0]; - - [synchronizer performCommitForSize:CGSizeMake(100, 100) - notify:^{ - commit2 = YES; - } - delay:0]; - }]; - - [synchronizer beginResizeForSize:CGSizeMake(100, 100) - notify:^{ - latch.Signal(); - }]; - - EXPECT_EQ(commit1, YES); - EXPECT_EQ(commit2, YES); -} - -TEST(FlutterThreadSynchronizerTest, UnblocksOnShutDown) { - [FlutterRunLoop ensureMainLoopInitialized]; - - FlutterResizeSynchronizer* synchronizer = [[FlutterResizeSynchronizer alloc] init]; - - // Resize synchronizer must have at received one frame in order to block. - __block BOOL didReceiveFrame = NO; - [synchronizer performCommitForSize:CGSizeMake(10, 10) - notify:^{ - didReceiveFrame = YES; - } - delay:0]; - - CFTimeInterval start = CFAbsoluteTimeGetCurrent(); - while (!didReceiveFrame && CFAbsoluteTimeGetCurrent() - start < 1.0) { - [FlutterRunLoop.mainRunLoop pollFlutterMessagesOnce]; - } - - fml::AutoResetWaitableEvent latch_; - fml::AutoResetWaitableEvent& latch = latch_; - - [NSThread detachNewThreadWithBlock:^{ - latch.Wait(); - - [synchronizer shutDown]; - }]; - - [synchronizer beginResizeForSize:CGSizeMake(100, 100) - notify:^{ - // Unblock resize - latch.Signal(); - }]; - - // Subsequent calls should not block. - [synchronizer beginResizeForSize:CGSizeMake(100, 100) - notify:^{ - }]; -} diff --git a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterView.mm b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterView.mm index 756e9e9d719..078a353f214 100644 --- a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterView.mm +++ b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterView.mm @@ -4,11 +4,12 @@ #import "flutter/shell/platform/darwin/macos/framework/Source/FlutterView.h" -#import "flutter/shell/platform/darwin/macos/framework/Source/FlutterResizeSynchronizer.h" -#import "flutter/shell/platform/darwin/macos/framework/Source/FlutterSurfaceManager.h" - #import +#import "flutter/shell/platform/darwin/common/InternalFlutterSwiftCommon/InternalFlutterSwiftCommon.h" +#import "flutter/shell/platform/darwin/macos/InternalFlutterSwift/InternalFlutterSwift.h" +#import "flutter/shell/platform/darwin/macos/framework/Source/FlutterSurfaceManager.h" + @interface FlutterView () { FlutterViewIdentifier _viewIdentifier; __weak id _viewDelegate; @@ -42,7 +43,7 @@ } - (void)onPresent:(CGSize)frameSize withBlock:(dispatch_block_t)block delay:(NSTimeInterval)delay { - [_resizeSynchronizer performCommitForSize:frameSize notify:block delay:delay]; + [_resizeSynchronizer performCommitForSize:frameSize afterDelay:delay notify:block]; } - (FlutterSurfaceManager*)surfaceManager { @@ -63,9 +64,12 @@ [super setFrameSize:newSize]; CGSize scaledSize = [self convertSizeToBacking:self.bounds.size]; [_resizeSynchronizer beginResizeForSize:scaledSize - notify:^{ - [_viewDelegate viewDidReshape:self]; - }]; + notify:^{ + [_viewDelegate viewDidReshape:self]; + } + onTimeout:^{ + [FlutterLogger logError:@"Resize timed out"]; + }]; } /** diff --git a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/ResizeSynchronizer.swift b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/ResizeSynchronizer.swift new file mode 100644 index 00000000000..21bb9ee0efe --- /dev/null +++ b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/ResizeSynchronizer.swift @@ -0,0 +1,151 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import CoreGraphics +import Foundation + +/// Coordinates Flutter view content updates with macOS window resizing. +/// +/// When a native window containing a Flutter view is resized, the platform's window bounds can +/// change before Flutter has been able to render a new frame matching those new dimensions. This +/// asynchronicity can lead to undesirable visual effects such as: +/// - Content temporarily appearing at the old size within the new, larger bounds. +/// - Black "letterboxing" artifacts rendered at view edges. +/// - A perception of lag in content resizing. +/// +/// `ResizeSynchronizer` mitigates these issues by introducing a controlled blocking mechanism +/// during the resize lifecycle: +/// +/// 1. Initiation (Platform Thread): When a native window resize is detected (e.g., by a window +/// delegate), the platform thread calls `beginResize(forSize:notify:onTimeout:)` with the new +/// target dimensions. This method blocks the calling platform thread, pausing the completion of +/// the native resize event. While blocked, it continuously polls for engine messages to process +/// relevant updates. The `notify` closure is invoked just before blocking begins. +/// +/// 2. Commit (Raster Thread): As the engine renders frames, the raster thread, upon presenting a +/// new frame, calls `performCommit(forSize:afterDelay:notify:)`, providing the size of the +/// frame just rendered. +/// +/// 3. Unblocking: If the size of the committed frame matches the target size that `beginResize()` +/// is waiting for, `beginResize()` unblocks. This allows the native resize operation to +/// complete, now that Flutter's content is synchronized with the new window dimensions. +/// +/// Safeguards: +/// - Timeout: `beginResize()` includes a timeout mechanism to prevent indefinite blocking if a +/// matching frame isn't committed in a timely manner. An optional `onTimeout` closure can be +/// provided to handle this event. +/// - Shutdown: The synchronization can be cleanly interrupted by calling `shutDown()`, which will +/// also unblock any pending `beginResize()` call. After shutdown, `beginResize()` will no +/// longer block. +/// - Thread Safety: The class manages its internal state in a thread-safe manner to coordinate +/// actions between the platform thread and the raster thread. +@objc(FlutterResizeSynchronizer) +public final class ResizeSynchronizer: NSObject { + private static let invalidSize = CGSize(width: -1, height: -1) + + // Synchronizes access to _isInResize_unsafe: isInResize is accessed from multiple threads and + // thus requires synchronized access to the underlying storage. + private let isInResizeLock = NSLock() + private var _isInResize_unsafe: Bool = false + private var isInResize: Bool { + get { + isInResizeLock.lock() + defer { isInResizeLock.unlock() } + return _isInResize_unsafe + } + set { + isInResizeLock.lock() + _isInResize_unsafe = newValue + isInResizeLock.unlock() + } + } + + // True if the app is shutting down. Must be set on platform thread. + private var isShuttingDown: Bool = false + + // True if at least one frame has been presented. Must be set on platform thread. + private var didReceiveFrame: Bool = false + + // The updated view surface size. Must be set on platform thread. + private var contentSize: CGSize = ResizeSynchronizer.invalidSize + + /// Begins window resize operation to the specified size. + /// + /// Blocks the thread until `performCommit(forSize:notify:delay:)` with the same size is called. + /// While the thread is blocked, Flutter messages are being pumped. + /// See `FlutterRunLoop.mainRunLoop.pollFlutterMessagesOnce()`. + @objc public func beginResize( + forSize size: CGSize, + notify: () -> Void, + onTimeout: (() -> Void)? = nil + ) { + if !didReceiveFrame || isShuttingDown { + // If we haven't yet received a frame, or we're shutting down, there's nothing to do. + notify() + return + } + + // Mark that we're in a resize and set the content size to a sentinel value. + isInResize = true + contentSize = ResizeSynchronizer.invalidSize + + // Call the notify callback. + notify() + + // Spin, waiting for the commit (during frame present) for up to 1 second, then time out. + let startTime = CFAbsoluteTimeGetCurrent() + let timeoutDuration: TimeInterval = 1.0 + while true { + // If no change to size, or we got a shutdown notice, bail out. + if contentSize == size || isShuttingDown { + break + } + + // If we've hit the timeout, notify the caller and bail out. + if CFAbsoluteTimeGetCurrent() - startTime > timeoutDuration { + onTimeout?() + break + } + + // Process platform thread messages to pick up any updates to contentSize, didReceiveFrame, + // isShuttingDown made in performCommit and shutdown methods. + FlutterRunLoop.mainRunLoop.pollFlutterMessagesOnce() + } + isInResize = false + } + + /// Commit the updated frame size. + /// + /// Schedules the given block on the platform thread with the given delay. Unblocks `beginResize` + /// on the platform thread, if waiting for the surface during resize. + /// + /// Called from the raster thread on frame present. + @objc public func performCommit( + forSize size: CGSize, + afterDelay delay: TimeInterval, + notify: @escaping () -> Void + ) { + var effectiveDelay = delay + + // If we're currently resizing, process immediately. + if self.isInResize { // Accesses the computed property which uses the lock + effectiveDelay = 0 + } + + FlutterRunLoop.mainRunLoop.perform(afterDelay: effectiveDelay) { + self.didReceiveFrame = true + self.contentSize = size + notify() + } + } + + /// Notifies the synchronizer that the Flutter view is being shut down. + /// + /// Unblocks the platform thread if blocked. + @objc public func shutDown() { + FlutterRunLoop.mainRunLoop.perform { + self.isShuttingDown = true + } + } +} diff --git a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/ResizeSynchronizerTest.swift b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/ResizeSynchronizerTest.swift new file mode 100644 index 00000000000..04cec96b249 --- /dev/null +++ b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/ResizeSynchronizerTest.swift @@ -0,0 +1,212 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import InternalFlutterSwift +import Testing + +// Tests for `ResizeSynchronizer`. +// +// `FlutterRunLoop` asserts that it be called on the main thread, and thus the main thread is, +// in effect, a source of implicit shared state/behaviour. Because `beginResize` is a blocking call +// performed on the main thread, we serialise to avoid potential interactions between tests. +@Suite("ResizeSynchronizer tests", .serialized) +struct ResizeSynchronizerTest { + + @MainActor + @Test("performCommit callback executes when no resize is active") + func testNotBlocked() async { + FlutterRunLoop.ensureMainLoopInitialized() + + let synchronizer = ResizeSynchronizer() + var didReceiveFrame = false + + // Call performCommit from raster thread during frame present. + Thread.detachNewThread { + synchronizer.performCommit(forSize: CGSize(width: 100, height: 100), afterDelay: 0) { + didReceiveFrame = true + } + } + + // Ensure the task is processed within the timeout. + do { + try await waitForCondition("didReceiveFrame to be true", timeout: 1.0) { didReceiveFrame } + } catch { + // Record the timeout and bail out of the test. + Issue.record("\(error)") + return + } + } + + @MainActor + @Test("beginResize does not invoke onTimeout if performCommit called with matching frame size") + func testBeginResizeDoesNotTimeOutWithMatchingPerformCommit() async { + FlutterRunLoop.ensureMainLoopInitialized() + + // Resize synchronizer must have presented a frame in order to block. + let synchronizer = ResizeSynchronizer() + var didReceiveFrame = false + synchronizer.performCommit(forSize: CGSize(width: 10, height: 10), afterDelay: 0) { + didReceiveFrame = true + } + do { + try await waitForCondition("didReceiveFrame to be true", timeout: 1.0) { didReceiveFrame } + } catch { + // Record the timeout and bail out of the test. + Issue.record("\(error)") + return + } + + var commit1 = false + var commit2 = false + var didTimeout = false + let latch = DispatchSemaphore(value: 0) + + // Call performCommit from raster thread during frame present. + Thread.detachNewThread { + // Block until `beginResize` has been called. + latch.wait() + + // First commit size DOES NOT match that passed to beginResize. + synchronizer.performCommit(forSize: CGSize(width: 50, height: 100), afterDelay: 0) { + commit1 = true + } + + // Second commit size DOES match that passed to beginResize. + synchronizer.performCommit(forSize: CGSize(width: 100, height: 100), afterDelay: 0) { + commit2 = true + } + } + + // This call blocks until performCommit is called with matching size, or times out. + synchronizer.beginResize(forSize: CGSize(width: 100, height: 100)) { + // Unblock the raster thread by signaling the latch. + latch.signal() + } onTimeout: { + didTimeout = true + } + + // Verify beginResize did not timeout. + #expect( + didTimeout == false, + "onTimeout was called even though performCommit was called with matching size") + + // Verify performCommit callbacks were invoked. + #expect(commit1 == true) + #expect(commit2 == true) + } + + @MainActor + @Test("beginResize invokes onTimeout if performCommit not called with matching frame size") + func testBeginResizeDoesTimeOutWithoutMatchingPerformCommit() async { + FlutterRunLoop.ensureMainLoopInitialized() + + // Resize synchronizer must have presented a frame in order to block. + let synchronizer = ResizeSynchronizer() + var didReceiveFrame = false + synchronizer.performCommit(forSize: CGSize(width: 10, height: 10), afterDelay: 0) { + didReceiveFrame = true + } + do { + try await waitForCondition("didReceiveFrame to be true", timeout: 1.0) { didReceiveFrame } + } catch { + // Record the timeout and bail out of the test. + Issue.record("\(error)") + return + } + + didReceiveFrame = false + var didTimeout = false + let latch = DispatchSemaphore(value: 0) + + // Call performCommit from raster thread during frame present. + Thread.detachNewThread { + // Block until `beginResize` has been called. + latch.wait() + + // First commit size DOES NOT match that passed to beginResize. + synchronizer.performCommit(forSize: CGSize(width: 50, height: 100), afterDelay: 0) { + didReceiveFrame = true + } + } + + // This call blocks until performCommit is called with matching size, or times out. + synchronizer.beginResize(forSize: CGSize(width: 100, height: 100)) { + // Unblock the raster thread by signaling the latch. + latch.signal() + } onTimeout: { + didTimeout = true + } + + // Verify beginResize timed out. + #expect( + didTimeout == true, + "onTimeout was not called even though performCommit was not called with matching size") + + // Verify performCommit callback was invoked. + #expect(didReceiveFrame == true) + } + + @MainActor + @Test("shutDown unblocks an active beginResize and prevents future blocking") + func testUnblocksOnShutdown() async { + FlutterRunLoop.ensureMainLoopInitialized() + let synchronizer = ResizeSynchronizer() + + // Resize synchronizer must have received one frame in order to block. + var didReceiveFrame = false + synchronizer.performCommit(forSize: CGSize(width: 10, height: 10), afterDelay: 0) { + didReceiveFrame = true + } + do { + try await waitForCondition("didReceiveFrame to be true", timeout: 1.0) { didReceiveFrame } + } catch { + // Record the timeout and bail out of the test. + Issue.record("\(error)") + return + } + + // Block until we receive a performCommit with a matching frameSize. + let latch = DispatchSemaphore(value: 0) + Thread.detachNewThread { + // Block until `beginResize` has been called, and signals the latch. + latch.wait() + + synchronizer.shutDown() + } + + synchronizer.beginResize(forSize: CGSize(width: 100, height: 100)) { + // Unblock resize. + latch.signal() + } + + // Subsequent calls should not block. + synchronizer.beginResize(forSize: CGSize(width: 100, height: 100)) {} + } + +} + +// Returns when `condition` is true, or throws if it's not true at the expiration of `timeout`. +// Polls `FlutterRunLoop.mainRunLoop` to execute any posted tasks every `pollingInterval` seconds. +@MainActor +private func waitForCondition( + _ description: String, + timeout: TimeInterval, + pollingInterval: TimeInterval = 0.01, + condition: @escaping @MainActor () -> Bool +) async throws { + let startTime = CFAbsoluteTimeGetCurrent() + while !condition() { + if CFAbsoluteTimeGetCurrent() - startTime > timeout { + struct TimeoutError: Error, CustomStringConvertible { + let message: String + var description: String { message } + } + throw TimeoutError(message: "Timeout waiting for \(description) after \(timeout) seconds") + } + // Pump messages to ensure performCommit blocks execute. + FlutterRunLoop.mainRunLoop.pollFlutterMessagesOnce() + // Allow other tasks to run, avoid pegging the CPU. + try await Task.sleep(nanoseconds: UInt64(pollingInterval * 1_000_000_000)) + } +} diff --git a/engine/src/flutter/testing/run_tests.py b/engine/src/flutter/testing/run_tests.py index 51f77083e08..eeacd250f7d 100755 --- a/engine/src/flutter/testing/run_tests.py +++ b/engine/src/flutter/testing/run_tests.py @@ -506,6 +506,15 @@ def run_cc_tests(build_dir, executable_filter, coverage, capture_core_dump): xvfb.stop_virtual_x(build_name) if is_mac(): + # macOS Desktop unit tests written in Swift. + run_engine_executable( + build_dir, + 'flutter_desktop_darwin_swift_unittests', + executable_filter, + shuffle_flags, + coverage=coverage + ) + # flutter_desktop_darwin_unittests uses global state that isn't handled # correctly by gtest-parallel. # https://github.com/flutter/flutter/issues/104789