diff --git a/WebDriverAgentLib/Commands/FBScreenCaptureCommands.m b/WebDriverAgentLib/Commands/FBScreenCaptureCommands.m index c3c1f1c362..00f57813a0 100644 --- a/WebDriverAgentLib/Commands/FBScreenCaptureCommands.m +++ b/WebDriverAgentLib/Commands/FBScreenCaptureCommands.m @@ -10,6 +10,7 @@ #import "FBBroadcastManager.h" #import "FBConfiguration.h" +#import "FBLogger.h" #import "FBRouteRequest.h" #import "FBVideoStreamManager.h" @@ -30,10 +31,12 @@ + (NSArray *)routes // otherwise be swallowed by 'GET /mobilerun/screencapture/:id'. [[FBRoute POST:@"/mobilerun/screencapture/broadcast/start"] respondWithTarget:self action:@selector(handleStartBroadcast:)], [[FBRoute POST:@"/mobilerun/screencapture/broadcast/stop"] respondWithTarget:self action:@selector(handleStopBroadcast:)], + // Not marked onControlQueue: decorating a session-required route reads FBSession's static + // active-session state, which the automation queue writes without synchronization. [[FBRoute GET:@"/mobilerun/screencapture/broadcast"] respondWithTarget:self action:@selector(handleGetBroadcastStatus:)], [[FBRoute POST:@"/mobilerun/screencapture/broadcast/start"].withoutSession respondWithTarget:self action:@selector(handleStartBroadcast:)], [[FBRoute POST:@"/mobilerun/screencapture/broadcast/stop"].withoutSession respondWithTarget:self action:@selector(handleStopBroadcast:)], - [[FBRoute GET:@"/mobilerun/screencapture/broadcast"].withoutSession respondWithTarget:self action:@selector(handleGetBroadcastStatus:)], + [[[FBRoute GET:@"/mobilerun/screencapture/broadcast"].withoutSession onControlQueue] respondWithTarget:self action:@selector(handleGetBroadcastStatus:)], [[FBRoute POST:@"/mobilerun/screencapture/start"] respondWithTarget:self action:@selector(handleStartScreenCapture:)], [[FBRoute POST:@"/mobilerun/screencapture/stop"] respondWithTarget:self action:@selector(handleStopAllScreenCapture:)], @@ -43,11 +46,11 @@ + (NSArray *)routes [[FBRoute POST:@"/mobilerun/screencapture/:id/keyframe"] respondWithTarget:self action:@selector(handleRequestKeyFrame:)], [[FBRoute POST:@"/mobilerun/screencapture/start"].withoutSession respondWithTarget:self action:@selector(handleStartScreenCapture:)], - [[FBRoute POST:@"/mobilerun/screencapture/stop"].withoutSession respondWithTarget:self action:@selector(handleStopAllScreenCapture:)], - [[FBRoute GET:@"/mobilerun/screencapture"].withoutSession respondWithTarget:self action:@selector(handleListScreenCapture:)], - [[FBRoute GET:@"/mobilerun/screencapture/:id"].withoutSession respondWithTarget:self action:@selector(handleGetScreenCapture:)], - [[FBRoute POST:@"/mobilerun/screencapture/:id/stop"].withoutSession respondWithTarget:self action:@selector(handleStopScreenCapture:)], - [[FBRoute POST:@"/mobilerun/screencapture/:id/keyframe"].withoutSession respondWithTarget:self action:@selector(handleRequestKeyFrame:)], + [[[FBRoute POST:@"/mobilerun/screencapture/stop"].withoutSession onControlQueue] respondWithTarget:self action:@selector(handleStopAllScreenCapture:)], + [[[FBRoute GET:@"/mobilerun/screencapture"].withoutSession onControlQueue] respondWithTarget:self action:@selector(handleListScreenCapture:)], + [[[FBRoute GET:@"/mobilerun/screencapture/:id"].withoutSession onControlQueue] respondWithTarget:self action:@selector(handleGetScreenCapture:)], + [[[FBRoute POST:@"/mobilerun/screencapture/:id/stop"].withoutSession onControlQueue] respondWithTarget:self action:@selector(handleStopScreenCapture:)], + [[[FBRoute POST:@"/mobilerun/screencapture/:id/keyframe"].withoutSession onControlQueue] respondWithTarget:self action:@selector(handleRequestKeyFrame:)], ]; } @@ -126,6 +129,21 @@ + (NSArray *)routes return FBResponseWithStatus([FBCommandStatus invalidArgumentErrorWithMessage:@"Both 'width' and 'height' must be provided as positive integers" traceback:nil]); } + NSUInteger pixelBudget = 0; + NSUInteger deviceDefaultBudget = [FBScreenCaptureConfiguration fb_defaultPixelBudgetForMachineModel:[FBScreenCaptureConfiguration fb_machineModel]]; + if (![FBScreenCaptureConfiguration fb_pixelBudget:&pixelBudget + fromArgument:request.arguments[@"maxPixels"] + deviceDefault:deviceDefaultBudget]) { + return FBResponseWithStatus([FBCommandStatus invalidArgumentErrorWithMessage:@"'maxPixels' must be 0 (uncapped) or an integer of at least 4" traceback:nil]); + } + CGSize cappedSize = [FBScreenCaptureConfiguration fb_sizeForWidth:(NSUInteger)width height:(NSUInteger)height pixelBudget:pixelBudget]; + if ((NSInteger)cappedSize.width < width || (NSInteger)cappedSize.height < height) { + [FBLogger logFmt:@"Capping the requested capture size %ldx%ld to %ldx%ld (pixel budget %lu)", + (long)width, (long)height, (long)cappedSize.width, (long)cappedSize.height, (unsigned long)pixelBudget]; + width = (NSInteger)cappedSize.width; + height = (NSInteger)cappedSize.height; + } + FBScreenCaptureConfiguration *configuration = [[FBScreenCaptureConfiguration alloc] init]; configuration.codec = codec; configuration.framing = framing; diff --git a/WebDriverAgentLib/Commands/FBSessionCommands.m b/WebDriverAgentLib/Commands/FBSessionCommands.m index c9ed629024..b23027daf0 100644 --- a/WebDriverAgentLib/Commands/FBSessionCommands.m +++ b/WebDriverAgentLib/Commands/FBSessionCommands.m @@ -48,7 +48,7 @@ + (NSArray *)routes [[FBRoute GET:@"/wda/apps/list"] respondWithTarget:self action:@selector(handleGetActiveAppsList:)], [[FBRoute GET:@""] respondWithTarget:self action:@selector(handleGetActiveSession:)], [[FBRoute DELETE:@""] respondWithTarget:self action:@selector(handleDeleteSession:)], - [[FBRoute GET:@"/status"].withoutSession respondWithTarget:self action:@selector(handleGetStatus:)], + [[[FBRoute GET:@"/status"].withoutSession onControlQueue] respondWithTarget:self action:@selector(handleGetStatus:)], // Health check might modify simulator state so it should only be called in-between testing sessions [[FBRoute GET:@"/wda/healthcheck"].withoutSession respondWithTarget:self action:@selector(handleGetHealthCheck:)], diff --git a/WebDriverAgentLib/Commands/FBUnknownCommands.m b/WebDriverAgentLib/Commands/FBUnknownCommands.m index 7fc35b96b6..4355953854 100644 --- a/WebDriverAgentLib/Commands/FBUnknownCommands.m +++ b/WebDriverAgentLib/Commands/FBUnknownCommands.m @@ -23,10 +23,10 @@ + (NSArray *)routes { return @[ - [[FBRoute GET:@"/*"].withoutSession respondWithTarget:self action:@selector(unhandledHandler:)], - [[FBRoute POST:@"/*"].withoutSession respondWithTarget:self action:@selector(unhandledHandler:)], - [[FBRoute PUT:@"/*"].withoutSession respondWithTarget:self action:@selector(unhandledHandler:)], - [[FBRoute DELETE:@"/*"].withoutSession respondWithTarget:self action:@selector(unhandledHandler:)] + [[[FBRoute GET:@"/*"].withoutSession onControlQueue] respondWithTarget:self action:@selector(unhandledHandler:)], + [[[FBRoute POST:@"/*"].withoutSession onControlQueue] respondWithTarget:self action:@selector(unhandledHandler:)], + [[[FBRoute PUT:@"/*"].withoutSession onControlQueue] respondWithTarget:self action:@selector(unhandledHandler:)], + [[[FBRoute DELETE:@"/*"].withoutSession onControlQueue] respondWithTarget:self action:@selector(unhandledHandler:)] ]; } diff --git a/WebDriverAgentLib/Routing/FBRoute.h b/WebDriverAgentLib/Routing/FBRoute.h index fce8dd8a98..7982b7e707 100644 --- a/WebDriverAgentLib/Routing/FBRoute.h +++ b/WebDriverAgentLib/Routing/FBRoute.h @@ -27,6 +27,10 @@ typedef __nonnull id (^FBRouteSyncHandler)(FBRouteRequest *re /*! Route's path */ @property (nonatomic, copy, readonly) NSString *path; +/*! YES when the route is served directly on the HTTP connection's queue instead of the + automation (main) queue */ +@property (nonatomic, assign, readonly) BOOL usesControlQueue; + /** Convenience constructor for GET route with given pathPattern */ @@ -67,6 +71,14 @@ typedef __nonnull id (^FBRouteSyncHandler)(FBRouteRequest *re */ - (instancetype)withoutSession; +/** + Chain-able modifier that marks the route to be served on the HTTP connection's own queue, + bypassing the automation (main) queue. Only routes whose handlers never call XCUI or + testmanagerd APIs and only touch thread-safe state may opt in — such routes stay responsive + even while an automation request is blocked. + */ +- (instancetype)onControlQueue; + /** Dispatches response for request */ diff --git a/WebDriverAgentLib/Routing/FBRoute.m b/WebDriverAgentLib/Routing/FBRoute.m index fbe69b8c3c..b401666496 100644 --- a/WebDriverAgentLib/Routing/FBRoute.m +++ b/WebDriverAgentLib/Routing/FBRoute.m @@ -18,6 +18,7 @@ @interface FBRoute () @property (nonatomic, assign, readwrite) BOOL requiresSession; +@property (nonatomic, assign, readwrite) BOOL usesControlQueue; @property (nonatomic, copy, readwrite) NSString *verb; @property (nonatomic, copy, readwrite) NSString *path; @@ -126,10 +127,17 @@ - (instancetype)withoutSession return self; } +- (instancetype)onControlQueue +{ + self.usesControlQueue = YES; + return self; +} + - (instancetype)respondWithBlock:(FBRouteSyncHandler)handler { FBRoute_Sync *route = [FBRoute_Sync withVerb:self.verb path:self.path requiresSession:self.requiresSession]; route.handler = handler; + route.usesControlQueue = self.usesControlQueue; return route; } @@ -138,6 +146,7 @@ - (instancetype)respondWithTarget:(id)target action:(SEL)action FBRoute_TargetAction *route = [FBRoute_TargetAction withVerb:self.verb path:self.path requiresSession:self.requiresSession]; route.target = target; route.action = action; + route.usesControlQueue = self.usesControlQueue; return route; } diff --git a/WebDriverAgentLib/Routing/FBSession.m b/WebDriverAgentLib/Routing/FBSession.m index 02889303a2..a57b1c0877 100644 --- a/WebDriverAgentLib/Routing/FBSession.m +++ b/WebDriverAgentLib/Routing/FBSession.m @@ -89,19 +89,31 @@ - (void)didDetectAlert:(FBAlert *)alert @implementation FBSession +// Control routes (e.g. /status) are served on their own connection queue and read this +// static concurrently with main-queue writes in markSessionActive:/kill. All reads and +// writes of _activeSession must go through the @synchronized (FBSession.class) accessors +// below. static FBSession *_activeSession = nil; + (instancetype)activeSession { - return _activeSession; + @synchronized (FBSession.class) { + return _activeSession; + } } + (void)markSessionActive:(FBSession *)session { - if (_activeSession) { - [_activeSession kill]; + FBSession *previousSession; + @synchronized (FBSession.class) { + previousSession = _activeSession; + } + if (previousSession) { + [previousSession kill]; + } + @synchronized (FBSession.class) { + _activeSession = session; } - _activeSession = session; } + (instancetype)sessionWithIdentifier:(NSString *)identifier @@ -109,10 +121,11 @@ + (instancetype)sessionWithIdentifier:(NSString *)identifier if (!identifier) { return nil; } - if (![identifier isEqualToString:_activeSession.identifier]) { + FBSession *activeSession = self.activeSession; + if (![identifier isEqualToString:activeSession.identifier]) { return nil; } - return _activeSession; + return activeSession; } + (instancetype)initWithApplication:(XCUIApplication *)application @@ -169,7 +182,11 @@ - (BOOL)disableAlertsMonitor - (void)kill { - if (nil == _activeSession) { + BOOL wasActive; + @synchronized (FBSession.class) { + wasActive = (nil != _activeSession); + } + if (!wasActive) { return; } @@ -195,7 +212,9 @@ - (void)kill } } - _activeSession = nil; + @synchronized (FBSession.class) { + _activeSession = nil; + } } - (XCUIApplication *)activeApplication diff --git a/WebDriverAgentLib/Routing/FBWebServer.m b/WebDriverAgentLib/Routing/FBWebServer.m index c9a3a03281..4578f9993c 100644 --- a/WebDriverAgentLib/Routing/FBWebServer.m +++ b/WebDriverAgentLib/Routing/FBWebServer.m @@ -28,6 +28,7 @@ #import "FBUnknownCommands.h" #import "FBConfiguration.h" #import "FBLogger.h" +#import "FBXCodeCompatibility.h" #import "XCUIDevice+FBHelpers.h" @@ -65,6 +66,9 @@ @interface FBWebServer () @property (nonatomic, nullable, strong) FBMjpegServer *mjpegServer; #endif @property (atomic, assign) BOOL keepAlive; +// Serializes automation requests onto a single funnel so at most one is ever in flight on +// the main queue. See registerRouteHandlers: for why this is necessary. +@property (nonatomic, strong) dispatch_queue_t automationQueue; @end @implementation FBWebServer @@ -105,6 +109,17 @@ - (void)startServing #endif self.keepAlive = YES; + // /status is served off the main queue (it uses onControlQueue), but FBSDKVersion() and + // FBTestmanagerdVersion() cache their result behind a dispatch_once. Burn both once-tokens + // here, on the main thread, warmed only after the server has bound: FBTestmanagerdVersion()'s + // legacy branch waits (with a bounded timeout) on the daemon, and a degraded daemon must not + // be able to prevent the server from binding. An early request that races the warm-up just + // blocks on the dispatch_once for at most the bounded handshake. Warmed only after + // initialization is complete and keepAlive is set, so a shutdown that arrives while the + // bounded legacy handshake spins the run loop simply clears keepAlive via stopServing and the + // serving loop below never starts. + FBSDKVersion(); + FBTestmanagerdVersion(); NSRunLoop *runLoop = [NSRunLoop mainRunLoop]; while (self.keepAlive) { @try { @@ -132,7 +147,9 @@ - (BOOL)startHTTPServer #else self.server = [[RoutingHTTPServer alloc] init]; #endif +#if TARGET_OS_WATCH [self.server setRouteQueue:dispatch_get_main_queue()]; +#endif [self.server setDefaultHeader:@"Server" value:@"WebDriverAgent/1.0"]; [self.server setDefaultHeader:@"Access-Control-Allow-Origin" value:@"*"]; [self.server setDefaultHeader:@"Access-Control-Allow-Headers" value:@"Content-Type, X-Requested-With"]; @@ -140,6 +157,8 @@ - (BOOL)startHTTPServer [self.server setConnectionClass:[FBHTTPConnection self]]; #endif + self.automationQueue = dispatch_queue_create("com.facebook.WebDriverAgent.automation-funnel", DISPATCH_QUEUE_SERIAL); + [self registerRouteHandlers:[self.class collectCommandHandlerClasses]]; [self registerServerKeyRouteHandlers]; @@ -293,17 +312,41 @@ - (void)registerRouteHandlers:(NSArray *)commandHandlerClasses [FBLogger verboseLog:routeParams.description]; - @try { - [route mountRequest:routeParams intoResponse:response]; - } - @catch (NSException *exception) { - [strongSelf handleException:exception forResponse:response]; +#if TARGET_OS_WATCH + [strongSelf mountRoute:route request:routeParams intoResponse:response]; +#else + if (route.usesControlQueue) { + // Served on this connection's own queue so it stays responsive while the automation + // queue is busy or blocked. Only routes that never touch XCUI state opt in. + [strongSelf mountRoute:route request:routeParams intoResponse:response]; + } else { + // Serialize automation requests: while one is on the main queue (possibly spinning the + // run loop), the next waits here instead of being enqueued to main, where a nested run + // loop drain would otherwise execute it reentrantly inside the first handler. + dispatch_sync(strongSelf.automationQueue, ^{ + dispatch_sync(dispatch_get_main_queue(), ^{ + @autoreleasepool { + [strongSelf mountRoute:route request:routeParams intoResponse:response]; + } + }); + }); } +#endif }]; } } } +- (void)mountRoute:(FBRoute *)route request:(FBRouteRequest *)routeParams intoResponse:(RouteResponse *)response +{ + @try { + [route mountRequest:routeParams intoResponse:response]; + } + @catch (NSException *exception) { + [self handleException:exception forResponse:response]; + } +} + - (void)handleException:(NSException *)exception forResponse:(RouteResponse *)response { [self.exceptionHandler handleException:exception forResponse:response]; @@ -332,7 +375,11 @@ - (void)registerServerKeyRouteHandlers return; } [response respondWithString:@"Shutting down"]; - [strongSelf.delegate webServerDidRequestShutdown:strongSelf]; + // The delegate tears down automation state; run it on the main queue without blocking + // this connection's queue. + dispatch_async(dispatch_get_main_queue(), ^{ + [strongSelf.delegate webServerDidRequestShutdown:strongSelf]; + }); }]; [self registerRouteHandlers:@[FBUnknownCommands.class]]; diff --git a/WebDriverAgentLib/Utilities/FBBroadcastManager.m b/WebDriverAgentLib/Utilities/FBBroadcastManager.m index 9e07094ff6..8bd149a56e 100644 --- a/WebDriverAgentLib/Utilities/FBBroadcastManager.m +++ b/WebDriverAgentLib/Utilities/FBBroadcastManager.m @@ -43,7 +43,9 @@ static uint64_t FBBroadcastNowMs(void) @interface FBBroadcastManager () -@property (nonatomic, nullable) FBBroadcastControlServer *controlServer; +// Read from connection queues (broadcast status route, sessionless capture-stop notifications) +// while the main thread assigns/clears it - must stay atomic. +@property (atomic, nullable) FBBroadcastControlServer *controlServer; @property (atomic, nullable, copy) NSDictionary *helloInfo; @property (atomic, nullable, copy) NSDictionary *lastHeartbeat; @property (atomic, nullable) NSDate *connectedAt; diff --git a/WebDriverAgentLib/Utilities/FBConfiguration.h b/WebDriverAgentLib/Utilities/FBConfiguration.h index a7091a4d42..74004ed8df 100644 --- a/WebDriverAgentLib/Utilities/FBConfiguration.h +++ b/WebDriverAgentLib/Utilities/FBConfiguration.h @@ -139,6 +139,14 @@ typedef NS_ENUM(NSInteger, FBConfigurationKeyboardPreference) { */ @property (atomic, readonly) UInt64 httpRequestBodySizeLimit; +/** + Extra time in seconds granted on top of a synthesized event's own scheduled duration before an + unacknowledged synthesis is failed with an error instead of blocking the caller forever. + Override with the EVENT_SYNTHESIS_TIMEOUT_MARGIN environment variable (a positive number of + seconds). Defaults to 15. + */ +- (NSTimeInterval)eventSynthesisTimeoutMargin; + /** The default port number where the raw H.264/H.265 screen capture broadcaster is supposed to run. The default value is 9200. It can be overridden via the SCREEN_CAPTURE_SERVER_PORT environment diff --git a/WebDriverAgentLib/Utilities/FBConfiguration.m b/WebDriverAgentLib/Utilities/FBConfiguration.m index 649b3bb602..4c321d2c6f 100644 --- a/WebDriverAgentLib/Utilities/FBConfiguration.m +++ b/WebDriverAgentLib/Utilities/FBConfiguration.m @@ -30,6 +30,7 @@ static NSUInteger const DefaultAudioCaptureServerPort = 9400; static NSUInteger const DefaultPortRange = 100; static UInt64 const DefaultHttpRequestBodySizeLimit = 1024ull * 1024ull * 1024ull; +static const NSTimeInterval DefaultEventSynthesisTimeoutMargin = 15.0; static char const *const controllerPrefBundlePath = "/System/Library/PrivateFrameworks/TextInput.framework/TextInput"; static NSString *const controllerClassName = @"TIPreferencesController"; @@ -218,6 +219,18 @@ - (UInt64)httpRequestBodySizeLimit return DefaultHttpRequestBodySizeLimit; } +- (NSTimeInterval)eventSynthesisTimeoutMargin +{ + const char *rawMargin = getenv("EVENT_SYNTHESIS_TIMEOUT_MARGIN"); + if (rawMargin != NULL) { + double parsedMargin = atof(rawMargin); + if (parsedMargin > 0) { + return parsedMargin; + } + } + return DefaultEventSynthesisTimeoutMargin; +} + - (BOOL)verboseLoggingEnabled { return [NSProcessInfo.processInfo.environment[@"VERBOSE_LOGGING"] boolValue]; diff --git a/WebDriverAgentLib/Utilities/FBRunLoopSpinner.h b/WebDriverAgentLib/Utilities/FBRunLoopSpinner.h index 5f8faf52ec..a3f19e220c 100644 --- a/WebDriverAgentLib/Utilities/FBRunLoopSpinner.h +++ b/WebDriverAgentLib/Utilities/FBRunLoopSpinner.h @@ -22,6 +22,16 @@ typedef __nullable id (^FBRunLoopSpinnerObjectBlock)(void); */ + (void)spinUntilCompletion:(void (^)(void(^completion)(void)))block; +/** + Dispatches block and spins the run loop until `completion` is called or the timeout expires. + + @param block the block to wait for to finish. + @param timeout the maximum time in seconds to wait for the completion. + @return YES if the completion was called before the deadline, NO on timeout. A completion + firing after the deadline is a harmless no-op. + */ ++ (BOOL)spinUntilCompletion:(void (^)(void(^completion)(void)))block timeout:(NSTimeInterval)timeout; + /** Updates the error message to print in the event of a timeout. diff --git a/WebDriverAgentLib/Utilities/FBRunLoopSpinner.m b/WebDriverAgentLib/Utilities/FBRunLoopSpinner.m index 0912325421..5f1ac8ffda 100644 --- a/WebDriverAgentLib/Utilities/FBRunLoopSpinner.m +++ b/WebDriverAgentLib/Utilities/FBRunLoopSpinner.m @@ -24,13 +24,25 @@ @implementation FBRunLoopSpinner + (void)spinUntilCompletion:(void (^)(void(^completion)(void)))block { + [self spinUntilCompletion:block timeout:DBL_MAX]; +} + ++ (BOOL)spinUntilCompletion:(void (^)(void(^completion)(void)))block timeout:(NSTimeInterval)timeout +{ + // The __block flag is moved to the heap when the completion block escapes, so a completion + // arriving after a timeout return still writes valid memory and is simply never read. __block volatile atomic_bool didFinish = false; block(^{ atomic_fetch_or(&didFinish, true); }); + NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:timeout]; while (!atomic_fetch_and(&didFinish, false)) { + if (deadline.timeIntervalSinceNow <= 0) { + return NO; + } [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:FBWaitInterval]]; } + return YES; } - (instancetype)init diff --git a/WebDriverAgentLib/Utilities/FBVideoStreamManager.m b/WebDriverAgentLib/Utilities/FBVideoStreamManager.m index 1e9182dfda..261d043bd3 100644 --- a/WebDriverAgentLib/Utilities/FBVideoStreamManager.m +++ b/WebDriverAgentLib/Utilities/FBVideoStreamManager.m @@ -39,6 +39,9 @@ @interface FBVideoStreamManager () // bind/encoder start happens outside the lock). Counted toward the cap so concurrent starts // cannot collectively exceed MAX_SESSIONS. @property (nonatomic) NSUInteger pendingStarts; +// Guarded by @synchronized (self.sessions). Bumped by stopAllSessions so starts already in +// flight across it abort instead of resurrecting a capture after a completed stop-all. +@property (nonatomic) NSUInteger stopGeneration; @property (nonatomic) long long mainScreenID; @property (nonatomic) NSUInteger consecutiveScreenshotFailures; @property (atomic) BOOL isStreaming; @@ -79,7 +82,9 @@ - (nullable FBVideoStreamSession *)startSessionWithConfiguration:(FBScreenCaptur NSUInteger identifier; BOOL shouldStartLoop = NO; BOOL autoAssignPort = (0 == configuration.port); + NSUInteger startGeneration; @synchronized (self.sessions) { + startGeneration = self.stopGeneration; // Count in-flight starts toward the cap: their sessions are not inserted until after the // (slow) bind/encoder start, so without this two concurrent starts could both pass the check // and exceed MAX_SESSIONS. @@ -115,16 +120,43 @@ - (nullable FBVideoStreamSession *)startSessionWithConfiguration:(FBScreenCaptur } NSUInteger generation = 0; + BOOL abortedByStopAll = NO; @synchronized (self.sessions) { - self.pendingStarts -= 1; - self.sessions[@(identifier)] = session; - self.mainScreenID = [XCUIScreen.mainScreen displayID]; - if (!self.isStreaming) { - self.isStreaming = YES; - self.loopGeneration += 1; - shouldStartLoop = YES; + if (self.stopGeneration != startGeneration) { + // A stop-all ran to completion while this start's bind/encoder setup was in flight outside + // the lock. Abort instead of inserting a session the stop-all already promised was gone. + self.pendingStarts -= 1; + abortedByStopAll = YES; + } else { + self.pendingStarts -= 1; + self.sessions[@(identifier)] = session; + self.mainScreenID = [XCUIScreen.mainScreen displayID]; + if (!self.isStreaming) { + self.isStreaming = YES; + self.loopGeneration += 1; + shouldStartLoop = YES; + } + generation = self.loopGeneration; + // Attach the session to the broadcast extension (if connected): the session keeps serving + // locally encoded screenshot frames until the extension's first key frame arrives. + session.onBroadcastKeyFrameNeeded = ^(NSUInteger sessionIdentifier) { + [FBBroadcastManager.sharedInstance requestKeyFrameForSession:sessionIdentifier]; + }; + // notifySessionAdded: only enqueues onto the broadcast control server's own serial queue + // (verified), and sending it under the sessions lock guarantees a stop that removes this + // session — which must take the same lock first — always enqueues its REMOVE after our ADD. + [FBBroadcastManager.sharedInstance notifySessionAdded:session]; + } + } + + if (abortedByStopAll) { + [session stop]; + if (error) { + *error = [NSError errorWithDomain:@"com.facebook.WebDriverAgent.FBVideoStreamManager" + code:1 + userInfo:@{NSLocalizedDescriptionKey: @"The screen capture session was stopped while it was starting"}]; } - generation = self.loopGeneration; + return nil; } if (shouldStartLoop) { @@ -134,12 +166,6 @@ - (nullable FBVideoStreamSession *)startSessionWithConfiguration:(FBScreenCaptur [weakSelf captureFrameWithGeneration:generation]; }); } - // Attach the session to the broadcast extension (if connected): the session keeps serving - // locally encoded screenshot frames until the extension's first key frame arrives. - session.onBroadcastKeyFrameNeeded = ^(NSUInteger sessionIdentifier) { - [FBBroadcastManager.sharedInstance requestKeyFrameForSession:sessionIdentifier]; - }; - [FBBroadcastManager.sharedInstance notifySessionAdded:session]; [FBLogger logFmt:@"Started screen capture session %@ (%@ %@x%@) on port %@", @(identifier), session.toDictionary[@"codec"], @(configuration.width), @(configuration.height), @(configuration.port)]; return session; @@ -244,6 +270,10 @@ - (void)stopAllSessions snapshot = self.sessions.allValues; [self.sessions removeAllObjects]; self.isStreaming = NO; + // Bump the barrier: any start already past this method's own lock acquisition (i.e. mid + // bind/encoder setup) will see its stale startGeneration on re-entry and abort rather than + // resurrect a session after this stop-all has completed. + self.stopGeneration += 1; } for (FBVideoStreamSession *session in snapshot) { [session stop]; diff --git a/WebDriverAgentLib/Utilities/FBVideoStreamSession.h b/WebDriverAgentLib/Utilities/FBVideoStreamSession.h index 612e1ffcef..8c94a77cca 100644 --- a/WebDriverAgentLib/Utilities/FBVideoStreamSession.h +++ b/WebDriverAgentLib/Utilities/FBVideoStreamSession.h @@ -49,6 +49,40 @@ typedef NS_ENUM(NSUInteger, FBVideoStreamSource) { /** The TCP port the encoded stream is broadcast on. */ @property (nonatomic) uint16_t port; +/** + The raw device model identifier (e.g. 'iPhone11,2'), resolved via sysctl. On the simulator the + simulated device's identifier is returned instead of the host architecture. + */ ++ (NSString *)fb_machineModel; + +/** + The pixel budget (maximum width*height) that is safe for sustained capture on the given device + model, or 0 when the model has no default cap. + */ ++ (NSUInteger)fb_defaultPixelBudgetForMachineModel:(NSString *)machineModel; + +/** + Scales width/height down (aspect-preserving, rounded down to even values) until + width*height <= budget. A budget of 0, a size already within budget, or a degenerate size is + returned unchanged. For budgets >= 4 the returned size never exceeds the budget; the aspect + ratio may be sacrificed for extreme aspect inputs where the minimum encodable size (2x2) would + otherwise push the product back over budget. Budgets 1..3 cannot be honored (2x2 = 4 is the + minimum encodable size) and are rejected at the API boundary before this method is called. + */ ++ (CGSize)fb_sizeForWidth:(NSUInteger)width height:(NSUInteger)height pixelBudget:(NSUInteger)budget; + +/** + Parses the optional 'maxPixels' request argument into a pixel budget. + + @param outBudget On success: the parsed budget (0 = uncapped); the device default when the + argument is absent. + @param maxPixels The raw request argument (nil when absent). + @param deviceDefault The device-class default budget applied when the argument is absent. + @return NO when the argument is present but is not a finite, non-negative, integral number + equal to 0 or of at least 4 (2x2 = 4 is the minimum encodable size). + */ ++ (BOOL)fb_pixelBudget:(NSUInteger *)outBudget fromArgument:(nullable id)maxPixels deviceDefault:(NSUInteger)deviceDefault; + @end /** diff --git a/WebDriverAgentLib/Utilities/FBVideoStreamSession.m b/WebDriverAgentLib/Utilities/FBVideoStreamSession.m index 10745a4c7f..45b3c3d6ca 100644 --- a/WebDriverAgentLib/Utilities/FBVideoStreamSession.m +++ b/WebDriverAgentLib/Utilities/FBVideoStreamSession.m @@ -13,6 +13,7 @@ #import #import #import +#import #import "GCDAsyncSocket.h" #import "FBLogger.h" @@ -34,6 +35,95 @@ - (instancetype)init return self; } +// 414x896 - the largest per-frame capture load verified safe for sustained 60 fps encoding on +// the oldest supported hardware class; larger frames make the system shed input events under +// load, which starves automation. +static const NSUInteger FBLegacyDevicePixelBudget = 370944; +// iPhone11,x is the A12 generation; every major version at or below it gets the budget. +static const NSInteger FBMaxLegacyIPhoneMajorVersion = 11; + ++ (NSString *)fb_machineModel +{ +#if TARGET_OS_SIMULATOR + // On the simulator hw.machine reports the host architecture; the simulated device model is + // exposed via the environment instead. + NSString *simulatorModel = NSProcessInfo.processInfo.environment[@"SIMULATOR_MODEL_IDENTIFIER"]; + if (simulatorModel.length > 0) { + return simulatorModel; + } +#endif + char machine[64] = {0}; + size_t size = sizeof(machine) - 1; + if (0 == sysctlbyname("hw.machine", machine, &size, NULL, 0) && machine[0] != '\0') { + return [NSString stringWithUTF8String:machine] ?: @""; + } + return @""; +} + ++ (NSUInteger)fb_defaultPixelBudgetForMachineModel:(NSString *)machineModel +{ + static NSString *const prefix = @"iPhone"; + if (![machineModel hasPrefix:prefix]) { + return 0; + } + NSScanner *scanner = [NSScanner scannerWithString:[machineModel substringFromIndex:prefix.length]]; + NSInteger major = 0; + if (![scanner scanInteger:&major] || major <= 0) { + return 0; + } + return major <= FBMaxLegacyIPhoneMajorVersion ? FBLegacyDevicePixelBudget : 0; +} + ++ (CGSize)fb_sizeForWidth:(NSUInteger)width height:(NSUInteger)height pixelBudget:(NSUInteger)budget +{ + // width * height <= budget <=> width <= budget / height (integer division; exact for + // positive integers). The division form cannot overflow, unlike the direct product. + if (0 == budget || 0 == width || 0 == height || width <= budget / height) { + return CGSizeMake(width, height); + } + double scale = sqrt((double)budget / ((double)width * (double)height)); + // floor + even-align only ever shrink, so the scaled product stays within the budget + NSUInteger scaledWidth = ((NSUInteger)floor((double)width * scale)) & ~(NSUInteger)1; + NSUInteger scaledHeight = ((NSUInteger)floor((double)height * scale)) & ~(NSUInteger)1; + scaledWidth = MAX(scaledWidth, (NSUInteger)2); + scaledHeight = MAX(scaledHeight, (NSUInteger)2); + // The minimum-size clamp can push a very skinny result back over the budget (a floored-to-zero + // axis becomes 2 while the other axis was scaled for the pre-clamp aspect). When that happens, + // shrink the larger axis to fit; honoring the budget outranks preserving the aspect ratio. + // Division-based comparison so the check cannot overflow. + if (scaledWidth > budget / scaledHeight) { + if (scaledWidth >= scaledHeight) { + scaledWidth = MAX((budget / scaledHeight) & ~(NSUInteger)1, (NSUInteger)2); + } else { + scaledHeight = MAX((budget / scaledWidth) & ~(NSUInteger)1, (NSUInteger)2); + } + } + return CGSizeMake(scaledWidth, scaledHeight); +} + ++ (BOOL)fb_pixelBudget:(NSUInteger *)outBudget fromArgument:(nullable id)maxPixels deviceDefault:(NSUInteger)deviceDefault +{ + if (nil == maxPixels) { + *outBudget = deviceDefault; + return YES; + } + if (![maxPixels isKindOfClass:NSNumber.class]) { + return NO; + } + // Validate the original numeric value: integerValue would silently truncate fractions + // (0.5 -> 0 disables the cap; -0.5 -> 0 passes a sign check but converts to garbage). + double rawBudget = ((NSNumber *)maxPixels).doubleValue; + if (!isfinite(rawBudget) || rawBudget < 0 || rawBudget != floor(rawBudget) || rawBudget > (double)NSUIntegerMax) { + return NO; + } + // 1..3 cannot be honored: 2x2 = 4 is the minimum encodable size. + if (rawBudget > 0 && rawBudget < 4) { + return NO; + } + *outBudget = (NSUInteger)rawBudget; + return YES; +} + @end diff --git a/WebDriverAgentLib/Utilities/FBXCTestDaemonsProxy.m b/WebDriverAgentLib/Utilities/FBXCTestDaemonsProxy.m index cec4a8bfaf..0e6f784ead 100644 --- a/WebDriverAgentLib/Utilities/FBXCTestDaemonsProxy.m +++ b/WebDriverAgentLib/Utilities/FBXCTestDaemonsProxy.m @@ -21,6 +21,7 @@ #import "XCTRunnerDaemonSession.h" #import "XCUIApplication.h" #import "XCUIDevice.h" +#import "XCSynthesizedEventRecord.h" #define LAUNCH_APP_TIMEOUT_SEC 300 @@ -105,7 +106,12 @@ + (void)swizzleLaunchApp { + (BOOL)synthesizeEventWithRecord:(XCSynthesizedEventRecord *)record error:(NSError *__autoreleasing*)error { __block NSError *innerError = nil; - [FBRunLoopSpinner spinUntilCompletion:^(void(^completion)(void)){ + // maximumOffset is the record's total scheduled duration in seconds, so quick taps get a + // short deadline while long W3C action chains still fit. A synthesis whose completion never + // arrives (e.g. the event was shed by the system under load) must fail this one request + // instead of blocking the automation queue forever. + NSTimeInterval timeout = record.maximumOffset + FBConfiguration.sharedInstance.eventSynthesisTimeoutMargin; + BOOL didComplete = [FBRunLoopSpinner spinUntilCompletion:^(void(^completion)(void)){ void (^errorHandler)(NSError *) = ^(NSError *invokeError) { if (nil != invokeError) { innerError = invokeError; @@ -119,7 +125,12 @@ + (BOOL)synthesizeEventWithRecord:(XCSynthesizedEventRecord *)record error:(NSEr [[XCUIDevice.sharedDevice eventSynthesizer] synthesizeEvent:record completion:(id)^(BOOL result, NSError *invokeError) { handlerBlock(record, invokeError); }]; - }]; + } timeout:timeout]; + if (!didComplete) { + return [[[FBErrorBuilder builder] + withDescriptionFormat:@"The synthesized event was not acknowledged within %.1f seconds. The event delivery pipeline may be overloaded", timeout] + buildError:error]; + } if (nil != innerError) { if (error) { *error = innerError; diff --git a/WebDriverAgentLib/Utilities/FBXCodeCompatibility.m b/WebDriverAgentLib/Utilities/FBXCodeCompatibility.m index e2fa950310..161be878cd 100644 --- a/WebDriverAgentLib/Utilities/FBXCodeCompatibility.m +++ b/WebDriverAgentLib/Utilities/FBXCodeCompatibility.m @@ -63,6 +63,11 @@ - (XCUIElementQuery *)fb_query @end +// Bounds the legacy testmanagerd protocol-version exchange below. A daemon that never replies +// (observed on some legacy configurations) must not be able to hang startup indefinitely; the +// value is diagnostic-only, so timing out and moving on is safe. +static const NSTimeInterval FBProtocolVersionExchangeTimeout = 30.0; + @implementation XCPointerEvent (FBXcodeCompatibility) + (BOOL)fb_areKeyEventsSupported @@ -85,12 +90,22 @@ NSInteger FBTestmanagerdVersion(void) id proxy = [FBXCTestDaemonsProxy testRunnerProxy]; if ([(NSObject *)proxy respondsToSelector:@selector(_XCT_exchangeProtocolVersion:reply:)]) { id legacyProxy = (id)proxy; - [FBRunLoopSpinner spinUntilCompletion:^(void(^completion)(void)){ + // The reply lands in a block-local so a late response (after the bounded wait below has + // given up and dispatch_once has completed) never writes the shared static while + // concurrent readers may be using it; late replies are simply ignored. + __block NSInteger exchangedVersion = 0; + BOOL exchanged = [FBRunLoopSpinner spinUntilCompletion:^(void(^completion)(void)){ [legacyProxy _XCT_exchangeProtocolVersion:testmanagerdVersion reply:^(unsigned long long code) { - testmanagerdVersion = (NSInteger) code; + exchangedVersion = (NSInteger) code; completion(); }]; - }]; + } timeout:FBProtocolVersionExchangeTimeout]; + if (exchanged) { + testmanagerdVersion = exchangedVersion; + } else { + [FBLogger log:@"Timed out waiting for the testmanagerd protocol version exchange"]; + // testmanagerdVersion stays at its default (diagnostic-only). + } } else { // Modern testmanagerd (Xcode 15+) has already negotiated named XCTCapabilities by the time // a daemon session exists, instead of a single scalar protocol version. There is no direct diff --git a/WebDriverAgentLib/Vendor/CocoaHTTPServer/HTTPServer.m b/WebDriverAgentLib/Vendor/CocoaHTTPServer/HTTPServer.m index 4a0ca7f3f8..26731df089 100644 --- a/WebDriverAgentLib/Vendor/CocoaHTTPServer/HTTPServer.m +++ b/WebDriverAgentLib/Vendor/CocoaHTTPServer/HTTPServer.m @@ -334,8 +334,11 @@ - (HTTPConfig *)config // // Try the apache benchmark tool (already installed on your Mac): // $ ab -n 1000 -c 1 http://localhost:/some_path.html - - return [[HTTPConfig alloc] initWithServer:self documentRoot:documentRoot queue:connectionQueue]; + + // Each connection gets its own dispatch queue (HTTPConnection creates one when the config + // carries none), so a request blocked on the automation queue cannot stall request parsing + // and responses for every other connection. + return [[HTTPConfig alloc] initWithServer:self documentRoot:documentRoot queue:NULL]; } - (void)socket:(GCDAsyncSocket *)sock didAcceptNewSocket:(GCDAsyncSocket *)newSocket diff --git a/WebDriverAgentTests/UnitTests/FBConfigurationTests.m b/WebDriverAgentTests/UnitTests/FBConfigurationTests.m index 47001e374c..18a8937ebc 100644 --- a/WebDriverAgentTests/UnitTests/FBConfigurationTests.m +++ b/WebDriverAgentTests/UnitTests/FBConfigurationTests.m @@ -69,4 +69,24 @@ - (void)testHttpRequestBodySizeLimitEnvironmentOverwrite XCTAssertEqual(FBConfiguration.sharedInstance.httpRequestBodySizeLimit, 1024ull); } +- (void)testEventSynthesisTimeoutMarginDefault +{ + unsetenv("EVENT_SYNTHESIS_TIMEOUT_MARGIN"); + XCTAssertEqualWithAccuracy([FBConfiguration.sharedInstance eventSynthesisTimeoutMargin], 15.0, 0.001); +} + +- (void)testEventSynthesisTimeoutMarginEnvOverride +{ + setenv("EVENT_SYNTHESIS_TIMEOUT_MARGIN", "42.5", 1); + XCTAssertEqualWithAccuracy([FBConfiguration.sharedInstance eventSynthesisTimeoutMargin], 42.5, 0.001); + unsetenv("EVENT_SYNTHESIS_TIMEOUT_MARGIN"); +} + +- (void)testEventSynthesisTimeoutMarginRejectsInvalidOverride +{ + setenv("EVENT_SYNTHESIS_TIMEOUT_MARGIN", "-3", 1); + XCTAssertEqualWithAccuracy([FBConfiguration.sharedInstance eventSynthesisTimeoutMargin], 15.0, 0.001); + unsetenv("EVENT_SYNTHESIS_TIMEOUT_MARGIN"); +} + @end diff --git a/WebDriverAgentTests/UnitTests/FBRouteTests.m b/WebDriverAgentTests/UnitTests/FBRouteTests.m index 5dede0c5f2..01e420e843 100644 --- a/WebDriverAgentTests/UnitTests/FBRouteTests.m +++ b/WebDriverAgentTests/UnitTests/FBRouteTests.m @@ -9,6 +9,8 @@ #import #import "FBRoute.h" +#import "FBResponsePayload.h" +#import "FBRouteRequest.h" @class RouteResponse; @@ -121,9 +123,204 @@ - (void)testEmptyRouteWithoutSessionWithSlash XCTAssertEqualObjects(route.path, @"/"); } +- (void)testControlQueueFlagDefaultsToNo +{ + FBRoute *route = [[FBRoute GET:@"/status"].withoutSession respondWithTarget:self action:@selector(description)]; + XCTAssertFalse(route.usesControlQueue); +} + +- (void)testOnControlQueueSurvivesRespondWithTarget +{ + FBRoute *route = [[[FBRoute GET:@"/status"].withoutSession onControlQueue] respondWithTarget:self action:@selector(description)]; + XCTAssertTrue(route.usesControlQueue); +} + +- (void)testOnControlQueueSurvivesRespondWithBlock +{ + FBRoute *route = [[[FBRoute POST:@"/probe"] onControlQueue] respondWithBlock:^ id (FBRouteRequest *request) { + return nil; + }]; + XCTAssertTrue(route.usesControlQueue); +} + + (id)dummyHandler:(FBRouteRequest *)request { return nil; } @end + +#import +#import "FBCommandHandler.h" +#import "FBWebServer.h" +#import "RoutingHTTPServer.h" + +static atomic_bool gControlProbeDone; +static atomic_bool gControlProbeRanOffMain; +static atomic_bool gAutomationProbeDone; +static atomic_bool gAutomationProbeRanOnMain; +static atomic_int gSpinningProbeDepth; +static atomic_int gSpinningProbeMaxDepth; +static atomic_int gSpinningProbeCompletions; + +@interface FBWebServer (DispatchTests) +- (void)registerRouteHandlers:(NSArray *)commandHandlerClasses; +- (RoutingHTTPServer *)server; +@property (nonatomic, strong) dispatch_queue_t automationQueue; +@end + +@interface FBDispatchProbeCommands : NSObject +@end + +@implementation FBDispatchProbeCommands + ++ (BOOL)shouldRegisterAutomatically +{ + return NO; +} + ++ (NSArray *)routes +{ + return @[ + [[[FBRoute GET:@"/probe/control"].withoutSession onControlQueue] respondWithBlock:^ id (FBRouteRequest *request) { + atomic_store(&gControlProbeRanOffMain, !NSThread.isMainThread); + atomic_store(&gControlProbeDone, true); + return FBResponseWithOK(); + }], + [[FBRoute GET:@"/probe/automation"].withoutSession respondWithBlock:^ id (FBRouteRequest *request) { + atomic_store(&gAutomationProbeRanOnMain, NSThread.isMainThread); + atomic_store(&gAutomationProbeDone, true); + return FBResponseWithOK(); + }], + // Not control-marked: goes through the automation funnel like any other automation route. + // Records the max nesting depth observed while it spins the main run loop, so a test can + // pin that a second concurrent automation request never executes reentrantly inside this one. + [[FBRoute GET:@"/probe/spinning"].withoutSession respondWithBlock:^ id (FBRouteRequest *request) { + int depth = atomic_fetch_add(&gSpinningProbeDepth, 1) + 1; + int prevMax = atomic_load(&gSpinningProbeMaxDepth); + while (depth > prevMax && !atomic_compare_exchange_weak(&gSpinningProbeMaxDepth, &prevMax, depth)) { + // retry until either our depth is recorded or another thread recorded a higher one + } + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.4]]; + atomic_fetch_sub(&gSpinningProbeDepth, 1); + atomic_fetch_add(&gSpinningProbeCompletions, 1); + return FBResponseWithOK(); + }], + ]; +} + +@end + +@interface FBWebServerDispatchTests : XCTestCase +@property (nonatomic, strong) FBWebServer *webServer; +@property (nonatomic, strong) RoutingHTTPServer *httpServer; +@property (nonatomic, assign) UInt16 port; +@end + +@implementation FBWebServerDispatchTests + +- (void)setUp +{ + [super setUp]; + atomic_store(&gControlProbeDone, false); + atomic_store(&gControlProbeRanOffMain, false); + atomic_store(&gAutomationProbeDone, false); + atomic_store(&gAutomationProbeRanOnMain, false); + atomic_store(&gSpinningProbeDepth, 0); + atomic_store(&gSpinningProbeMaxDepth, 0); + atomic_store(&gSpinningProbeCompletions, 0); + + self.webServer = [FBWebServer new]; + // startHTTPServer (unused by this test, see below) is normally what creates this; wire it + // up manually since automation routes now funnel through it. + self.webServer.automationQueue = dispatch_queue_create("com.facebook.WebDriverAgent.test-automation-funnel", DISPATCH_QUEUE_SERIAL); + self.httpServer = [RoutingHTTPServer new]; + // Inject the server so route registration can be exercised without booting the full agent + [self.webServer setValue:self.httpServer forKey:@"server"]; + [self.webServer registerRouteHandlers:@[FBDispatchProbeCommands.class]]; + [self.httpServer setPort:0]; + NSError *error; + XCTAssertTrue([self.httpServer start:&error], @"%@", error); + self.port = [self.httpServer listeningPort]; +} + +- (void)tearDown +{ + [self.httpServer stop:NO]; + self.httpServer = nil; + self.webServer = nil; + [super tearDown]; +} + +- (void)fireRequestForPath:(NSString *)path +{ + NSURL *url = [NSURL URLWithString:[NSString stringWithFormat:@"http://127.0.0.1:%d%@", self.port, path]]; + [[[NSURLSession sharedSession] dataTaskWithURL:url] resume]; +} + +- (void)testControlRouteRespondsWhileMainThreadIsBusy +{ + [self fireRequestForPath:@"/probe/control"]; + // Sleeping keeps the main thread (and thus the automation queue) busy without servicing + // the run loop — the control route must complete anyway, on another queue. + NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:5.0]; + while (!atomic_load(&gControlProbeDone) && deadline.timeIntervalSinceNow > 0) { + [NSThread sleepForTimeInterval:0.05]; + } + XCTAssertTrue(atomic_load(&gControlProbeDone)); + XCTAssertTrue(atomic_load(&gControlProbeRanOffMain)); +} + +- (void)testAutomationRouteRunsOnMainQueue +{ + [self fireRequestForPath:@"/probe/automation"]; + // Automation routes hop onto the main queue, so the run loop must be serviced + NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:5.0]; + while (!atomic_load(&gAutomationProbeDone) && deadline.timeIntervalSinceNow > 0) { + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.05]]; + } + XCTAssertTrue(atomic_load(&gAutomationProbeDone)); + XCTAssertTrue(atomic_load(&gAutomationProbeRanOnMain)); +} + +- (void)testControlRouteRespondsWhileAutomationRouteIsBlocked +{ + // The automation request will queue onto the main queue, which this test never services + // while asserting — simulating a busy/wedged automation queue. + [self fireRequestForPath:@"/probe/automation"]; + [NSThread sleepForTimeInterval:0.3]; + [self fireRequestForPath:@"/probe/control"]; + NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:5.0]; + while (!atomic_load(&gControlProbeDone) && deadline.timeIntervalSinceNow > 0) { + [NSThread sleepForTimeInterval:0.05]; + } + XCTAssertTrue(atomic_load(&gControlProbeDone), @"control route must answer while automation is blocked"); + XCTAssertFalse(atomic_load(&gAutomationProbeDone)); + // Drain the queued automation request so tearDown shuts down cleanly + deadline = [NSDate dateWithTimeIntervalSinceNow:5.0]; + while (!atomic_load(&gAutomationProbeDone) && deadline.timeIntervalSinceNow > 0) { + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.05]]; + } + XCTAssertTrue(atomic_load(&gAutomationProbeDone)); +} + +- (void)testAutomationRequestsDoNotNestInsideRunLoopSpin +{ + // Fire two automation requests close together. The first spins the main run loop inside its + // handler; without the automation funnel a nested run loop drain would let the second + // handler execute reentrantly inside the first (depth 2). With the funnel, the second + // request blocks on its own connection queue until the first finishes on main (depth 1). + [self fireRequestForPath:@"/probe/spinning"]; + [NSThread sleepForTimeInterval:0.1]; + [self fireRequestForPath:@"/probe/spinning"]; + + NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:10.0]; + while (atomic_load(&gSpinningProbeCompletions) < 2 && deadline.timeIntervalSinceNow > 0) { + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.05]]; + } + + XCTAssertEqual(atomic_load(&gSpinningProbeCompletions), 2, @"both spinning requests must complete"); + XCTAssertEqual(atomic_load(&gSpinningProbeMaxDepth), 1, @"a second automation request must never nest inside the first"); +} + +@end diff --git a/WebDriverAgentTests/UnitTests/FBRunLoopSpinnerTests.m b/WebDriverAgentTests/UnitTests/FBRunLoopSpinnerTests.m index da3c41552d..3b26ddc73a 100644 --- a/WebDriverAgentTests/UnitTests/FBRunLoopSpinnerTests.m +++ b/WebDriverAgentTests/UnitTests/FBRunLoopSpinnerTests.m @@ -95,4 +95,39 @@ - (void)testSpinUntilNotNilTimeout XCTAssertNotNil(error); } +- (void)testBoundedSpinReturnsYesWhenCompletionFires +{ + NSDate *start = [NSDate date]; + BOOL result = [FBRunLoopSpinner spinUntilCompletion:^(void (^completion)(void)) { + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.2 * NSEC_PER_SEC)), + dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0), completion); + } timeout:5.0]; + XCTAssertTrue(result); + XCTAssertLessThan([[NSDate date] timeIntervalSinceDate:start], 4.0); +} + +- (void)testBoundedSpinReturnsNoOnTimeout +{ + NSDate *start = [NSDate date]; + BOOL result = [FBRunLoopSpinner spinUntilCompletion:^(void (^completion)(void)) { + // The completion is intentionally never called + } timeout:0.5]; + XCTAssertFalse(result); + NSTimeInterval elapsed = [[NSDate date] timeIntervalSinceDate:start]; + XCTAssertGreaterThanOrEqual(elapsed, 0.5); + XCTAssertLessThan(elapsed, 3.0); +} + +- (void)testBoundedSpinToleratesLateCompletion +{ + __block void (^lateCompletion)(void) = nil; + BOOL result = [FBRunLoopSpinner spinUntilCompletion:^(void (^completion)(void)) { + lateCompletion = [completion copy]; + } timeout:0.2]; + XCTAssertFalse(result); + XCTAssertNotNil(lateCompletion); + // A completion arriving after the deadline must be a harmless no-op + lateCompletion(); +} + @end diff --git a/WebDriverAgentTests/UnitTests/FBVideoStreamSessionTests.m b/WebDriverAgentTests/UnitTests/FBVideoStreamSessionTests.m index 8ab51202e9..47134e8a31 100644 --- a/WebDriverAgentTests/UnitTests/FBVideoStreamSessionTests.m +++ b/WebDriverAgentTests/UnitTests/FBVideoStreamSessionTests.m @@ -9,6 +9,7 @@ #import #import "FBScrcpyPacket.h" +#import "FBVideoStreamSession.h" // Mirrors the wire constants consumed by ios-wired/cmd/scrcpy-bridge/h264reader.go. static const uint64_t kFlagConfig = (uint64_t)1 << 63; @@ -113,4 +114,98 @@ - (void)testPtsAndFlagsDoNotCollide XCTAssertEqual(pts, bigPts); } +- (void)testDefaultPixelBudgetForLegacyIPhones +{ + XCTAssertEqual([FBScreenCaptureConfiguration fb_defaultPixelBudgetForMachineModel:@"iPhone11,2"], (NSUInteger)370944); + XCTAssertEqual([FBScreenCaptureConfiguration fb_defaultPixelBudgetForMachineModel:@"iPhone11,8"], (NSUInteger)370944); + XCTAssertEqual([FBScreenCaptureConfiguration fb_defaultPixelBudgetForMachineModel:@"iPhone9,1"], (NSUInteger)370944); +} + +- (void)testDefaultPixelBudgetForModernOrUnknownModels +{ + XCTAssertEqual([FBScreenCaptureConfiguration fb_defaultPixelBudgetForMachineModel:@"iPhone12,1"], (NSUInteger)0); + XCTAssertEqual([FBScreenCaptureConfiguration fb_defaultPixelBudgetForMachineModel:@"iPhone17,3"], (NSUInteger)0); + XCTAssertEqual([FBScreenCaptureConfiguration fb_defaultPixelBudgetForMachineModel:@"iPad8,1"], (NSUInteger)0); + XCTAssertEqual([FBScreenCaptureConfiguration fb_defaultPixelBudgetForMachineModel:@"AppleTV11,1"], (NSUInteger)0); + XCTAssertEqual([FBScreenCaptureConfiguration fb_defaultPixelBudgetForMachineModel:@""], (NSUInteger)0); + XCTAssertEqual([FBScreenCaptureConfiguration fb_defaultPixelBudgetForMachineModel:@"iPhoneX"], (NSUInteger)0); +} + +- (void)testPixelBudgetClampPreservesAspectAndAlignment +{ + CGSize capped = [FBScreenCaptureConfiguration fb_sizeForWidth:562 height:1218 pixelBudget:370944]; + XCTAssertLessThanOrEqual(capped.width * capped.height, 370944.0); + XCTAssertEqualWithAccuracy(capped.width / capped.height, 562.0 / 1218.0, 0.02); + XCTAssertEqual(((NSUInteger)capped.width) % 2, (NSUInteger)0); + XCTAssertEqual(((NSUInteger)capped.height) % 2, (NSUInteger)0); + XCTAssertGreaterThan(capped.width, 0.0); +} + +- (void)testPixelBudgetLeavesSizesWithinBudgetAlone +{ + CGSize size = [FBScreenCaptureConfiguration fb_sizeForWidth:414 height:896 pixelBudget:370944]; + XCTAssertEqual(size.width, 414.0); + XCTAssertEqual(size.height, 896.0); +} + +- (void)testZeroPixelBudgetDisablesClamp +{ + CGSize size = [FBScreenCaptureConfiguration fb_sizeForWidth:5000 height:5000 pixelBudget:0]; + XCTAssertEqual(size.width, 5000.0); + XCTAssertEqual(size.height, 5000.0); +} + +- (void)testMachineModelIsNonNil +{ + XCTAssertNotNil([FBScreenCaptureConfiguration fb_machineModel]); +} + +- (void)testPixelBudgetClampSurvivesHugeDimensions +{ + NSUInteger huge = (NSUInteger)1 << 32; + CGSize capped = [FBScreenCaptureConfiguration fb_sizeForWidth:huge height:huge pixelBudget:370944]; + XCTAssertLessThanOrEqual(capped.width * capped.height, 370944.0); + XCTAssertGreaterThan(capped.width, 0.0); +} + +- (void)testPixelBudgetClampHonorsBudgetForSkinnyDimensions +{ + CGSize capped = [FBScreenCaptureConfiguration fb_sizeForWidth:10000 height:2 pixelBudget:100]; + XCTAssertLessThanOrEqual(capped.width * capped.height, 100.0); + XCTAssertGreaterThanOrEqual(capped.width, 2.0); + XCTAssertGreaterThanOrEqual(capped.height, 2.0); +} + +- (void)testPixelBudgetClampAtMinimumBudget +{ + CGSize capped = [FBScreenCaptureConfiguration fb_sizeForWidth:5000 height:5000 pixelBudget:4]; + XCTAssertEqual(capped.width, 2.0); + XCTAssertEqual(capped.height, 2.0); +} + +- (void)testPixelBudgetArgumentParsing +{ + NSUInteger budget = 99; + XCTAssertTrue([FBScreenCaptureConfiguration fb_pixelBudget:&budget fromArgument:nil deviceDefault:370944]); + XCTAssertEqual(budget, (NSUInteger)370944); + XCTAssertTrue([FBScreenCaptureConfiguration fb_pixelBudget:&budget fromArgument:@0 deviceDefault:370944]); + XCTAssertEqual(budget, (NSUInteger)0); + XCTAssertTrue([FBScreenCaptureConfiguration fb_pixelBudget:&budget fromArgument:@4 deviceDefault:0]); + XCTAssertEqual(budget, (NSUInteger)4); + XCTAssertTrue([FBScreenCaptureConfiguration fb_pixelBudget:&budget fromArgument:@370944.0 deviceDefault:0]); + XCTAssertEqual(budget, (NSUInteger)370944); +} + +- (void)testPixelBudgetArgumentParsingRejectsMalformedValues +{ + NSUInteger budget = 0; + XCTAssertFalse([FBScreenCaptureConfiguration fb_pixelBudget:&budget fromArgument:@"370944" deviceDefault:0]); + XCTAssertFalse([FBScreenCaptureConfiguration fb_pixelBudget:&budget fromArgument:@(-1) deviceDefault:0]); + XCTAssertFalse([FBScreenCaptureConfiguration fb_pixelBudget:&budget fromArgument:@(0.5) deviceDefault:0]); + XCTAssertFalse([FBScreenCaptureConfiguration fb_pixelBudget:&budget fromArgument:@(-0.5) deviceDefault:0]); + XCTAssertFalse([FBScreenCaptureConfiguration fb_pixelBudget:&budget fromArgument:@(2) deviceDefault:0]); + XCTAssertFalse([FBScreenCaptureConfiguration fb_pixelBudget:&budget fromArgument:@(NAN) deviceDefault:0]); + XCTAssertFalse([FBScreenCaptureConfiguration fb_pixelBudget:&budget fromArgument:@(INFINITY) deviceDefault:0]); +} + @end diff --git a/docs/mobilerun-actions.md b/docs/mobilerun-actions.md index d80ed26297..f3685a7b23 100644 --- a/docs/mobilerun-actions.md +++ b/docs/mobilerun-actions.md @@ -61,6 +61,11 @@ curl -X POST "$WDA/mobilerun/actions" -H 'Content-Type: application/json' \ - invalid argument — body is not a JSON array, an item has no/unknown `type`, a `pointerDown`/`pointerMove` lacks `x`/`y`, or a `pointerUp` has no preceding down. - unknown error — the event synthesizer rejected the record. +- `500 unknown error` is also returned when the synthesized event is not acknowledged by the + system within the action's own duration plus a safety margin (default 15 s; tune with the + `EVENT_SYNTHESIS_TIMEOUT_MARGIN` env var). This typically means the event delivery pipeline + is overloaded — the request fails, but the agent keeps serving; clients should treat it as + retryable or re-establish the runner. ## What it skips vs `/session/{id}/actions` diff --git a/docs/mobilerun-screencapture.md b/docs/mobilerun-screencapture.md index 1922e63baf..5024356a91 100644 --- a/docs/mobilerun-screencapture.md +++ b/docs/mobilerun-screencapture.md @@ -45,6 +45,7 @@ not a WDA automation session is active. | `bitrate` | int | no | `6000000` | Target average bits/sec. | | `quality` | float | no | `0.8` | JPEG quality (`0.0`–`1.0`) used for XCTest screenshot capture before local H.264/H.265 encoding. Lower values can reduce screenshot capture/decode cost. Does not affect ReplayKit/broadcast-source frames. | | `fps` | int | no | `30` | Capture/encode frame rate. | +| `maxPixels` | int | no | device-dependent | Upper bound on `width×height`. Larger requests are scaled down aspect-preserving (rounded down to even); the aspect ratio may be sacrificed for extreme aspect inputs where the minimum encodable size (2×2) would otherwise push the product back over budget. `0` disables the cap. The minimum accepted non-zero value is `4` (values `1`–`3` are rejected as `2×2 = 4` is the minimum encodable size). When omitted, devices with an A12 chip or older default to `370944` (≈414×896); newer devices are uncapped. | | `port` | int | no | auto | `0` or omitted → auto-assign from **9200** (env `SCREEN_CAPTURE_SERVER_PORT` overrides the base), scanning forward up to 64 ports. An explicit port (1–65535) is tried once and surfaces a bind failure. | ## Session object @@ -138,5 +139,11 @@ curl -s -X POST http://localhost:8100/mobilerun/screencapture/1/stop - If multiple screenshot-source sessions request different `quality` values, WDA captures the shared local screenshot frame at the lowest requested quality and fans it out to all local encoders. +- When the cap shrinks the request, the session object and the stream's `VIDEO_PARAMS` carry + the actual (capped) dimensions — consumers should always read those instead of assuming the + requested size. - For `scrcpy` framing you must parse the 12-byte header yourself (or reuse `ReadFrame` from `h264reader.go`); you can't pipe it straight into ffmpeg the way you can with `annexb`. +- Only the **sessionless** capture-control endpoints (stop / list / get / keyframe / broadcast + status — i.e. the URLs without `/session/{id}`) stay responsive while an automation command + is blocked; session-scoped URLs queue behind automation. diff --git a/docs/request-dispatch.md b/docs/request-dispatch.md new file mode 100644 index 0000000000..eaa221037b --- /dev/null +++ b/docs/request-dispatch.md @@ -0,0 +1,127 @@ +# Request dispatch, queues, and timeouts + +How WebDriverAgent executes HTTP requests, and the guarantees that keep the agent +responsive when the system is under heavy load. Applies to iOS and tvOS; watchOS keeps the +older single-queue model (see the end of this document). + +## Why this design + +Automation commands ultimately wait on out-of-process confirmations (testmanagerd +acknowledging a synthesized touch, an accessibility snapshot completing). Under heavy load +— most notably sustained high-resolution screen capture on older hardware — the system can +shed a synthesized HID event, and the confirmation for it never arrives. Historically that +single lost confirmation blocked the agent's main queue forever, and because every route +and every HTTP connection funneled through shared serial queues, it took down all +endpoints, including `/status`, until the agent was relaunched. + +The dispatch model below contains that failure to the single affected request: + +- every HTTP connection is independent, +- routes that never touch automation state stay responsive no matter what the automation + queue is doing, +- every event-synthesis wait has a deadline and fails with a `500` instead of hanging. + +## Connection model + +Each accepted HTTP connection gets its **own serial GCD queue** (the vendored +`HTTPServer` passes no shared queue in its `HTTPConfig`, so `HTTPConnection` creates one +per connection). Request parsing and response writing for one connection never block +another connection. + +## Route dispatch: control vs automation + +Routes are registered in `FBWebServer registerRouteHandlers:` and dispatched per route: + +- **Automation routes** (the default): the handler is executed on the **main queue** via + `dispatch_sync`, preserving the threading model XCUI code expects. Additionally, all + automation requests pass through a serial **funnel queue** first + (`dispatch_sync(automationQueue) { dispatch_sync(main) { … } }`). The funnel guarantees + at most one automation request is in flight: while one handler runs (possibly spinning + the main run loop while waiting on the system), the next request waits at the funnel + instead of being enqueued to the main queue, where a nested run-loop drain would + otherwise execute it reentrantly inside the first handler. +- **Control routes**: marked with the chainable `.onControlQueue` modifier on `FBRoute`. + Their handlers run **inline on the connection's own queue** and therefore keep working + even while an automation request is blocked or wedged. + +### What qualifies as a control route + +A route may opt into `.onControlQueue` only if its handler: + +1. never calls XCUI / testmanagerd APIs, and +2. only touches state that is safe to read off the main queue (lock-protected, atomic, or + immutable), and +3. does not require a WebDriver session — session lookup/decoration is main-queue state, + so only `.withoutSession` variants qualify. + +Currently marked: `GET /status`, the sessionless screen-capture control endpoints +(`POST /mobilerun/screencapture/stop`, `GET /mobilerun/screencapture`, +`GET /mobilerun/screencapture/:id`, `POST /mobilerun/screencapture/:id/stop`, +`POST /mobilerun/screencapture/:id/keyframe`, `GET /mobilerun/screencapture/broadcast`), +and the unknown-endpoint fallback. `/health`, `/calibrate`, and `/wda/shutdown` are +registered directly on the server and likewise run on the connection queue +(`/wda/shutdown` responds first and hops to the main queue asynchronously for the actual +teardown). + +Deliberately **not** control routes: + +- `POST /mobilerun/screencapture/start` (reads `XCUIScreen`), +- the broadcast start/stop endpoints (drive the system broadcast picker through XCUI), +- `/mobilerun/state` — it is the intended **liveness probe**: because it runs on the + automation queue and does real accessibility work, it reflects a wedged automation queue + by timing out, while `/status` stays green as a process-liveness signal. + +### Thread-safety notes for control handlers + +State read by control handlers is protected accordingly: the active-session singleton is +accessed through synchronized accessors (response envelopes read it for `sessionId`), the +video stream manager guards its session table with a lock and makes capture *stop-all* a +lifecycle barrier (a capture start in flight across a stop-all aborts instead of +resurrecting a session afterwards), and the broadcast manager's state, including its +control-server reference, is atomic. + +## Bounded event synthesis + +All touch/typing synthesis funnels through +`FBXCTestDaemonsProxy synthesizeEventWithRecord:error:` (used by `/mobilerun/actions`, +W3C `/actions`, and typing). The wait for the system's acknowledgement is bounded: + +``` +timeout = event record duration (maximumOffset) + margin +``` + +The margin defaults to **15 seconds** and can be tuned with the +`EVENT_SYNTHESIS_TIMEOUT_MARGIN` environment variable (a positive number of seconds; see +`FBConfiguration eventSynthesisTimeoutMargin`). Quick taps therefore fail fast while long +W3C action chains still fit their own duration. + +On timeout the request fails with a `500` ("The synthesized event was not acknowledged +within N seconds…"), the agent keeps serving, and a confirmation that arrives late is +ignored. Clients should treat the error as retryable or recycle the agent. Native +`XCUIElement` gesture endpoints (element tap/swipe/pinch) use XCTest-internal waits and +are not covered by this bound. + +## Startup and shutdown ordering + +`FBWebServer startServing` binds the HTTP server first, then initializes the capture +broadcasters, arms the keep-alive flag, and only then warms the `dispatch_once`-backed +`/status` values (`FBSDKVersion()`, `FBTestmanagerdVersion()`) on the main thread — the +legacy testmanagerd protocol exchange inside that warm-up is itself bounded (30 s), and +late replies are discarded rather than published. Consequences: + +- a degraded daemon cannot prevent the server from binding — `/health` and control routes + come up regardless; +- a `/wda/shutdown` that arrives while the warm-up is still waiting is honored: the + serving loop checks the keep-alive flag after the warm-up and exits instead of starting. + +## Capture pixel budget + +As a load-prevention measure, `POST /mobilerun/screencapture/start` clamps the requested +capture size to a pixel budget (explicit `maxPixels` argument, or a device-class default +on older hardware). See [mobilerun-screencapture.md](mobilerun-screencapture.md) for the +API details. + +## watchOS + +`FBWatchHTTPServer` keeps the pre-existing model: a single global route queue (the main +queue), no per-route split, no funnel.