From 2838c5bd27655c87d67ec19be88e8e48a9678e53 Mon Sep 17 00:00:00 2001 From: timo <44401485+Timo972@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:15:20 +0200 Subject: [PATCH 01/14] docs: design spec for runner reliability under capture load Co-Authored-By: Claude Fable 5 --- ...-runner-reliability-capture-load-design.md | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-19-runner-reliability-capture-load-design.md diff --git a/docs/superpowers/specs/2026-08-19-runner-reliability-capture-load-design.md b/docs/superpowers/specs/2026-08-19-runner-reliability-capture-load-design.md new file mode 100644 index 0000000000..b9eba586ff --- /dev/null +++ b/docs/superpowers/specs/2026-08-19-runner-reliability-capture-load-design.md @@ -0,0 +1,149 @@ +# Runner reliability under capture load — design + +Date: 2026-08-19 + +## Problem + +On lower-end devices (A12 and older), heavy screen-capture load (ReplayKit broadcast + +high-resolution hardware encoding) can make backboardd shed synthesized HID events +(`kIOReturnNoMemory` enqueue drops). When a synthesized touch event is dropped, +testmanagerd never delivers the `TouchEventsCompleted` confirmation, and the runner-side +wait blocks forever: + +- `FBXCTestDaemonsProxy synthesizeEventWithRecord:` waits via + `+[FBRunLoopSpinner spinUntilCompletion:]`, which has **no timeout** and spins the main + run loop indefinitely. +- Every WDA route is `dispatch_sync`ed onto the **main queue** + (`[FBWebServer startHTTPServer]` sets `routeQueue` to the main queue), so one lost + completion permanently blocks all automation endpoints. +- All HTTP connections share **one serial socket queue** (`HTTPServer.m` creates a single + `connectionQueue` passed to every `HTTPConnection`), so the blocked `dispatch_sync` + also prevents parsing of any further request on any connection — even `/status` and + keyframe requests wedge. The runner never self-recovers. + +The fix has three independent parts: keep the HTTP layer responsive (queue split), make +lost waits fail a single request instead of the whole agent (bounded waits), and reduce +the capture load that triggers event shedding in the first place (capture pixel cap). + +## Goals + +- A lost/wedged XCUI synthesis wait fails that one request with a 5xx; `/status`, + keyframe, and subsequent requests keep working. +- Control endpoints stay responsive even while an automation request is blocked. +- Capture requests are capped to a safe pixel budget on older chips by default, with an + explicit API override. + +## Non-goals + +- Portal-side recovery orchestration (separate component). +- Bounding native `XCUIElement` gesture waits (element tap/swipe/pinch go through + XCTest-internal wait machinery, not our spinner; unchanged). +- MJPEG server changes (already runs on its own socket/queues). + +## Part 1 — HTTP layer: per-connection queues + control-route split + +### Per-connection socket queues + +`HTTPServer` currently creates one serial `connectionQueue` and hands it to every +connection via `HTTPConfig`. Change the vendored `HTTPServer` to pass a nil queue so each +`HTTPConnection` creates its own queue (this is stock CocoaHTTPServer behavior when no +queue is provided). One blocked request then only stalls its own TCP connection. + +### Route dispatch split + +Remove the global `setRouteQueue(main)`. Dispatch per route inside +`-[FBWebServer registerRouteHandlers:]`: + +- **Automation routes** (default): `dispatch_sync` onto the main queue — semantics + identical to today for everything that touches XCUI / testmanagerd. +- **Control routes**: run inline on the connection's own queue. Marked with a new + chainable `FBRoute` flag (`.onControlQueue`). Only routes whose handlers never touch + XCUI and whose backing state is thread-safe qualify: + - `/status` (reads bundle/env/UIDevice info only) + - `/health`, `/calibrate`, `/wda/shutdown` (registered directly on the server; run on + the connection queue automatically once no global route queue is set — the shutdown + delegate hop must be audited for thread safety) + - `/mobilerun/screencapture` family: start / stop / stop-all / list / get / keyframe + (`FBVideoStreamManager` is `@synchronized`-guarded and does its work on its own + background queue) + - `GET /mobilerun/screencapture/broadcast` (status read; `FBBroadcastManager` state + reads must be audited/made atomic) + +Broadcast **start/stop** stay on the main queue: they drive the system broadcast picker +through XCUI. `/mobilerun/state` also stays on the main queue **deliberately**: it is the +liveness probe that must reflect a wedged automation queue by timing out or erroring. + +## Part 2 — Bounded synthesis waits + +- Add `+[FBRunLoopSpinner spinUntilCompletion:timeout:]` returning `BOOL` (`NO` when the + deadline passes before the completion fires). The existing no-timeout variant remains + for callers not yet migrated. +- `+[FBXCTestDaemonsProxy synthesizeEventWithRecord:error:]` computes + `timeout = record.maximumOffset + margin`: + - `maximumOffset` is the total scheduled duration of the synthesized event record, so + quick taps get a short deadline while long W3C action chains still fit. + - `margin` is a new `FBConfiguration` property (default **15 s**), overridable via env + var so it can be tuned without rebuilding. +- On timeout the method fails with a descriptive `NSError`; command handlers already map + that to a 5xx (`FBResponseWithUnknownError`). The portal treats 5xx as + relaunch-worthy, which is the desired escalation. +- A late completion after the deadline is harmless: the completion block only flips a + heap-allocated atomic flag that nobody reads anymore. +- No attempt is made to "clean up" a possibly stuck touch (e.g. down without up); after + a synthesis failure the client is expected to recover the session. +- Covered call sites (all funnel through this one proxy method): `/mobilerun/actions`, + W3C `/actions`, and typing (`XCUIElement+FBTyping` / `FBKeyboard`). + +## Part 3 — Capture pixel cap + +- `POST /mobilerun/screencapture/start` gains an optional integer argument + `maxPixels` (`0` = explicitly uncapped). +- When `width × height > maxPixels`, WDA scales the requested dimensions down + aspect-preserving (`scale = sqrt(maxPixels / (w·h))`), rounding to even values (HW + encoder requirement). +- When `maxPixels` is absent, a device-class default applies: + - A12-and-older chips → **370 944 px** (equivalent to 414×896, a budget verified safe + for sustained 60 fps HEVC capture on that hardware class). + - Newer chips → uncapped. + - Detection via `hw.machine` sysctl: iPhone models with a major version of 11 or + lower (`iPhone11,*` = A12) are classified A12-and-older. Non-iPhone and unknown + models are treated as uncapped — only this hardware class has shown HID event + shedding, and mis-capping newer devices would silently degrade capture quality. +- fps is not touched: the pixel budget alone is what distinguishes the safe from the + wedging configuration on the affected hardware class. +- The clamped dimensions flow through the existing config → session → `SESSION_ADD` + path, and the actual size is already reported back via the session dictionary and the + stream's `VIDEO_PARAMS`, so consumers adapt without changes. + +## Files touched (expected) + +- `WebDriverAgentLib/Vendor/CocoaHTTPServer/HTTPServer.m` — per-connection queues +- `WebDriverAgentLib/Routing/FBWebServer.m` — remove global route queue, per-route dispatch +- `WebDriverAgentLib/Routing/FBRoute.{h,m}` — `.onControlQueue` chainable flag +- `WebDriverAgentLib/Utilities/FBRunLoopSpinner.{h,m}` — timeout variant +- `WebDriverAgentLib/Utilities/FBXCTestDaemonsProxy.m` — bounded synthesis wait +- `WebDriverAgentLib/Utilities/FBConfiguration.{h,m}` — synthesis margin property +- `WebDriverAgentLib/Commands/FBScreenCaptureCommands.m` — `maxPixels` argument +- New helper for device-class detection + clamp math (unit-testable pure functions) +- `docs/mobilerun-screencapture.md`, `docs/mobilerun-actions.md` — API docs + +## Error handling + +- Synthesis timeout → `NSError` → 5xx on the single request; agent keeps serving. +- Capture clamp never fails a request: it only shrinks dimensions (invalid `maxPixels` + values, e.g. negative or non-numeric, are rejected as `invalid argument`). +- Queue split changes no response semantics; control handlers must not throw XCUI-related + exceptions since they never call XCUI. + +## Testing + +- **Unit** (`UnitTests` target): spinner timeout (fires/expires), clamp math (aspect + ratio, even alignment, no-op when under budget), device-model→budget mapping with + injected model strings. +- **Integration** (simulator): with the server running, saturate the main queue with a + long-running block and assert control routes (`/status`, screencapture list) still + answer while an automation route blocks; assert a synthesis wait that never completes + returns a 5xx within its deadline. +- **On-device validation** (manual, fleet): sustained broadcast capture + continuous + automation on an A12 device without a wedge; wedge injection recovers per request + instead of killing the runner. From 6920cc2d7e05d15d5da19c0ecb5b518d99c4ff92 Mon Sep 17 00:00:00 2001 From: timo <44401485+Timo972@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:29:35 +0200 Subject: [PATCH 02/14] docs: implementation plan for runner reliability under capture load Co-Authored-By: Claude Fable 5 --- ...6-08-19-runner-reliability-capture-load.md | 984 ++++++++++++++++++ ...-runner-reliability-capture-load-design.md | 11 +- 2 files changed, 992 insertions(+), 3 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-19-runner-reliability-capture-load.md diff --git a/docs/superpowers/plans/2026-08-19-runner-reliability-capture-load.md b/docs/superpowers/plans/2026-08-19-runner-reliability-capture-load.md new file mode 100644 index 0000000000..408bf80fac --- /dev/null +++ b/docs/superpowers/plans/2026-08-19-runner-reliability-capture-load.md @@ -0,0 +1,984 @@ +# Runner Reliability Under Capture Load Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Keep the WDA runner serving requests when the system drops synthesized touch events under heavy capture load: bounded synthesis waits (single request fails 5xx), control routes that bypass the automation queue, and a capture pixel cap for older devices. + +**Architecture:** Three independent changes to existing files. (1) `FBRunLoopSpinner` gains a bounded completion-spin used by `FBXCTestDaemonsProxy synthesizeEventWithRecord:` with a duration-derived deadline. (2) The HTTP layer stops funneling everything through one shared connection queue + a global main route queue: each connection gets its own queue, and routes marked "control" run inline on it while all other routes `dispatch_sync` to the main queue exactly as today. (3) `/mobilerun/screencapture/start` clamps requested dimensions to a pixel budget (explicit `maxPixels` argument, or a device-class default for A12-and-older iPhones). + +**Tech Stack:** Objective-C, XCTest unit tests (`UnitTests` bundle), xcodebuild against an iOS simulator. + +**Spec:** `docs/superpowers/specs/2026-08-19-runner-reliability-capture-load-design.md` + +## Global Constraints + +- **No new source or test files.** All code goes into existing files so `project.pbxproj` is never touched (Xcode reorders it and wiring is error-prone). New unit tests join existing test-case files; a second `XCTestCase` class in an existing file is fine. +- **All platforms must compile:** CI builds iOS, tvOS, and watchOS. The watchOS path (`TARGET_OS_WATCH`) keeps today's behavior (`FBWatchHTTPServer` + main route queue); only the `RoutingHTTPServer` path changes. +- **Do not modify copyright headers** of edited files. +- **Public repo hygiene:** commit messages describe the mechanism generically. Never mention fleet hosts, device serials, internal recovery tooling, or reproduction infrastructure. +- **Env var naming:** bare uppercase style matching existing vars (e.g. `MAX_HTTP_REQUEST_BODY_SIZE`) — the new var is `EVENT_SYNTHESIS_TIMEOUT_MARGIN`. +- **Unit test command** (pick an available iPhone simulator via `xcrun simctl list devices available | grep iPhone`): + ```bash + xcodebuild test -project WebDriverAgent.xcodeproj -scheme WebDriverAgentRunner \ + -destination 'platform=iOS Simulator,name=iPhone 17' \ + -only-testing:UnitTests/ CODE_SIGNING_ALLOWED=NO + ``` + Drop `/` to run the whole bundle. First runs are slow (build); later runs are incremental. +- The working branch is `timo/dro-2713-wda-runner-reliability-under-capture-load` (already created from origin/master). + +--- + +### Task 1: Bounded run-loop spin (`FBRunLoopSpinner`) + +**Files:** +- Modify: `WebDriverAgentLib/Utilities/FBRunLoopSpinner.h` +- Modify: `WebDriverAgentLib/Utilities/FBRunLoopSpinner.m` +- Test: `WebDriverAgentTests/UnitTests/FBRunLoopSpinnerTests.m` + +**Interfaces:** +- Consumes: nothing new. +- Produces: `+ (BOOL)spinUntilCompletion:(void (^)(void(^completion)(void)))block timeout:(NSTimeInterval)timeout;` — returns `YES` when `completion` fired before the deadline, `NO` on timeout. Task 2 calls this. + +- [ ] **Step 1: Write the failing tests** + +Append to `WebDriverAgentTests/UnitTests/FBRunLoopSpinnerTests.m` (inside the existing `FBRunLoopSpinnerTests` implementation, before `@end`): + +```objc +- (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(); +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `xcodebuild test ... -only-testing:UnitTests/FBRunLoopSpinnerTests CODE_SIGNING_ALLOWED=NO` (full command from Global Constraints) +Expected: BUILD FAILURE — `no known class method for selector 'spinUntilCompletion:timeout:'` (a compile error is the RED state here). + +- [ ] **Step 3: Implement the bounded spin** + +In `FBRunLoopSpinner.h`, after the existing `spinUntilCompletion:` declaration: + +```objc +/** + 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; +``` + +In `FBRunLoopSpinner.m`, replace the existing `+spinUntilCompletion:` implementation with a delegating pair: + +```objc ++ (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; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: same command as Step 2. +Expected: all `FBRunLoopSpinnerTests` PASS (including the three pre-existing tests — the no-timeout delegation must not regress them). + +- [ ] **Step 5: Commit** + +```bash +git add WebDriverAgentLib/Utilities/FBRunLoopSpinner.h WebDriverAgentLib/Utilities/FBRunLoopSpinner.m WebDriverAgentTests/UnitTests/FBRunLoopSpinnerTests.m +git commit -m "feat: add bounded variant of the run loop completion spinner" +``` + +--- + +### Task 2: Bounded event synthesis (`FBConfiguration` margin + `FBXCTestDaemonsProxy`) + +**Files:** +- Modify: `WebDriverAgentLib/Utilities/FBConfiguration.h` +- Modify: `WebDriverAgentLib/Utilities/FBConfiguration.m` +- Modify: `WebDriverAgentLib/Utilities/FBXCTestDaemonsProxy.m` (`synthesizeEventWithRecord:error:`) +- Modify: `docs/mobilerun-actions.md` +- Test: `WebDriverAgentTests/UnitTests/FBConfigurationTests.m` + +**Interfaces:** +- Consumes: `+[FBRunLoopSpinner spinUntilCompletion:timeout:]` from Task 1. +- Produces: `- (NSTimeInterval)eventSynthesisTimeoutMargin;` on `FBConfiguration` (instance method on the shared singleton, like `httpRequestBodySizeLimit`). No later task depends on this; it completes the "survive" wait-bounding. + +- [ ] **Step 1: Write the failing tests** + +Append inside the existing `FBConfigurationTests` implementation in `WebDriverAgentTests/UnitTests/FBConfigurationTests.m`: + +```objc +- (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"); +} +``` + +Note: `FBConfiguration.sharedInstance` is how config is accessed in this fork (singleton since the upstream v16.2 merge). If `FBConfigurationTests.m` already manipulates env vars differently, follow its local pattern for set/unset but keep the assertions. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `xcodebuild test ... -only-testing:UnitTests/FBConfigurationTests CODE_SIGNING_ALLOWED=NO` +Expected: BUILD FAILURE — `no visible @interface for 'FBConfiguration' declares the selector 'eventSynthesisTimeoutMargin'`. + +- [ ] **Step 3: Implement the margin property** + +`FBConfiguration.h` — add near the other timeout-ish instance methods (e.g. next to `httpRequestBodySizeLimit`): + +```objc +/** + 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; +``` + +`FBConfiguration.m` — add next to `httpRequestBodySizeLimit` (uses `getenv` rather than `NSProcessInfo` so the value is not frozen at first access — `NSProcessInfo.environment` caches, which would break both env-based tests and runtime tuning): + +```objc +static const NSTimeInterval DefaultEventSynthesisTimeoutMargin = 15.0; + +- (NSTimeInterval)eventSynthesisTimeoutMargin +{ + const char *rawMargin = getenv("EVENT_SYNTHESIS_TIMEOUT_MARGIN"); + if (rawMargin != NULL) { + double parsedMargin = atof(rawMargin); + if (parsedMargin > 0) { + return parsedMargin; + } + } + return DefaultEventSynthesisTimeoutMargin; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: same command as Step 2. Expected: PASS (all of `FBConfigurationTests`). + +- [ ] **Step 5: Bound the synthesis wait** + +In `WebDriverAgentLib/Utilities/FBXCTestDaemonsProxy.m`: + +Add to the imports (it is not imported today; the type currently arrives via the header's forward declaration): + +```objc +#import "XCSynthesizedEventRecord.h" +``` + +Replace the body of `+ (BOOL)synthesizeEventWithRecord:(XCSynthesizedEventRecord *)record error:(NSError *__autoreleasing*)error` with: + +```objc + __block NSError *innerError = nil; + // 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; + } + completion(); + }; + + void (^handlerBlock)(XCSynthesizedEventRecord *, NSError *) = ^(XCSynthesizedEventRecord *innerRecord, NSError *invokeError) { + errorHandler(invokeError); + }; + [[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; + } + return NO; + } + return YES; +``` + +(Only the wrapping changed: `spinUntilCompletion:` → bounded variant + the `didComplete` check. The inner blocks are byte-identical to today's.) + +- [ ] **Step 6: Verify the library still compiles and the spinner/config tests still pass** + +Run: `xcodebuild test ... -only-testing:UnitTests/FBRunLoopSpinnerTests -only-testing:UnitTests/FBConfigurationTests CODE_SIGNING_ALLOWED=NO` +Expected: BUILD OK, all listed tests PASS. (The synthesize path itself needs a real testmanagerd and is exercised by the existing integration suites + on-device validation, not unit tests.) + +- [ ] **Step 7: Document the behavior** + +In `docs/mobilerun-actions.md`, the `## Responses` section (starts line ~58) documents error responses. Append this bullet to that section: + +```markdown +- `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. +``` + +- [ ] **Step 8: Commit** + +```bash +git add WebDriverAgentLib/Utilities/FBConfiguration.h WebDriverAgentLib/Utilities/FBConfiguration.m \ + WebDriverAgentLib/Utilities/FBXCTestDaemonsProxy.m WebDriverAgentTests/UnitTests/FBConfigurationTests.m \ + docs/mobilerun-actions.md +git commit -m "feat: fail unacknowledged event synthesis with an error instead of blocking forever" +``` + +--- + +### Task 3: `FBRoute` control-queue flag + +**Files:** +- Modify: `WebDriverAgentLib/Routing/FBRoute.h` +- Modify: `WebDriverAgentLib/Routing/FBRoute.m` +- Test: `WebDriverAgentTests/UnitTests/FBRouteTests.m` + +**Interfaces:** +- Consumes: nothing new. +- Produces: `@property (nonatomic, assign, readonly) BOOL usesControlQueue;` and chainable `- (instancetype)onControlQueue;` on `FBRoute`. The flag must survive `withoutSession` and both `respondWithTarget:action:` / `respondWithBlock:` (those constructors create a **new** route object — the flag must be copied over, exactly like `requiresSession` is today). Task 4 reads `route.usesControlQueue`. + +- [ ] **Step 1: Write the failing tests** + +Append inside the existing `FBRouteTests` implementation in `WebDriverAgentTests/UnitTests/FBRouteTests.m`: + +```objc +- (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); +} +``` + +If `FBRouteTests.m` does not already import `FBResponsePayload.h`, add `#import "FBResponsePayload.h"` and `#import "FBRouteRequest.h"` next to the existing imports. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `xcodebuild test ... -only-testing:UnitTests/FBRouteTests CODE_SIGNING_ALLOWED=NO` +Expected: BUILD FAILURE — `property 'usesControlQueue' not found on object of type 'FBRoute *'`. + +- [ ] **Step 3: Implement the flag** + +`FBRoute.h` — add below the existing `path` property and next to the other chainable (`withoutSession` is declared further down; keep declarations adjacent to their kin): + +```objc +/*! 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; +``` + +and next to the `withoutSession` declaration: + +```objc +/** + 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; +``` + +`FBRoute.m`: + +1. In the class extension at the top, add: + ```objc + @property (nonatomic, assign, readwrite) BOOL usesControlQueue; + ``` +2. Next to `- (instancetype)withoutSession`, add: + ```objc + - (instancetype)onControlQueue + { + self.usesControlQueue = YES; + return self; + } + ``` +3. In `respondWithBlock:` and `respondWithTarget:action:`, copy the flag onto the newly created route (both methods build a fresh `FBRoute_Sync` / `FBRoute_TargetAction`): + ```objc + route.usesControlQueue = self.usesControlQueue; + ``` + placed right after the existing property assignments in each method. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: same command as Step 2. Expected: all `FBRouteTests` PASS. + +- [ ] **Step 5: Commit** + +```bash +git add WebDriverAgentLib/Routing/FBRoute.h WebDriverAgentLib/Routing/FBRoute.m WebDriverAgentTests/UnitTests/FBRouteTests.m +git commit -m "feat: allow marking routes to be served off the automation queue" +``` + +--- + +### Task 4: HTTP layer split (per-connection queues + per-route dispatch) + +**Files:** +- Modify: `WebDriverAgentLib/Vendor/CocoaHTTPServer/HTTPServer.m` (the `config` method, currently returning `[[HTTPConfig alloc] initWithServer:self documentRoot:documentRoot queue:connectionQueue]` around line 338) +- Modify: `WebDriverAgentLib/Routing/FBWebServer.m` +- Modify: `WebDriverAgentLib/Commands/FBUnknownCommands.m` +- Modify: `WebDriverAgentLib/Commands/FBSessionCommands.m` (the `/status` route registration) +- Modify: `WebDriverAgentLib/Commands/FBScreenCaptureCommands.m` (route registrations) +- Test: `WebDriverAgentTests/UnitTests/FBRouteTests.m` (add a **second test-case class** `FBWebServerDispatchTests` in the same file — no new file, to avoid pbxproj churn) + +**Interfaces:** +- Consumes: `route.usesControlQueue` from Task 3. +- Produces: no new API. Behavioral contract for later tasks and the portal: control-marked routes respond while the main queue is busy; all other routes keep main-queue semantics. + +- [ ] **Step 1: Write the failing dispatch tests** + +Append to `WebDriverAgentTests/UnitTests/FBRouteTests.m` (after the `FBRouteTests` `@end`, as a separate test-case class): + +```objc +#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; + +@interface FBWebServer (DispatchTests) +- (void)registerRouteHandlers:(NSArray *)commandHandlerClasses; +- (RoutingHTTPServer *)server; +@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(); + }], + ]; +} + +@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); + + self.webServer = [FBWebServer new]; + 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)); +} + +@end +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `xcodebuild test ... -only-testing:UnitTests/FBWebServerDispatchTests CODE_SIGNING_ALLOWED=NO` +Expected: `testControlRouteRespondsWhileMainThreadIsBusy` and `testControlRouteRespondsWhileAutomationRouteIsBlocked` FAIL (today every route is dispatched to the main queue, which the sleep-poll never services; the requests are also serialized behind the shared connection queue). `testAutomationRouteRunsOnMainQueue` may already pass — that is expected: it pins today's semantics so the refactor cannot regress them. + +- [ ] **Step 3: Implement per-connection queues** + +In `WebDriverAgentLib/Vendor/CocoaHTTPServer/HTTPServer.m`, find the `config` method (returns the `HTTPConfig` with `queue:connectionQueue`) and change it to pass no queue: + +```objc +- (HTTPConfig *)config +{ + // Override me if you want to provide a custom config to the new connection. + // + // Generally this involves overriding the HTTPConfig class to include any custom settings, + // and then having this method return an instance of 'MyHTTPConfig'. + + // Note: Think you can make the server faster by putting each connection on its own queue? + // Then benchmark it before and after and discover for yourself the shocking truth! + // + // Try the apache benchmark tool (already installed on your Mac): + // $ ab -n 1000 -c 1 http://localhost:/some_path.html + + // 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]; +} +``` + +(Keep the surrounding comments if they differ slightly — the functional change is `queue:connectionQueue` → `queue:NULL`. Everything else about `connectionQueue` in that file stays: it is still used for the server's own bookkeeping.) + +- [ ] **Step 4: Implement per-route dispatch in FBWebServer** + +In `WebDriverAgentLib/Routing/FBWebServer.m`: + +1. In `startHTTPServer`, restrict the global route queue to watchOS (the `FBWatchHTTPServer` keeps today's behavior; `RoutingHTTPServer` now gets per-route dispatch): + + ```objc + #if TARGET_OS_WATCH + [self.server setRouteQueue:dispatch_get_main_queue()]; + #endif + ``` + + (replacing the unconditional `[self.server setRouteQueue:dispatch_get_main_queue()];`) + +2. Replace the registered block's mount portion in `registerRouteHandlers:` — the block body after `[FBLogger verboseLog:routeParams.description];` currently is: + + ```objc + @try { + [route mountRequest:routeParams intoResponse:response]; + } + @catch (NSException *exception) { + [strongSelf handleException:exception forResponse:response]; + } + ``` + + Replace it with: + + ```objc + #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 { + dispatch_sync(dispatch_get_main_queue(), ^{ + @autoreleasepool { + [strongSelf mountRoute:route request:routeParams intoResponse:response]; + } + }); + } + #endif + ``` + +3. Add the extracted mount helper next to `handleException:forResponse:`: + + ```objc + - (void)mountRoute:(FBRoute *)route request:(FBRouteRequest *)routeParams intoResponse:(RouteResponse *)response + { + @try { + [route mountRequest:routeParams intoResponse:response]; + } + @catch (NSException *exception) { + [self handleException:exception forResponse:response]; + } + } + ``` + + `FBRoute` is already visible via `FBCommandHandler.h`/route usage; add `#import "FBRoute.h"` to the imports if the compiler complains. + +4. In `registerServerKeyRouteHandlers`, the `/wda/shutdown` block currently calls the delegate inline. With no global route queue that block now runs on a connection queue while the delegate tears down XCUI state — hop the delegate call to the main queue asynchronously so the response is not held hostage by a busy automation queue: + + ```objc + [self.server get:@"/wda/shutdown" withBlock:^(RouteRequest *request, RouteResponse *response) { + __strong typeof(weakSelf) strongSelf = weakSelf; + if (nil == strongSelf) { + return; + } + [response respondWithString:@"Shutting down"]; + // 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]; + }); + }]; + ``` + + (`/health` and `/calibrate` need no change — they respond with static strings and are safe on the connection queue.) + +- [ ] **Step 5: Mark the control routes** + +1. `WebDriverAgentLib/Commands/FBUnknownCommands.m` — the fallback handler only builds an error payload; keep it responsive during a wedge. All four routes become: + + ```objc + [[[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:)] + ``` + +2. `WebDriverAgentLib/Commands/FBSessionCommands.m` — `/status` reads only bundle/env/UIDevice info and socket interfaces; mark it: + + ```objc + [[[FBRoute GET:@"/status"].withoutSession onControlQueue] respondWithTarget:self action:@selector(handleGetStatus:)], + ``` + +3. `WebDriverAgentLib/Commands/FBScreenCaptureCommands.m` — mark the pure video-manager routes (both the session and sessionless variants): `POST .../broadcast/stop` and `POST .../broadcast/start` **stay unmarked** (they drive the system broadcast picker via XCUI), and `POST /mobilerun/screencapture/start` **stays unmarked** (it touches `XCUIScreen`). Mark these with `onControlQueue` (wrap each existing registration as `[[[FBRoute ...] ...] onControlQueue]` keeping `withoutSession` where present): + - `GET /mobilerun/screencapture/broadcast` (status read) + - `POST /mobilerun/screencapture/stop` + - `GET /mobilerun/screencapture` + - `GET /mobilerun/screencapture/:id` + - `POST /mobilerun/screencapture/:id/stop` + - `POST /mobilerun/screencapture/:id/keyframe` + + Example for one line: + + ```objc + [[[FBRoute POST:@"/mobilerun/screencapture/:id/keyframe"] onControlQueue] respondWithTarget:self action:@selector(handleRequestKeyFrame:)], + ``` + + Rationale to keep in mind (not as comments on every line): these handlers only touch `FBVideoStreamManager` / `FBBroadcastManager`, which are `@synchronized`- and serial-queue-protected and already run capture work off-main in production. + +- [ ] **Step 6: Run the dispatch tests to verify they pass** + +Run: `xcodebuild test ... -only-testing:UnitTests/FBWebServerDispatchTests -only-testing:UnitTests/FBRouteTests CODE_SIGNING_ALLOWED=NO` +Expected: all PASS — control probes answer with the main thread busy; the automation probe still runs on the main queue. + +- [ ] **Step 7: Verify all platforms still build** + +Run (fast sanity, generic destinations, no signing): + +```bash +xcodebuild build -project WebDriverAgent.xcodeproj -scheme WebDriverAgentRunner -destination 'generic/platform=iOS' CODE_SIGNING_ALLOWED=NO +xcodebuild build -project WebDriverAgent.xcodeproj -scheme WebDriverAgentRunner_tvOS -destination 'generic/platform=tvOS' CODE_SIGNING_ALLOWED=NO ARCHS=arm64 +xcodebuild build -project WebDriverAgent.xcodeproj -scheme WebDriverAgentRunner_watchOS -destination 'generic/platform=watchOS' CODE_SIGNING_ALLOWED=NO ARCHS=arm64 +``` + +Expected: all three BUILD SUCCEEDED (watchOS exercises the `TARGET_OS_WATCH` branches). + +- [ ] **Step 8: Commit** + +```bash +git add WebDriverAgentLib/Vendor/CocoaHTTPServer/HTTPServer.m WebDriverAgentLib/Routing/FBWebServer.m \ + WebDriverAgentLib/Commands/FBUnknownCommands.m WebDriverAgentLib/Commands/FBSessionCommands.m \ + WebDriverAgentLib/Commands/FBScreenCaptureCommands.m WebDriverAgentTests/UnitTests/FBRouteTests.m +git commit -m "feat: serve status and capture control routes off the automation queue" +``` + +--- + +### Task 5: Capture pixel cap (`maxPixels` + device-class default) + +**Files:** +- Modify: `WebDriverAgentLib/Utilities/FBVideoStreamSession.h` (`FBScreenCaptureConfiguration` interface) +- Modify: `WebDriverAgentLib/Utilities/FBVideoStreamSession.m` +- Modify: `WebDriverAgentLib/Commands/FBScreenCaptureCommands.m` (`handleStartScreenCapture:`) +- Modify: `docs/mobilerun-screencapture.md` +- Test: `WebDriverAgentTests/UnitTests/FBVideoStreamSessionTests.m` + +**Interfaces:** +- Consumes: nothing from other tasks (independent of Tasks 1–4). +- Produces (class methods on `FBScreenCaptureConfiguration`): + - `+ (NSString *)fb_machineModel;` — raw device model identifier (e.g. `iPhone11,2`), sysctl-backed with a simulator fallback. + - `+ (NSUInteger)fb_defaultPixelBudgetForMachineModel:(NSString *)machineModel;` — `370944` for iPhone majors ≤ 11, else `0` (no cap). + - `+ (CGSize)fb_sizeForWidth:(NSUInteger)width height:(NSUInteger)height pixelBudget:(NSUInteger)budget;` — aspect-preserving, even-aligned clamp; returns the input unchanged when `budget == 0` or already within budget. + +- [ ] **Step 1: Write the failing tests** + +Append inside the existing test-case implementation in `WebDriverAgentTests/UnitTests/FBVideoStreamSessionTests.m` (add `#import "FBVideoStreamSession.h"` to its imports if not present): + +```objc +- (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]); +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `xcodebuild test ... -only-testing:UnitTests/FBVideoStreamSessionTests CODE_SIGNING_ALLOWED=NO` +Expected: BUILD FAILURE — `no known class method for selector 'fb_defaultPixelBudgetForMachineModel:'`. + +- [ ] **Step 3: Implement the helpers** + +`WebDriverAgentLib/Utilities/FBVideoStreamSession.h` — add to the `FBScreenCaptureConfiguration` interface (after the `port` property): + +```objc +/** + 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. + */ ++ (CGSize)fb_sizeForWidth:(NSUInteger)width height:(NSUInteger)height pixelBudget:(NSUInteger)budget; +``` + +`WebDriverAgentLib/Utilities/FBVideoStreamSession.m` — add `#import ` to the imports and implement inside the `FBScreenCaptureConfiguration` implementation block: + +```objc +// 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 +{ + if (0 == budget || 0 == width || 0 == height || width * height <= budget) { + return CGSizeMake(width, height); + } + double scale = sqrt((double)budget / (double)(width * 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; + return CGSizeMake(MAX(scaledWidth, (NSUInteger)2), MAX(scaledHeight, (NSUInteger)2)); +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: same command as Step 2. Expected: all `FBVideoStreamSessionTests` PASS. + +- [ ] **Step 5: Wire the cap into the start endpoint** + +In `WebDriverAgentLib/Commands/FBScreenCaptureCommands.m`, `handleStartScreenCapture:` — after the existing `width`/`height` positive validation and **before** `FBScreenCaptureConfiguration *configuration = ...`, insert: + +```objc + NSUInteger pixelBudget = 0; + id maxPixels = request.arguments[@"maxPixels"]; + if (nil == maxPixels) { + pixelBudget = [FBScreenCaptureConfiguration fb_defaultPixelBudgetForMachineModel:[FBScreenCaptureConfiguration fb_machineModel]]; + } else if (![maxPixels isKindOfClass:NSNumber.class] || ((NSNumber *)maxPixels).integerValue < 0) { + return FBResponseWithStatus([FBCommandStatus invalidArgumentErrorWithMessage:@"'maxPixels' must be a non-negative integer (0 disables the capture size cap)" traceback:nil]); + } else { + pixelBudget = ((NSNumber *)maxPixels).unsignedIntegerValue; + } + 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; + } +``` + +The existing even-alignment lines (`configuration.width = (NSUInteger)(width - (width % 2));` etc.) stay as they are and now operate on the capped values. Add `#import "FBLogger.h"` to the file's imports if it is not already there. + +- [ ] **Step 6: Run the full unit bundle as a regression check** + +Run: `xcodebuild test ... -only-testing:UnitTests CODE_SIGNING_ALLOWED=NO` +Expected: PASS (entire `UnitTests` bundle). + +- [ ] **Step 7: Document the argument** + +`docs/mobilerun-screencapture.md`: + +1. In the `## Start arguments (JSON body)` table, add a row after the `fps` row: + + ```markdown + | `maxPixels` | int | no | device-dependent | Upper bound on `width×height`. Larger requests are scaled down aspect-preserving (rounded down to even). `0` disables the cap. When omitted, devices with an A12 chip or older default to `370944` (≈414×896); newer devices are uncapped. | + ``` + +2. In `## Notes / gotchas`, add a bullet: + + ```markdown + - 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. + ``` + +- [ ] **Step 8: Commit** + +```bash +git add WebDriverAgentLib/Utilities/FBVideoStreamSession.h WebDriverAgentLib/Utilities/FBVideoStreamSession.m \ + WebDriverAgentLib/Commands/FBScreenCaptureCommands.m WebDriverAgentTests/UnitTests/FBVideoStreamSessionTests.m \ + docs/mobilerun-screencapture.md +git commit -m "feat: cap screen capture size via maxPixels with a safe default on older devices" +``` + +--- + +### Task 6: Full verification + +**Files:** none new — verification only. + +**Interfaces:** n/a. + +- [ ] **Step 1: Run the complete unit test bundle** + +Run: `xcodebuild test -project WebDriverAgent.xcodeproj -scheme WebDriverAgentRunner -destination 'platform=iOS Simulator,name=iPhone 17' -only-testing:UnitTests CODE_SIGNING_ALLOWED=NO` +Expected: all tests PASS. + +- [ ] **Step 2: Build all three platforms** + +```bash +xcodebuild build -project WebDriverAgent.xcodeproj -scheme WebDriverAgentRunner -destination 'generic/platform=iOS' CODE_SIGNING_ALLOWED=NO +xcodebuild build -project WebDriverAgent.xcodeproj -scheme WebDriverAgentRunner_tvOS -destination 'generic/platform=tvOS' CODE_SIGNING_ALLOWED=NO ARCHS=arm64 +xcodebuild build -project WebDriverAgent.xcodeproj -scheme WebDriverAgentRunner_watchOS -destination 'generic/platform=watchOS' CODE_SIGNING_ALLOWED=NO ARCHS=arm64 +``` + +Expected: three times BUILD SUCCEEDED. + +- [ ] **Step 3: Smoke the mobilerun actions integration suite on the simulator** + +This exercises the real `/mobilerun/actions` → synthesize path (now running through the bounded wait) end to end: + +```bash +xcodebuild test -project WebDriverAgent.xcodeproj -scheme IntegrationTests_3 \ + -destination 'platform=iOS Simulator,name=iPhone 17' \ + -only-testing:IntegrationTests_3/FBMobilerunActionsIntegrationTests CODE_SIGNING_ALLOWED=NO +``` + +Expected: PASS. (Known environment caveat: some unrelated tap-to-alert tests in this scheme fail on current simulators regardless of changes — only the mobilerun actions class is in scope here.) + +- [ ] **Step 4: Verify the working tree is clean and every change is committed** + +Run: `git status --short` → empty; `git log --oneline origin/master..HEAD` → the spec commit plus one commit per task. + +- [ ] **Step 5: Hand off** + +Implementation done. Next: superpowers:finishing-a-development-branch (PR against `droidrun/WebDriverAgent` master — always pass `--repo droidrun/WebDriverAgent` to `gh`, since bare `gh` targets the appium upstream). Keep the PR description generic: what changed and why at the mechanism level, no fleet/infra details. On-device fleet validation (sustained capture + hammer, portal-side probes) happens after merge and is tracked on the ticket, not in the PR. diff --git a/docs/superpowers/specs/2026-08-19-runner-reliability-capture-load-design.md b/docs/superpowers/specs/2026-08-19-runner-reliability-capture-load-design.md index b9eba586ff..86aacf2ac0 100644 --- a/docs/superpowers/specs/2026-08-19-runner-reliability-capture-load-design.md +++ b/docs/superpowers/specs/2026-08-19-runner-reliability-capture-load-design.md @@ -63,9 +63,12 @@ Remove the global `setRouteQueue(main)`. Dispatch per route inside - `/health`, `/calibrate`, `/wda/shutdown` (registered directly on the server; run on the connection queue automatically once no global route queue is set — the shutdown delegate hop must be audited for thread safety) - - `/mobilerun/screencapture` family: start / stop / stop-all / list / get / keyframe + - `/mobilerun/screencapture` family: stop / stop-all / list / get / keyframe (`FBVideoStreamManager` is `@synchronized`-guarded and does its work on its own - background queue) + background queue). **start** stays on the automation queue — it reads + `XCUIScreen.mainScreen`, which violates the never-touches-XCUI rule. + - The unknown-endpoint fallback (`FBUnknownCommands`) — it only builds an error + payload, and a wedged agent should still say "no such route" instead of hanging. - `GET /mobilerun/screencapture/broadcast` (status read; `FBBroadcastManager` state reads must be audited/made atomic) @@ -124,7 +127,9 @@ liveness probe that must reflect a wedged automation queue by timing out or erro - `WebDriverAgentLib/Utilities/FBXCTestDaemonsProxy.m` — bounded synthesis wait - `WebDriverAgentLib/Utilities/FBConfiguration.{h,m}` — synthesis margin property - `WebDriverAgentLib/Commands/FBScreenCaptureCommands.m` — `maxPixels` argument -- New helper for device-class detection + clamp math (unit-testable pure functions) +- `WebDriverAgentLib/Utilities/FBVideoStreamSession.{h,m}` — device-class detection + clamp + math as class methods on `FBScreenCaptureConfiguration` (unit-testable pure functions; no + new files so the Xcode project file stays untouched) - `docs/mobilerun-screencapture.md`, `docs/mobilerun-actions.md` — API docs ## Error handling From e014830216eb9fbb388c1eda241d8365ee648039 Mon Sep 17 00:00:00 2001 From: timo <44401485+Timo972@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:44:41 +0200 Subject: [PATCH 03/14] feat: add bounded variant of the run loop completion spinner --- .../Utilities/FBRunLoopSpinner.h | 10 ++++++ .../Utilities/FBRunLoopSpinner.m | 12 +++++++ .../UnitTests/FBRunLoopSpinnerTests.m | 35 +++++++++++++++++++ 3 files changed, 57 insertions(+) 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/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 From d55a6f4ca6aca17d775e13089076a4ed380e37cb Mon Sep 17 00:00:00 2001 From: timo <44401485+Timo972@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:51:31 +0200 Subject: [PATCH 04/14] feat: fail unacknowledged event synthesis with an error instead of blocking forever --- WebDriverAgentLib/Utilities/FBConfiguration.h | 8 ++++++++ WebDriverAgentLib/Utilities/FBConfiguration.m | 13 ++++++++++++ .../Utilities/FBXCTestDaemonsProxy.m | 15 ++++++++++++-- .../UnitTests/FBConfigurationTests.m | 20 +++++++++++++++++++ docs/mobilerun-actions.md | 5 +++++ 5 files changed, 59 insertions(+), 2 deletions(-) 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/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/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/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` From 4c9579298463be7f2c107c1d8cc56804181524d7 Mon Sep 17 00:00:00 2001 From: timo <44401485+Timo972@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:57:47 +0200 Subject: [PATCH 05/14] feat: allow marking routes to be served off the automation queue --- WebDriverAgentLib/Routing/FBRoute.h | 12 +++++++++++ WebDriverAgentLib/Routing/FBRoute.m | 9 ++++++++ WebDriverAgentTests/UnitTests/FBRouteTests.m | 22 ++++++++++++++++++++ 3 files changed, 43 insertions(+) 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/WebDriverAgentTests/UnitTests/FBRouteTests.m b/WebDriverAgentTests/UnitTests/FBRouteTests.m index 5dede0c5f2..bf60373c74 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,6 +123,26 @@ - (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; From 7c021762f37d99575e8d79196cf184099be41af8 Mon Sep 17 00:00:00 2001 From: timo <44401485+Timo972@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:06:59 +0200 Subject: [PATCH 06/14] feat: serve status and capture control routes off the automation queue --- .../Commands/FBScreenCaptureCommands.m | 24 ++-- .../Commands/FBSessionCommands.m | 2 +- .../Commands/FBUnknownCommands.m | 8 +- WebDriverAgentLib/Routing/FBWebServer.m | 37 ++++- .../Vendor/CocoaHTTPServer/HTTPServer.m | 7 +- WebDriverAgentTests/UnitTests/FBRouteTests.m | 132 ++++++++++++++++++ 6 files changed, 185 insertions(+), 25 deletions(-) diff --git a/WebDriverAgentLib/Commands/FBScreenCaptureCommands.m b/WebDriverAgentLib/Commands/FBScreenCaptureCommands.m index c3c1f1c362..04f1108e91 100644 --- a/WebDriverAgentLib/Commands/FBScreenCaptureCommands.m +++ b/WebDriverAgentLib/Commands/FBScreenCaptureCommands.m @@ -30,24 +30,24 @@ + (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:)], - [[FBRoute GET:@"/mobilerun/screencapture/broadcast"] respondWithTarget:self action:@selector(handleGetBroadcastStatus:)], + [[[FBRoute GET:@"/mobilerun/screencapture/broadcast"] onControlQueue] 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:)], - [[FBRoute GET:@"/mobilerun/screencapture"] respondWithTarget:self action:@selector(handleListScreenCapture:)], - [[FBRoute GET:@"/mobilerun/screencapture/:id"] respondWithTarget:self action:@selector(handleGetScreenCapture:)], - [[FBRoute POST:@"/mobilerun/screencapture/:id/stop"] respondWithTarget:self action:@selector(handleStopScreenCapture:)], - [[FBRoute POST:@"/mobilerun/screencapture/:id/keyframe"] respondWithTarget:self action:@selector(handleRequestKeyFrame:)], + [[[FBRoute POST:@"/mobilerun/screencapture/stop"] onControlQueue] respondWithTarget:self action:@selector(handleStopAllScreenCapture:)], + [[[FBRoute GET:@"/mobilerun/screencapture"] onControlQueue] respondWithTarget:self action:@selector(handleListScreenCapture:)], + [[[FBRoute GET:@"/mobilerun/screencapture/:id"] onControlQueue] respondWithTarget:self action:@selector(handleGetScreenCapture:)], + [[[FBRoute POST:@"/mobilerun/screencapture/:id/stop"] onControlQueue] respondWithTarget:self action:@selector(handleStopScreenCapture:)], + [[[FBRoute POST:@"/mobilerun/screencapture/:id/keyframe"] onControlQueue] 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:)], ]; } 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/FBWebServer.m b/WebDriverAgentLib/Routing/FBWebServer.m index c9a3a03281..7fc8bf1091 100644 --- a/WebDriverAgentLib/Routing/FBWebServer.m +++ b/WebDriverAgentLib/Routing/FBWebServer.m @@ -132,7 +132,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"]; @@ -293,17 +295,36 @@ - (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 { + 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 +353,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/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/FBRouteTests.m b/WebDriverAgentTests/UnitTests/FBRouteTests.m index bf60373c74..8270a1ed71 100644 --- a/WebDriverAgentTests/UnitTests/FBRouteTests.m +++ b/WebDriverAgentTests/UnitTests/FBRouteTests.m @@ -149,3 +149,135 @@ - (void)testOnControlQueueSurvivesRespondWithBlock } @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; + +@interface FBWebServer (DispatchTests) +- (void)registerRouteHandlers:(NSArray *)commandHandlerClasses; +- (RoutingHTTPServer *)server; +@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(); + }], + ]; +} + +@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); + + self.webServer = [FBWebServer new]; + 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)); +} + +@end From a3ea6f3860189616b3728e870320baf9f867b104 Mon Sep 17 00:00:00 2001 From: timo <44401485+Timo972@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:23:21 +0200 Subject: [PATCH 07/14] fix: keep session-bound routes on the automation queue and pre-warm status caches Address two review findings on the HTTP layer split: - Pre-warm FBSDKVersion() and FBTestmanagerdVersion() on the main thread during startServing, before the HTTP server starts accepting connections. Both cache their result behind a dispatch_once, and /status is now served off the automation queue, so the first /status request could otherwise resolve the testmanagerd daemon proxy from a connection queue. - Unmark onControlQueue from the six session-required screencapture routes. Decorating a session-required route reads FBSession's static active-session state, which the automation queue writes without synchronization. Only the .withoutSession variants (already synchronization-safe) stay on the connection queue. --- .../Commands/FBScreenCaptureCommands.m | 14 ++++++++------ WebDriverAgentLib/Routing/FBWebServer.m | 6 ++++++ ...08-19-runner-reliability-capture-load-design.md | 4 +++- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/WebDriverAgentLib/Commands/FBScreenCaptureCommands.m b/WebDriverAgentLib/Commands/FBScreenCaptureCommands.m index 04f1108e91..b31f896c79 100644 --- a/WebDriverAgentLib/Commands/FBScreenCaptureCommands.m +++ b/WebDriverAgentLib/Commands/FBScreenCaptureCommands.m @@ -30,17 +30,19 @@ + (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:)], - [[[FBRoute GET:@"/mobilerun/screencapture/broadcast"] onControlQueue] respondWithTarget:self action:@selector(handleGetBroadcastStatus:)], + // 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 onControlQueue] respondWithTarget:self action:@selector(handleGetBroadcastStatus:)], [[FBRoute POST:@"/mobilerun/screencapture/start"] respondWithTarget:self action:@selector(handleStartScreenCapture:)], - [[[FBRoute POST:@"/mobilerun/screencapture/stop"] onControlQueue] respondWithTarget:self action:@selector(handleStopAllScreenCapture:)], - [[[FBRoute GET:@"/mobilerun/screencapture"] onControlQueue] respondWithTarget:self action:@selector(handleListScreenCapture:)], - [[[FBRoute GET:@"/mobilerun/screencapture/:id"] onControlQueue] respondWithTarget:self action:@selector(handleGetScreenCapture:)], - [[[FBRoute POST:@"/mobilerun/screencapture/:id/stop"] onControlQueue] respondWithTarget:self action:@selector(handleStopScreenCapture:)], - [[[FBRoute POST:@"/mobilerun/screencapture/:id/keyframe"] onControlQueue] respondWithTarget:self action:@selector(handleRequestKeyFrame:)], + [[FBRoute POST:@"/mobilerun/screencapture/stop"] respondWithTarget:self action:@selector(handleStopAllScreenCapture:)], + [[FBRoute GET:@"/mobilerun/screencapture"] respondWithTarget:self action:@selector(handleListScreenCapture:)], + [[FBRoute GET:@"/mobilerun/screencapture/:id"] respondWithTarget:self action:@selector(handleGetScreenCapture:)], + [[FBRoute POST:@"/mobilerun/screencapture/:id/stop"] respondWithTarget:self action:@selector(handleStopScreenCapture:)], + [[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 onControlQueue] respondWithTarget:self action:@selector(handleStopAllScreenCapture:)], diff --git a/WebDriverAgentLib/Routing/FBWebServer.m b/WebDriverAgentLib/Routing/FBWebServer.m index 7fc8bf1091..ce82843c85 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" @@ -95,6 +96,11 @@ - (void)startServing { [FBLogger logFmt:@"Built at %s %s", __DATE__, __TIME__]; self.exceptionHandler = [FBExceptionHandler new]; + // /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, so the first request never resolves them from a connection queue. + FBSDKVersion(); + FBTestmanagerdVersion(); if (![self startHTTPServer]) { return; } diff --git a/docs/superpowers/specs/2026-08-19-runner-reliability-capture-load-design.md b/docs/superpowers/specs/2026-08-19-runner-reliability-capture-load-design.md index 86aacf2ac0..1a2d607aef 100644 --- a/docs/superpowers/specs/2026-08-19-runner-reliability-capture-load-design.md +++ b/docs/superpowers/specs/2026-08-19-runner-reliability-capture-load-design.md @@ -66,7 +66,9 @@ Remove the global `setRouteQueue(main)`. Dispatch per route inside - `/mobilerun/screencapture` family: stop / stop-all / list / get / keyframe (`FBVideoStreamManager` is `@synchronized`-guarded and does its work on its own background queue). **start** stays on the automation queue — it reads - `XCUIScreen.mainScreen`, which violates the never-touches-XCUI rule. + `XCUIScreen.mainScreen`, which violates the never-touches-XCUI rule. Only the + sessionless variants are served on the connection queue — session-bound lookups stay + on the automation queue because the session store is main-queue state. - The unknown-endpoint fallback (`FBUnknownCommands`) — it only builds an error payload, and a wedged agent should still say "no such route" instead of hanging. - `GET /mobilerun/screencapture/broadcast` (status read; `FBBroadcastManager` state From 843d085305e9bd4fc9ba86e1b1e21a80f23cfcd9 Mon Sep 17 00:00:00 2001 From: timo <44401485+Timo972@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:30:03 +0200 Subject: [PATCH 08/14] feat: cap screen capture size via maxPixels with a safe default on older devices --- .../Commands/FBScreenCaptureCommands.m | 18 +++++++ .../Utilities/FBVideoStreamSession.h | 19 +++++++ .../Utilities/FBVideoStreamSession.m | 52 +++++++++++++++++++ .../UnitTests/FBVideoStreamSessionTests.m | 47 +++++++++++++++++ docs/mobilerun-screencapture.md | 4 ++ 5 files changed, 140 insertions(+) diff --git a/WebDriverAgentLib/Commands/FBScreenCaptureCommands.m b/WebDriverAgentLib/Commands/FBScreenCaptureCommands.m index b31f896c79..063df27b59 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" @@ -128,6 +129,23 @@ + (NSArray *)routes return FBResponseWithStatus([FBCommandStatus invalidArgumentErrorWithMessage:@"Both 'width' and 'height' must be provided as positive integers" traceback:nil]); } + NSUInteger pixelBudget = 0; + id maxPixels = request.arguments[@"maxPixels"]; + if (nil == maxPixels) { + pixelBudget = [FBScreenCaptureConfiguration fb_defaultPixelBudgetForMachineModel:[FBScreenCaptureConfiguration fb_machineModel]]; + } else if (![maxPixels isKindOfClass:NSNumber.class] || ((NSNumber *)maxPixels).integerValue < 0) { + return FBResponseWithStatus([FBCommandStatus invalidArgumentErrorWithMessage:@"'maxPixels' must be a non-negative integer (0 disables the capture size cap)" traceback:nil]); + } else { + pixelBudget = ((NSNumber *)maxPixels).unsignedIntegerValue; + } + 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/Utilities/FBVideoStreamSession.h b/WebDriverAgentLib/Utilities/FBVideoStreamSession.h index 612e1ffcef..f9b71d5fcf 100644 --- a/WebDriverAgentLib/Utilities/FBVideoStreamSession.h +++ b/WebDriverAgentLib/Utilities/FBVideoStreamSession.h @@ -49,6 +49,25 @@ 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. + */ ++ (CGSize)fb_sizeForWidth:(NSUInteger)width height:(NSUInteger)height pixelBudget:(NSUInteger)budget; + @end /** diff --git a/WebDriverAgentLib/Utilities/FBVideoStreamSession.m b/WebDriverAgentLib/Utilities/FBVideoStreamSession.m index 10745a4c7f..fc3c50c69a 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,57 @@ - (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 +{ + if (0 == budget || 0 == width || 0 == height || width * height <= budget) { + return CGSizeMake(width, height); + } + double scale = sqrt((double)budget / (double)(width * 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; + return CGSizeMake(MAX(scaledWidth, (NSUInteger)2), MAX(scaledHeight, (NSUInteger)2)); +} + @end diff --git a/WebDriverAgentTests/UnitTests/FBVideoStreamSessionTests.m b/WebDriverAgentTests/UnitTests/FBVideoStreamSessionTests.m index 8ab51202e9..b4ddbf25aa 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,50 @@ - (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]); +} + @end diff --git a/docs/mobilerun-screencapture.md b/docs/mobilerun-screencapture.md index 1922e63baf..7c329d2efc 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). `0` disables the cap. 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,8 @@ 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`. From 92e85244880b21aaf368d5d1d876d9f65dcab5ba Mon Sep 17 00:00:00 2001 From: timo <44401485+Timo972@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:02:34 +0200 Subject: [PATCH 09/14] fix: serialize automation requests and synchronize active session access Whole-branch review found two Important cross-task defects in the runner-reliability-under-capture-load work: - FBSession's _activeSession static was read off-main by every control-route response builder (FBResponsePayload) while session create/kill wrote it on the main queue, an ARC data race with a use-after-free window. All reads and writes now go through @synchronized (FBSession.class) accessors, scoped tightly around the bare static access so XCUI/teardown work in kill/markSessionActive: never runs under the lock. - With per-connection queues, a second automation request could dispatch_sync onto the main queue while a first automation handler was spinning the run loop (FBRunLoopSpinner waiting on synthesis), and the nested run loop drain would execute the second handler reentrantly inside the first -- something the old shared connection queue serialized away. FBWebServer now funnels all non-control-route requests through a dedicated serial automation-funnel queue before hopping to main, restoring one-at-a-time semantics. Added FBWebServerDispatchTests. testAutomationRequestsDoNotNestInsideRunLoopSpin to pin the non-nesting behavior via a new /probe/spinning route. Also folds in two docs corrections: mobilerun-screencapture.md notes that only sessionless capture-control endpoints stay responsive during a blocked automation command, and the design spec notes the same for the broadcast status route plus the new automation funnel queue. Co-Authored-By: Claude Fable 5 --- WebDriverAgentLib/Routing/FBSession.m | 35 +++++++++++---- WebDriverAgentLib/Routing/FBWebServer.m | 18 ++++++-- WebDriverAgentTests/UnitTests/FBRouteTests.m | 43 +++++++++++++++++++ docs/mobilerun-screencapture.md | 3 ++ ...-runner-reliability-capture-load-design.md | 9 +++- 5 files changed, 94 insertions(+), 14 deletions(-) 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 ce82843c85..d4d616b858 100644 --- a/WebDriverAgentLib/Routing/FBWebServer.m +++ b/WebDriverAgentLib/Routing/FBWebServer.m @@ -66,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 @@ -148,6 +151,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]; @@ -309,10 +314,15 @@ - (void)registerRouteHandlers:(NSArray *)commandHandlerClasses // queue is busy or blocked. Only routes that never touch XCUI state opt in. [strongSelf mountRoute:route request:routeParams intoResponse:response]; } else { - dispatch_sync(dispatch_get_main_queue(), ^{ - @autoreleasepool { - [strongSelf mountRoute:route request:routeParams intoResponse:response]; - } + // 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 diff --git a/WebDriverAgentTests/UnitTests/FBRouteTests.m b/WebDriverAgentTests/UnitTests/FBRouteTests.m index 8270a1ed71..01e420e843 100644 --- a/WebDriverAgentTests/UnitTests/FBRouteTests.m +++ b/WebDriverAgentTests/UnitTests/FBRouteTests.m @@ -159,10 +159,14 @@ - (void)testOnControlQueueSurvivesRespondWithBlock 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 @@ -188,6 +192,20 @@ + (NSArray *)routes 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(); + }], ]; } @@ -208,8 +226,14 @@ - (void)setUp 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"]; @@ -280,4 +304,23 @@ - (void)testControlRouteRespondsWhileAutomationRouteIsBlocked 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/docs/mobilerun-screencapture.md b/docs/mobilerun-screencapture.md index 7c329d2efc..763bf408c0 100644 --- a/docs/mobilerun-screencapture.md +++ b/docs/mobilerun-screencapture.md @@ -144,3 +144,6 @@ curl -s -X POST http://localhost:8100/mobilerun/screencapture/1/stop 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/superpowers/specs/2026-08-19-runner-reliability-capture-load-design.md b/docs/superpowers/specs/2026-08-19-runner-reliability-capture-load-design.md index 1a2d607aef..e041c253c9 100644 --- a/docs/superpowers/specs/2026-08-19-runner-reliability-capture-load-design.md +++ b/docs/superpowers/specs/2026-08-19-runner-reliability-capture-load-design.md @@ -55,7 +55,11 @@ Remove the global `setRouteQueue(main)`. Dispatch per route inside `-[FBWebServer registerRouteHandlers:]`: - **Automation routes** (default): `dispatch_sync` onto the main queue — semantics - identical to today for everything that touches XCUI / testmanagerd. + identical to today for everything that touches XCUI / testmanagerd. These requests are + additionally serialized through a dedicated funnel queue so at most one is ever in + flight, preserving pre-change one-at-a-time semantics against nested run-loop draining + (a second request could otherwise execute reentrantly inside a first handler that spins + the run loop, e.g. `FBRunLoopSpinner`). - **Control routes**: run inline on the connection's own queue. Marked with a new chainable `FBRoute` flag (`.onControlQueue`). Only routes whose handlers never touch XCUI and whose backing state is thread-safe qualify: @@ -72,7 +76,8 @@ Remove the global `setRouteQueue(main)`. Dispatch per route inside - The unknown-endpoint fallback (`FBUnknownCommands`) — it only builds an error payload, and a wedged agent should still say "no such route" instead of hanging. - `GET /mobilerun/screencapture/broadcast` (status read; `FBBroadcastManager` state - reads must be audited/made atomic) + reads must be audited/made atomic). Like the rest of the capture family, only the + sessionless variant is served on the connection queue. Broadcast **start/stop** stay on the main queue: they drive the system broadcast picker through XCUI. `/mobilerun/state` also stays on the main queue **deliberately**: it is the From 38cda98ba4d6d37df778d192cb27bbef04b0311c Mon Sep 17 00:00:00 2001 From: timo <44401485+Timo972@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:19:19 +0200 Subject: [PATCH 10/14] fix: make capture stop-all a barrier, bound the daemon version exchange, and harden the pixel cap math --- WebDriverAgentLib/Routing/FBWebServer.m | 11 ++-- .../Utilities/FBVideoStreamManager.m | 58 ++++++++++++++----- .../Utilities/FBVideoStreamSession.m | 6 +- .../Utilities/FBXCodeCompatibility.m | 14 ++++- .../UnitTests/FBVideoStreamSessionTests.m | 8 +++ 5 files changed, 75 insertions(+), 22 deletions(-) diff --git a/WebDriverAgentLib/Routing/FBWebServer.m b/WebDriverAgentLib/Routing/FBWebServer.m index d4d616b858..387b55b156 100644 --- a/WebDriverAgentLib/Routing/FBWebServer.m +++ b/WebDriverAgentLib/Routing/FBWebServer.m @@ -99,14 +99,17 @@ - (void)startServing { [FBLogger logFmt:@"Built at %s %s", __DATE__, __TIME__]; self.exceptionHandler = [FBExceptionHandler new]; + if (![self startHTTPServer]) { + return; + } // /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, so the first request never resolves them from a connection queue. + // 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. FBSDKVersion(); FBTestmanagerdVersion(); - if (![self startHTTPServer]) { - return; - } #if !TARGET_OS_WATCH [self initScreenshotsBroadcaster]; // Listen permanently so broadcasts started from Control Center attach as well. 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.m b/WebDriverAgentLib/Utilities/FBVideoStreamSession.m index fc3c50c69a..6a88dac2fe 100644 --- a/WebDriverAgentLib/Utilities/FBVideoStreamSession.m +++ b/WebDriverAgentLib/Utilities/FBVideoStreamSession.m @@ -76,10 +76,12 @@ + (NSUInteger)fb_defaultPixelBudgetForMachineModel:(NSString *)machineModel + (CGSize)fb_sizeForWidth:(NSUInteger)width height:(NSUInteger)height pixelBudget:(NSUInteger)budget { - if (0 == budget || 0 == width || 0 == height || width * height <= 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 * 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; diff --git a/WebDriverAgentLib/Utilities/FBXCodeCompatibility.m b/WebDriverAgentLib/Utilities/FBXCodeCompatibility.m index e2fa950310..a2e6620c75 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,17 @@ NSInteger FBTestmanagerdVersion(void) id proxy = [FBXCTestDaemonsProxy testRunnerProxy]; if ([(NSObject *)proxy respondsToSelector:@selector(_XCT_exchangeProtocolVersion:reply:)]) { id legacyProxy = (id)proxy; - [FBRunLoopSpinner spinUntilCompletion:^(void(^completion)(void)){ + BOOL exchanged = [FBRunLoopSpinner spinUntilCompletion:^(void(^completion)(void)){ [legacyProxy _XCT_exchangeProtocolVersion:testmanagerdVersion reply:^(unsigned long long code) { testmanagerdVersion = (NSInteger) code; completion(); }]; - }]; + } timeout:FBProtocolVersionExchangeTimeout]; + if (!exchanged) { + [FBLogger log:@"Timed out waiting for the testmanagerd protocol version exchange"]; + // testmanagerdVersion is left as-is (diagnostic-only); a late reply harmlessly fills it + // in afterwards. + } } 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/WebDriverAgentTests/UnitTests/FBVideoStreamSessionTests.m b/WebDriverAgentTests/UnitTests/FBVideoStreamSessionTests.m index b4ddbf25aa..25da6c457a 100644 --- a/WebDriverAgentTests/UnitTests/FBVideoStreamSessionTests.m +++ b/WebDriverAgentTests/UnitTests/FBVideoStreamSessionTests.m @@ -160,4 +160,12 @@ - (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); +} + @end From 86454e4779148397e12cc7ed0322dcb479704aac Mon Sep 17 00:00:00 2001 From: timo <44401485+Timo972@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:48:12 +0200 Subject: [PATCH 11/14] fix: honor shutdown during startup warm-up and enforce the pixel budget for min-clamped sizes Move the FBSDKVersion()/FBTestmanagerdVersion() warm-up in FBWebServer startServing to after keepAlive is set to YES and initialization is complete, so a /wda/shutdown that races the bounded legacy-daemon handshake (which can spin the main run loop up to 30s) clears keepAlive via stopServing instead of being negated by code that runs after it. Fix fb_sizeForWidth:height:pixelBudget: in FBVideoStreamSession so the minimum-size clamp (MAX(axis, 2)) can no longer push the product back over budget for skinny inputs (e.g. 10000x2 at budget 100 previously clamped to 1412px, 14x over budget). When the clamp pushes one axis to the 2px floor, the other axis is now shrunk to fit; honoring the budget outranks aspect ratio for extreme inputs. Budgets 1-3 can never be honored (2x2=4 is the minimum encodable size) and are now rejected by handleStartScreenCapture:'s maxPixels validation. Co-Authored-By: Claude Fable 5 --- .../Commands/FBScreenCaptureCommands.m | 7 +++++-- WebDriverAgentLib/Routing/FBWebServer.m | 19 +++++++++++-------- .../Utilities/FBVideoStreamSession.h | 5 ++++- .../Utilities/FBVideoStreamSession.m | 15 ++++++++++++++- .../UnitTests/FBVideoStreamSessionTests.m | 15 +++++++++++++++ docs/mobilerun-screencapture.md | 2 +- 6 files changed, 50 insertions(+), 13 deletions(-) diff --git a/WebDriverAgentLib/Commands/FBScreenCaptureCommands.m b/WebDriverAgentLib/Commands/FBScreenCaptureCommands.m index 063df27b59..c64a4755d0 100644 --- a/WebDriverAgentLib/Commands/FBScreenCaptureCommands.m +++ b/WebDriverAgentLib/Commands/FBScreenCaptureCommands.m @@ -133,8 +133,11 @@ + (NSArray *)routes id maxPixels = request.arguments[@"maxPixels"]; if (nil == maxPixels) { pixelBudget = [FBScreenCaptureConfiguration fb_defaultPixelBudgetForMachineModel:[FBScreenCaptureConfiguration fb_machineModel]]; - } else if (![maxPixels isKindOfClass:NSNumber.class] || ((NSNumber *)maxPixels).integerValue < 0) { - return FBResponseWithStatus([FBCommandStatus invalidArgumentErrorWithMessage:@"'maxPixels' must be a non-negative integer (0 disables the capture size cap)" traceback:nil]); + } else if (![maxPixels isKindOfClass:NSNumber.class] + || ((NSNumber *)maxPixels).integerValue < 0 + || (((NSNumber *)maxPixels).integerValue >= 1 && ((NSNumber *)maxPixels).integerValue <= 3)) { + // 1..3 cannot be honored: 2x2 = 4 is the minimum encodable size. + return FBResponseWithStatus([FBCommandStatus invalidArgumentErrorWithMessage:@"'maxPixels' must be 0 (uncapped) or an integer of at least 4" traceback:nil]); } else { pixelBudget = ((NSNumber *)maxPixels).unsignedIntegerValue; } diff --git a/WebDriverAgentLib/Routing/FBWebServer.m b/WebDriverAgentLib/Routing/FBWebServer.m index 387b55b156..4578f9993c 100644 --- a/WebDriverAgentLib/Routing/FBWebServer.m +++ b/WebDriverAgentLib/Routing/FBWebServer.m @@ -102,14 +102,6 @@ - (void)startServing if (![self startHTTPServer]) { return; } - // /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. - FBSDKVersion(); - FBTestmanagerdVersion(); #if !TARGET_OS_WATCH [self initScreenshotsBroadcaster]; // Listen permanently so broadcasts started from Control Center attach as well. @@ -117,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 { diff --git a/WebDriverAgentLib/Utilities/FBVideoStreamSession.h b/WebDriverAgentLib/Utilities/FBVideoStreamSession.h index f9b71d5fcf..6a7a885832 100644 --- a/WebDriverAgentLib/Utilities/FBVideoStreamSession.h +++ b/WebDriverAgentLib/Utilities/FBVideoStreamSession.h @@ -64,7 +64,10 @@ typedef NS_ENUM(NSUInteger, FBVideoStreamSource) { /** 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. + 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; diff --git a/WebDriverAgentLib/Utilities/FBVideoStreamSession.m b/WebDriverAgentLib/Utilities/FBVideoStreamSession.m index 6a88dac2fe..1ecfbcc575 100644 --- a/WebDriverAgentLib/Utilities/FBVideoStreamSession.m +++ b/WebDriverAgentLib/Utilities/FBVideoStreamSession.m @@ -85,7 +85,20 @@ + (CGSize)fb_sizeForWidth:(NSUInteger)width height:(NSUInteger)height pixelBudge // 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; - return CGSizeMake(MAX(scaledWidth, (NSUInteger)2), MAX(scaledHeight, (NSUInteger)2)); + 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); } @end diff --git a/WebDriverAgentTests/UnitTests/FBVideoStreamSessionTests.m b/WebDriverAgentTests/UnitTests/FBVideoStreamSessionTests.m index 25da6c457a..fddd8d2819 100644 --- a/WebDriverAgentTests/UnitTests/FBVideoStreamSessionTests.m +++ b/WebDriverAgentTests/UnitTests/FBVideoStreamSessionTests.m @@ -168,4 +168,19 @@ - (void)testPixelBudgetClampSurvivesHugeDimensions 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); +} + @end diff --git a/docs/mobilerun-screencapture.md b/docs/mobilerun-screencapture.md index 763bf408c0..5024356a91 100644 --- a/docs/mobilerun-screencapture.md +++ b/docs/mobilerun-screencapture.md @@ -45,7 +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). `0` disables the cap. When omitted, devices with an A12 chip or older default to `370944` (≈414×896); newer devices are uncapped. | +| `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 From 30774cb94b7235f7c3d298c51a4795dace4bb484 Mon Sep 17 00:00:00 2001 From: timo <44401485+Timo972@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:33:09 +0200 Subject: [PATCH 12/14] fix: ignore late testmanagerd protocol replies after the bounded exchange --- WebDriverAgentLib/Utilities/FBXCodeCompatibility.m | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/WebDriverAgentLib/Utilities/FBXCodeCompatibility.m b/WebDriverAgentLib/Utilities/FBXCodeCompatibility.m index a2e6620c75..161be878cd 100644 --- a/WebDriverAgentLib/Utilities/FBXCodeCompatibility.m +++ b/WebDriverAgentLib/Utilities/FBXCodeCompatibility.m @@ -90,16 +90,21 @@ NSInteger FBTestmanagerdVersion(void) id proxy = [FBXCTestDaemonsProxy testRunnerProxy]; if ([(NSObject *)proxy respondsToSelector:@selector(_XCT_exchangeProtocolVersion:reply:)]) { id legacyProxy = (id)proxy; + // 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) { + if (exchanged) { + testmanagerdVersion = exchangedVersion; + } else { [FBLogger log:@"Timed out waiting for the testmanagerd protocol version exchange"]; - // testmanagerdVersion is left as-is (diagnostic-only); a late reply harmlessly fills it - // in afterwards. + // testmanagerdVersion stays at its default (diagnostic-only). } } else { // Modern testmanagerd (Xcode 15+) has already negotiated named XCTCapabilities by the time From 85ccdb7d86ca8b3649320199af13c6adeb53602b Mon Sep 17 00:00:00 2001 From: timo <44401485+Timo972@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:11:57 +0200 Subject: [PATCH 13/14] fix: validate maxPixels on the raw numeric value and make the broadcast server reference atomic --- .../Commands/FBScreenCaptureCommands.m | 13 +++------- .../Utilities/FBBroadcastManager.m | 4 ++- .../Utilities/FBVideoStreamSession.h | 12 +++++++++ .../Utilities/FBVideoStreamSession.m | 23 +++++++++++++++++ .../UnitTests/FBVideoStreamSessionTests.m | 25 +++++++++++++++++++ 5 files changed, 67 insertions(+), 10 deletions(-) diff --git a/WebDriverAgentLib/Commands/FBScreenCaptureCommands.m b/WebDriverAgentLib/Commands/FBScreenCaptureCommands.m index c64a4755d0..00f57813a0 100644 --- a/WebDriverAgentLib/Commands/FBScreenCaptureCommands.m +++ b/WebDriverAgentLib/Commands/FBScreenCaptureCommands.m @@ -130,16 +130,11 @@ + (NSArray *)routes } NSUInteger pixelBudget = 0; - id maxPixels = request.arguments[@"maxPixels"]; - if (nil == maxPixels) { - pixelBudget = [FBScreenCaptureConfiguration fb_defaultPixelBudgetForMachineModel:[FBScreenCaptureConfiguration fb_machineModel]]; - } else if (![maxPixels isKindOfClass:NSNumber.class] - || ((NSNumber *)maxPixels).integerValue < 0 - || (((NSNumber *)maxPixels).integerValue >= 1 && ((NSNumber *)maxPixels).integerValue <= 3)) { - // 1..3 cannot be honored: 2x2 = 4 is the minimum encodable size. + 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]); - } else { - pixelBudget = ((NSNumber *)maxPixels).unsignedIntegerValue; } CGSize cappedSize = [FBScreenCaptureConfiguration fb_sizeForWidth:(NSUInteger)width height:(NSUInteger)height pixelBudget:pixelBudget]; if ((NSInteger)cappedSize.width < width || (NSInteger)cappedSize.height < height) { 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/FBVideoStreamSession.h b/WebDriverAgentLib/Utilities/FBVideoStreamSession.h index 6a7a885832..8c94a77cca 100644 --- a/WebDriverAgentLib/Utilities/FBVideoStreamSession.h +++ b/WebDriverAgentLib/Utilities/FBVideoStreamSession.h @@ -71,6 +71,18 @@ typedef NS_ENUM(NSUInteger, FBVideoStreamSource) { */ + (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 1ecfbcc575..45b3c3d6ca 100644 --- a/WebDriverAgentLib/Utilities/FBVideoStreamSession.m +++ b/WebDriverAgentLib/Utilities/FBVideoStreamSession.m @@ -101,6 +101,29 @@ + (CGSize)fb_sizeForWidth:(NSUInteger)width height:(NSUInteger)height pixelBudge 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/WebDriverAgentTests/UnitTests/FBVideoStreamSessionTests.m b/WebDriverAgentTests/UnitTests/FBVideoStreamSessionTests.m index fddd8d2819..47134e8a31 100644 --- a/WebDriverAgentTests/UnitTests/FBVideoStreamSessionTests.m +++ b/WebDriverAgentTests/UnitTests/FBVideoStreamSessionTests.m @@ -183,4 +183,29 @@ - (void)testPixelBudgetClampAtMinimumBudget 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 From 5be921ad1fd503773f9f024461e95761822323d6 Mon Sep 17 00:00:00 2001 From: timo <44401485+Timo972@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:59:31 +0200 Subject: [PATCH 14/14] docs: describe the request dispatch model instead of shipping working notes Co-Authored-By: Claude Fable 5 --- docs/request-dispatch.md | 127 +++ ...6-08-19-runner-reliability-capture-load.md | 984 ------------------ ...-runner-reliability-capture-load-design.md | 161 --- 3 files changed, 127 insertions(+), 1145 deletions(-) create mode 100644 docs/request-dispatch.md delete mode 100644 docs/superpowers/plans/2026-08-19-runner-reliability-capture-load.md delete mode 100644 docs/superpowers/specs/2026-08-19-runner-reliability-capture-load-design.md 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. diff --git a/docs/superpowers/plans/2026-08-19-runner-reliability-capture-load.md b/docs/superpowers/plans/2026-08-19-runner-reliability-capture-load.md deleted file mode 100644 index 408bf80fac..0000000000 --- a/docs/superpowers/plans/2026-08-19-runner-reliability-capture-load.md +++ /dev/null @@ -1,984 +0,0 @@ -# Runner Reliability Under Capture Load Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Keep the WDA runner serving requests when the system drops synthesized touch events under heavy capture load: bounded synthesis waits (single request fails 5xx), control routes that bypass the automation queue, and a capture pixel cap for older devices. - -**Architecture:** Three independent changes to existing files. (1) `FBRunLoopSpinner` gains a bounded completion-spin used by `FBXCTestDaemonsProxy synthesizeEventWithRecord:` with a duration-derived deadline. (2) The HTTP layer stops funneling everything through one shared connection queue + a global main route queue: each connection gets its own queue, and routes marked "control" run inline on it while all other routes `dispatch_sync` to the main queue exactly as today. (3) `/mobilerun/screencapture/start` clamps requested dimensions to a pixel budget (explicit `maxPixels` argument, or a device-class default for A12-and-older iPhones). - -**Tech Stack:** Objective-C, XCTest unit tests (`UnitTests` bundle), xcodebuild against an iOS simulator. - -**Spec:** `docs/superpowers/specs/2026-08-19-runner-reliability-capture-load-design.md` - -## Global Constraints - -- **No new source or test files.** All code goes into existing files so `project.pbxproj` is never touched (Xcode reorders it and wiring is error-prone). New unit tests join existing test-case files; a second `XCTestCase` class in an existing file is fine. -- **All platforms must compile:** CI builds iOS, tvOS, and watchOS. The watchOS path (`TARGET_OS_WATCH`) keeps today's behavior (`FBWatchHTTPServer` + main route queue); only the `RoutingHTTPServer` path changes. -- **Do not modify copyright headers** of edited files. -- **Public repo hygiene:** commit messages describe the mechanism generically. Never mention fleet hosts, device serials, internal recovery tooling, or reproduction infrastructure. -- **Env var naming:** bare uppercase style matching existing vars (e.g. `MAX_HTTP_REQUEST_BODY_SIZE`) — the new var is `EVENT_SYNTHESIS_TIMEOUT_MARGIN`. -- **Unit test command** (pick an available iPhone simulator via `xcrun simctl list devices available | grep iPhone`): - ```bash - xcodebuild test -project WebDriverAgent.xcodeproj -scheme WebDriverAgentRunner \ - -destination 'platform=iOS Simulator,name=iPhone 17' \ - -only-testing:UnitTests/ CODE_SIGNING_ALLOWED=NO - ``` - Drop `/` to run the whole bundle. First runs are slow (build); later runs are incremental. -- The working branch is `timo/dro-2713-wda-runner-reliability-under-capture-load` (already created from origin/master). - ---- - -### Task 1: Bounded run-loop spin (`FBRunLoopSpinner`) - -**Files:** -- Modify: `WebDriverAgentLib/Utilities/FBRunLoopSpinner.h` -- Modify: `WebDriverAgentLib/Utilities/FBRunLoopSpinner.m` -- Test: `WebDriverAgentTests/UnitTests/FBRunLoopSpinnerTests.m` - -**Interfaces:** -- Consumes: nothing new. -- Produces: `+ (BOOL)spinUntilCompletion:(void (^)(void(^completion)(void)))block timeout:(NSTimeInterval)timeout;` — returns `YES` when `completion` fired before the deadline, `NO` on timeout. Task 2 calls this. - -- [ ] **Step 1: Write the failing tests** - -Append to `WebDriverAgentTests/UnitTests/FBRunLoopSpinnerTests.m` (inside the existing `FBRunLoopSpinnerTests` implementation, before `@end`): - -```objc -- (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(); -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `xcodebuild test ... -only-testing:UnitTests/FBRunLoopSpinnerTests CODE_SIGNING_ALLOWED=NO` (full command from Global Constraints) -Expected: BUILD FAILURE — `no known class method for selector 'spinUntilCompletion:timeout:'` (a compile error is the RED state here). - -- [ ] **Step 3: Implement the bounded spin** - -In `FBRunLoopSpinner.h`, after the existing `spinUntilCompletion:` declaration: - -```objc -/** - 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; -``` - -In `FBRunLoopSpinner.m`, replace the existing `+spinUntilCompletion:` implementation with a delegating pair: - -```objc -+ (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; -} -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: same command as Step 2. -Expected: all `FBRunLoopSpinnerTests` PASS (including the three pre-existing tests — the no-timeout delegation must not regress them). - -- [ ] **Step 5: Commit** - -```bash -git add WebDriverAgentLib/Utilities/FBRunLoopSpinner.h WebDriverAgentLib/Utilities/FBRunLoopSpinner.m WebDriverAgentTests/UnitTests/FBRunLoopSpinnerTests.m -git commit -m "feat: add bounded variant of the run loop completion spinner" -``` - ---- - -### Task 2: Bounded event synthesis (`FBConfiguration` margin + `FBXCTestDaemonsProxy`) - -**Files:** -- Modify: `WebDriverAgentLib/Utilities/FBConfiguration.h` -- Modify: `WebDriverAgentLib/Utilities/FBConfiguration.m` -- Modify: `WebDriverAgentLib/Utilities/FBXCTestDaemonsProxy.m` (`synthesizeEventWithRecord:error:`) -- Modify: `docs/mobilerun-actions.md` -- Test: `WebDriverAgentTests/UnitTests/FBConfigurationTests.m` - -**Interfaces:** -- Consumes: `+[FBRunLoopSpinner spinUntilCompletion:timeout:]` from Task 1. -- Produces: `- (NSTimeInterval)eventSynthesisTimeoutMargin;` on `FBConfiguration` (instance method on the shared singleton, like `httpRequestBodySizeLimit`). No later task depends on this; it completes the "survive" wait-bounding. - -- [ ] **Step 1: Write the failing tests** - -Append inside the existing `FBConfigurationTests` implementation in `WebDriverAgentTests/UnitTests/FBConfigurationTests.m`: - -```objc -- (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"); -} -``` - -Note: `FBConfiguration.sharedInstance` is how config is accessed in this fork (singleton since the upstream v16.2 merge). If `FBConfigurationTests.m` already manipulates env vars differently, follow its local pattern for set/unset but keep the assertions. - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `xcodebuild test ... -only-testing:UnitTests/FBConfigurationTests CODE_SIGNING_ALLOWED=NO` -Expected: BUILD FAILURE — `no visible @interface for 'FBConfiguration' declares the selector 'eventSynthesisTimeoutMargin'`. - -- [ ] **Step 3: Implement the margin property** - -`FBConfiguration.h` — add near the other timeout-ish instance methods (e.g. next to `httpRequestBodySizeLimit`): - -```objc -/** - 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; -``` - -`FBConfiguration.m` — add next to `httpRequestBodySizeLimit` (uses `getenv` rather than `NSProcessInfo` so the value is not frozen at first access — `NSProcessInfo.environment` caches, which would break both env-based tests and runtime tuning): - -```objc -static const NSTimeInterval DefaultEventSynthesisTimeoutMargin = 15.0; - -- (NSTimeInterval)eventSynthesisTimeoutMargin -{ - const char *rawMargin = getenv("EVENT_SYNTHESIS_TIMEOUT_MARGIN"); - if (rawMargin != NULL) { - double parsedMargin = atof(rawMargin); - if (parsedMargin > 0) { - return parsedMargin; - } - } - return DefaultEventSynthesisTimeoutMargin; -} -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: same command as Step 2. Expected: PASS (all of `FBConfigurationTests`). - -- [ ] **Step 5: Bound the synthesis wait** - -In `WebDriverAgentLib/Utilities/FBXCTestDaemonsProxy.m`: - -Add to the imports (it is not imported today; the type currently arrives via the header's forward declaration): - -```objc -#import "XCSynthesizedEventRecord.h" -``` - -Replace the body of `+ (BOOL)synthesizeEventWithRecord:(XCSynthesizedEventRecord *)record error:(NSError *__autoreleasing*)error` with: - -```objc - __block NSError *innerError = nil; - // 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; - } - completion(); - }; - - void (^handlerBlock)(XCSynthesizedEventRecord *, NSError *) = ^(XCSynthesizedEventRecord *innerRecord, NSError *invokeError) { - errorHandler(invokeError); - }; - [[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; - } - return NO; - } - return YES; -``` - -(Only the wrapping changed: `spinUntilCompletion:` → bounded variant + the `didComplete` check. The inner blocks are byte-identical to today's.) - -- [ ] **Step 6: Verify the library still compiles and the spinner/config tests still pass** - -Run: `xcodebuild test ... -only-testing:UnitTests/FBRunLoopSpinnerTests -only-testing:UnitTests/FBConfigurationTests CODE_SIGNING_ALLOWED=NO` -Expected: BUILD OK, all listed tests PASS. (The synthesize path itself needs a real testmanagerd and is exercised by the existing integration suites + on-device validation, not unit tests.) - -- [ ] **Step 7: Document the behavior** - -In `docs/mobilerun-actions.md`, the `## Responses` section (starts line ~58) documents error responses. Append this bullet to that section: - -```markdown -- `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. -``` - -- [ ] **Step 8: Commit** - -```bash -git add WebDriverAgentLib/Utilities/FBConfiguration.h WebDriverAgentLib/Utilities/FBConfiguration.m \ - WebDriverAgentLib/Utilities/FBXCTestDaemonsProxy.m WebDriverAgentTests/UnitTests/FBConfigurationTests.m \ - docs/mobilerun-actions.md -git commit -m "feat: fail unacknowledged event synthesis with an error instead of blocking forever" -``` - ---- - -### Task 3: `FBRoute` control-queue flag - -**Files:** -- Modify: `WebDriverAgentLib/Routing/FBRoute.h` -- Modify: `WebDriverAgentLib/Routing/FBRoute.m` -- Test: `WebDriverAgentTests/UnitTests/FBRouteTests.m` - -**Interfaces:** -- Consumes: nothing new. -- Produces: `@property (nonatomic, assign, readonly) BOOL usesControlQueue;` and chainable `- (instancetype)onControlQueue;` on `FBRoute`. The flag must survive `withoutSession` and both `respondWithTarget:action:` / `respondWithBlock:` (those constructors create a **new** route object — the flag must be copied over, exactly like `requiresSession` is today). Task 4 reads `route.usesControlQueue`. - -- [ ] **Step 1: Write the failing tests** - -Append inside the existing `FBRouteTests` implementation in `WebDriverAgentTests/UnitTests/FBRouteTests.m`: - -```objc -- (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); -} -``` - -If `FBRouteTests.m` does not already import `FBResponsePayload.h`, add `#import "FBResponsePayload.h"` and `#import "FBRouteRequest.h"` next to the existing imports. - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `xcodebuild test ... -only-testing:UnitTests/FBRouteTests CODE_SIGNING_ALLOWED=NO` -Expected: BUILD FAILURE — `property 'usesControlQueue' not found on object of type 'FBRoute *'`. - -- [ ] **Step 3: Implement the flag** - -`FBRoute.h` — add below the existing `path` property and next to the other chainable (`withoutSession` is declared further down; keep declarations adjacent to their kin): - -```objc -/*! 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; -``` - -and next to the `withoutSession` declaration: - -```objc -/** - 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; -``` - -`FBRoute.m`: - -1. In the class extension at the top, add: - ```objc - @property (nonatomic, assign, readwrite) BOOL usesControlQueue; - ``` -2. Next to `- (instancetype)withoutSession`, add: - ```objc - - (instancetype)onControlQueue - { - self.usesControlQueue = YES; - return self; - } - ``` -3. In `respondWithBlock:` and `respondWithTarget:action:`, copy the flag onto the newly created route (both methods build a fresh `FBRoute_Sync` / `FBRoute_TargetAction`): - ```objc - route.usesControlQueue = self.usesControlQueue; - ``` - placed right after the existing property assignments in each method. - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: same command as Step 2. Expected: all `FBRouteTests` PASS. - -- [ ] **Step 5: Commit** - -```bash -git add WebDriverAgentLib/Routing/FBRoute.h WebDriverAgentLib/Routing/FBRoute.m WebDriverAgentTests/UnitTests/FBRouteTests.m -git commit -m "feat: allow marking routes to be served off the automation queue" -``` - ---- - -### Task 4: HTTP layer split (per-connection queues + per-route dispatch) - -**Files:** -- Modify: `WebDriverAgentLib/Vendor/CocoaHTTPServer/HTTPServer.m` (the `config` method, currently returning `[[HTTPConfig alloc] initWithServer:self documentRoot:documentRoot queue:connectionQueue]` around line 338) -- Modify: `WebDriverAgentLib/Routing/FBWebServer.m` -- Modify: `WebDriverAgentLib/Commands/FBUnknownCommands.m` -- Modify: `WebDriverAgentLib/Commands/FBSessionCommands.m` (the `/status` route registration) -- Modify: `WebDriverAgentLib/Commands/FBScreenCaptureCommands.m` (route registrations) -- Test: `WebDriverAgentTests/UnitTests/FBRouteTests.m` (add a **second test-case class** `FBWebServerDispatchTests` in the same file — no new file, to avoid pbxproj churn) - -**Interfaces:** -- Consumes: `route.usesControlQueue` from Task 3. -- Produces: no new API. Behavioral contract for later tasks and the portal: control-marked routes respond while the main queue is busy; all other routes keep main-queue semantics. - -- [ ] **Step 1: Write the failing dispatch tests** - -Append to `WebDriverAgentTests/UnitTests/FBRouteTests.m` (after the `FBRouteTests` `@end`, as a separate test-case class): - -```objc -#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; - -@interface FBWebServer (DispatchTests) -- (void)registerRouteHandlers:(NSArray *)commandHandlerClasses; -- (RoutingHTTPServer *)server; -@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(); - }], - ]; -} - -@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); - - self.webServer = [FBWebServer new]; - 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)); -} - -@end -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `xcodebuild test ... -only-testing:UnitTests/FBWebServerDispatchTests CODE_SIGNING_ALLOWED=NO` -Expected: `testControlRouteRespondsWhileMainThreadIsBusy` and `testControlRouteRespondsWhileAutomationRouteIsBlocked` FAIL (today every route is dispatched to the main queue, which the sleep-poll never services; the requests are also serialized behind the shared connection queue). `testAutomationRouteRunsOnMainQueue` may already pass — that is expected: it pins today's semantics so the refactor cannot regress them. - -- [ ] **Step 3: Implement per-connection queues** - -In `WebDriverAgentLib/Vendor/CocoaHTTPServer/HTTPServer.m`, find the `config` method (returns the `HTTPConfig` with `queue:connectionQueue`) and change it to pass no queue: - -```objc -- (HTTPConfig *)config -{ - // Override me if you want to provide a custom config to the new connection. - // - // Generally this involves overriding the HTTPConfig class to include any custom settings, - // and then having this method return an instance of 'MyHTTPConfig'. - - // Note: Think you can make the server faster by putting each connection on its own queue? - // Then benchmark it before and after and discover for yourself the shocking truth! - // - // Try the apache benchmark tool (already installed on your Mac): - // $ ab -n 1000 -c 1 http://localhost:/some_path.html - - // 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]; -} -``` - -(Keep the surrounding comments if they differ slightly — the functional change is `queue:connectionQueue` → `queue:NULL`. Everything else about `connectionQueue` in that file stays: it is still used for the server's own bookkeeping.) - -- [ ] **Step 4: Implement per-route dispatch in FBWebServer** - -In `WebDriverAgentLib/Routing/FBWebServer.m`: - -1. In `startHTTPServer`, restrict the global route queue to watchOS (the `FBWatchHTTPServer` keeps today's behavior; `RoutingHTTPServer` now gets per-route dispatch): - - ```objc - #if TARGET_OS_WATCH - [self.server setRouteQueue:dispatch_get_main_queue()]; - #endif - ``` - - (replacing the unconditional `[self.server setRouteQueue:dispatch_get_main_queue()];`) - -2. Replace the registered block's mount portion in `registerRouteHandlers:` — the block body after `[FBLogger verboseLog:routeParams.description];` currently is: - - ```objc - @try { - [route mountRequest:routeParams intoResponse:response]; - } - @catch (NSException *exception) { - [strongSelf handleException:exception forResponse:response]; - } - ``` - - Replace it with: - - ```objc - #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 { - dispatch_sync(dispatch_get_main_queue(), ^{ - @autoreleasepool { - [strongSelf mountRoute:route request:routeParams intoResponse:response]; - } - }); - } - #endif - ``` - -3. Add the extracted mount helper next to `handleException:forResponse:`: - - ```objc - - (void)mountRoute:(FBRoute *)route request:(FBRouteRequest *)routeParams intoResponse:(RouteResponse *)response - { - @try { - [route mountRequest:routeParams intoResponse:response]; - } - @catch (NSException *exception) { - [self handleException:exception forResponse:response]; - } - } - ``` - - `FBRoute` is already visible via `FBCommandHandler.h`/route usage; add `#import "FBRoute.h"` to the imports if the compiler complains. - -4. In `registerServerKeyRouteHandlers`, the `/wda/shutdown` block currently calls the delegate inline. With no global route queue that block now runs on a connection queue while the delegate tears down XCUI state — hop the delegate call to the main queue asynchronously so the response is not held hostage by a busy automation queue: - - ```objc - [self.server get:@"/wda/shutdown" withBlock:^(RouteRequest *request, RouteResponse *response) { - __strong typeof(weakSelf) strongSelf = weakSelf; - if (nil == strongSelf) { - return; - } - [response respondWithString:@"Shutting down"]; - // 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]; - }); - }]; - ``` - - (`/health` and `/calibrate` need no change — they respond with static strings and are safe on the connection queue.) - -- [ ] **Step 5: Mark the control routes** - -1. `WebDriverAgentLib/Commands/FBUnknownCommands.m` — the fallback handler only builds an error payload; keep it responsive during a wedge. All four routes become: - - ```objc - [[[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:)] - ``` - -2. `WebDriverAgentLib/Commands/FBSessionCommands.m` — `/status` reads only bundle/env/UIDevice info and socket interfaces; mark it: - - ```objc - [[[FBRoute GET:@"/status"].withoutSession onControlQueue] respondWithTarget:self action:@selector(handleGetStatus:)], - ``` - -3. `WebDriverAgentLib/Commands/FBScreenCaptureCommands.m` — mark the pure video-manager routes (both the session and sessionless variants): `POST .../broadcast/stop` and `POST .../broadcast/start` **stay unmarked** (they drive the system broadcast picker via XCUI), and `POST /mobilerun/screencapture/start` **stays unmarked** (it touches `XCUIScreen`). Mark these with `onControlQueue` (wrap each existing registration as `[[[FBRoute ...] ...] onControlQueue]` keeping `withoutSession` where present): - - `GET /mobilerun/screencapture/broadcast` (status read) - - `POST /mobilerun/screencapture/stop` - - `GET /mobilerun/screencapture` - - `GET /mobilerun/screencapture/:id` - - `POST /mobilerun/screencapture/:id/stop` - - `POST /mobilerun/screencapture/:id/keyframe` - - Example for one line: - - ```objc - [[[FBRoute POST:@"/mobilerun/screencapture/:id/keyframe"] onControlQueue] respondWithTarget:self action:@selector(handleRequestKeyFrame:)], - ``` - - Rationale to keep in mind (not as comments on every line): these handlers only touch `FBVideoStreamManager` / `FBBroadcastManager`, which are `@synchronized`- and serial-queue-protected and already run capture work off-main in production. - -- [ ] **Step 6: Run the dispatch tests to verify they pass** - -Run: `xcodebuild test ... -only-testing:UnitTests/FBWebServerDispatchTests -only-testing:UnitTests/FBRouteTests CODE_SIGNING_ALLOWED=NO` -Expected: all PASS — control probes answer with the main thread busy; the automation probe still runs on the main queue. - -- [ ] **Step 7: Verify all platforms still build** - -Run (fast sanity, generic destinations, no signing): - -```bash -xcodebuild build -project WebDriverAgent.xcodeproj -scheme WebDriverAgentRunner -destination 'generic/platform=iOS' CODE_SIGNING_ALLOWED=NO -xcodebuild build -project WebDriverAgent.xcodeproj -scheme WebDriverAgentRunner_tvOS -destination 'generic/platform=tvOS' CODE_SIGNING_ALLOWED=NO ARCHS=arm64 -xcodebuild build -project WebDriverAgent.xcodeproj -scheme WebDriverAgentRunner_watchOS -destination 'generic/platform=watchOS' CODE_SIGNING_ALLOWED=NO ARCHS=arm64 -``` - -Expected: all three BUILD SUCCEEDED (watchOS exercises the `TARGET_OS_WATCH` branches). - -- [ ] **Step 8: Commit** - -```bash -git add WebDriverAgentLib/Vendor/CocoaHTTPServer/HTTPServer.m WebDriverAgentLib/Routing/FBWebServer.m \ - WebDriverAgentLib/Commands/FBUnknownCommands.m WebDriverAgentLib/Commands/FBSessionCommands.m \ - WebDriverAgentLib/Commands/FBScreenCaptureCommands.m WebDriverAgentTests/UnitTests/FBRouteTests.m -git commit -m "feat: serve status and capture control routes off the automation queue" -``` - ---- - -### Task 5: Capture pixel cap (`maxPixels` + device-class default) - -**Files:** -- Modify: `WebDriverAgentLib/Utilities/FBVideoStreamSession.h` (`FBScreenCaptureConfiguration` interface) -- Modify: `WebDriverAgentLib/Utilities/FBVideoStreamSession.m` -- Modify: `WebDriverAgentLib/Commands/FBScreenCaptureCommands.m` (`handleStartScreenCapture:`) -- Modify: `docs/mobilerun-screencapture.md` -- Test: `WebDriverAgentTests/UnitTests/FBVideoStreamSessionTests.m` - -**Interfaces:** -- Consumes: nothing from other tasks (independent of Tasks 1–4). -- Produces (class methods on `FBScreenCaptureConfiguration`): - - `+ (NSString *)fb_machineModel;` — raw device model identifier (e.g. `iPhone11,2`), sysctl-backed with a simulator fallback. - - `+ (NSUInteger)fb_defaultPixelBudgetForMachineModel:(NSString *)machineModel;` — `370944` for iPhone majors ≤ 11, else `0` (no cap). - - `+ (CGSize)fb_sizeForWidth:(NSUInteger)width height:(NSUInteger)height pixelBudget:(NSUInteger)budget;` — aspect-preserving, even-aligned clamp; returns the input unchanged when `budget == 0` or already within budget. - -- [ ] **Step 1: Write the failing tests** - -Append inside the existing test-case implementation in `WebDriverAgentTests/UnitTests/FBVideoStreamSessionTests.m` (add `#import "FBVideoStreamSession.h"` to its imports if not present): - -```objc -- (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]); -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `xcodebuild test ... -only-testing:UnitTests/FBVideoStreamSessionTests CODE_SIGNING_ALLOWED=NO` -Expected: BUILD FAILURE — `no known class method for selector 'fb_defaultPixelBudgetForMachineModel:'`. - -- [ ] **Step 3: Implement the helpers** - -`WebDriverAgentLib/Utilities/FBVideoStreamSession.h` — add to the `FBScreenCaptureConfiguration` interface (after the `port` property): - -```objc -/** - 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. - */ -+ (CGSize)fb_sizeForWidth:(NSUInteger)width height:(NSUInteger)height pixelBudget:(NSUInteger)budget; -``` - -`WebDriverAgentLib/Utilities/FBVideoStreamSession.m` — add `#import ` to the imports and implement inside the `FBScreenCaptureConfiguration` implementation block: - -```objc -// 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 -{ - if (0 == budget || 0 == width || 0 == height || width * height <= budget) { - return CGSizeMake(width, height); - } - double scale = sqrt((double)budget / (double)(width * 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; - return CGSizeMake(MAX(scaledWidth, (NSUInteger)2), MAX(scaledHeight, (NSUInteger)2)); -} -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: same command as Step 2. Expected: all `FBVideoStreamSessionTests` PASS. - -- [ ] **Step 5: Wire the cap into the start endpoint** - -In `WebDriverAgentLib/Commands/FBScreenCaptureCommands.m`, `handleStartScreenCapture:` — after the existing `width`/`height` positive validation and **before** `FBScreenCaptureConfiguration *configuration = ...`, insert: - -```objc - NSUInteger pixelBudget = 0; - id maxPixels = request.arguments[@"maxPixels"]; - if (nil == maxPixels) { - pixelBudget = [FBScreenCaptureConfiguration fb_defaultPixelBudgetForMachineModel:[FBScreenCaptureConfiguration fb_machineModel]]; - } else if (![maxPixels isKindOfClass:NSNumber.class] || ((NSNumber *)maxPixels).integerValue < 0) { - return FBResponseWithStatus([FBCommandStatus invalidArgumentErrorWithMessage:@"'maxPixels' must be a non-negative integer (0 disables the capture size cap)" traceback:nil]); - } else { - pixelBudget = ((NSNumber *)maxPixels).unsignedIntegerValue; - } - 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; - } -``` - -The existing even-alignment lines (`configuration.width = (NSUInteger)(width - (width % 2));` etc.) stay as they are and now operate on the capped values. Add `#import "FBLogger.h"` to the file's imports if it is not already there. - -- [ ] **Step 6: Run the full unit bundle as a regression check** - -Run: `xcodebuild test ... -only-testing:UnitTests CODE_SIGNING_ALLOWED=NO` -Expected: PASS (entire `UnitTests` bundle). - -- [ ] **Step 7: Document the argument** - -`docs/mobilerun-screencapture.md`: - -1. In the `## Start arguments (JSON body)` table, add a row after the `fps` row: - - ```markdown - | `maxPixels` | int | no | device-dependent | Upper bound on `width×height`. Larger requests are scaled down aspect-preserving (rounded down to even). `0` disables the cap. When omitted, devices with an A12 chip or older default to `370944` (≈414×896); newer devices are uncapped. | - ``` - -2. In `## Notes / gotchas`, add a bullet: - - ```markdown - - 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. - ``` - -- [ ] **Step 8: Commit** - -```bash -git add WebDriverAgentLib/Utilities/FBVideoStreamSession.h WebDriverAgentLib/Utilities/FBVideoStreamSession.m \ - WebDriverAgentLib/Commands/FBScreenCaptureCommands.m WebDriverAgentTests/UnitTests/FBVideoStreamSessionTests.m \ - docs/mobilerun-screencapture.md -git commit -m "feat: cap screen capture size via maxPixels with a safe default on older devices" -``` - ---- - -### Task 6: Full verification - -**Files:** none new — verification only. - -**Interfaces:** n/a. - -- [ ] **Step 1: Run the complete unit test bundle** - -Run: `xcodebuild test -project WebDriverAgent.xcodeproj -scheme WebDriverAgentRunner -destination 'platform=iOS Simulator,name=iPhone 17' -only-testing:UnitTests CODE_SIGNING_ALLOWED=NO` -Expected: all tests PASS. - -- [ ] **Step 2: Build all three platforms** - -```bash -xcodebuild build -project WebDriverAgent.xcodeproj -scheme WebDriverAgentRunner -destination 'generic/platform=iOS' CODE_SIGNING_ALLOWED=NO -xcodebuild build -project WebDriverAgent.xcodeproj -scheme WebDriverAgentRunner_tvOS -destination 'generic/platform=tvOS' CODE_SIGNING_ALLOWED=NO ARCHS=arm64 -xcodebuild build -project WebDriverAgent.xcodeproj -scheme WebDriverAgentRunner_watchOS -destination 'generic/platform=watchOS' CODE_SIGNING_ALLOWED=NO ARCHS=arm64 -``` - -Expected: three times BUILD SUCCEEDED. - -- [ ] **Step 3: Smoke the mobilerun actions integration suite on the simulator** - -This exercises the real `/mobilerun/actions` → synthesize path (now running through the bounded wait) end to end: - -```bash -xcodebuild test -project WebDriverAgent.xcodeproj -scheme IntegrationTests_3 \ - -destination 'platform=iOS Simulator,name=iPhone 17' \ - -only-testing:IntegrationTests_3/FBMobilerunActionsIntegrationTests CODE_SIGNING_ALLOWED=NO -``` - -Expected: PASS. (Known environment caveat: some unrelated tap-to-alert tests in this scheme fail on current simulators regardless of changes — only the mobilerun actions class is in scope here.) - -- [ ] **Step 4: Verify the working tree is clean and every change is committed** - -Run: `git status --short` → empty; `git log --oneline origin/master..HEAD` → the spec commit plus one commit per task. - -- [ ] **Step 5: Hand off** - -Implementation done. Next: superpowers:finishing-a-development-branch (PR against `droidrun/WebDriverAgent` master — always pass `--repo droidrun/WebDriverAgent` to `gh`, since bare `gh` targets the appium upstream). Keep the PR description generic: what changed and why at the mechanism level, no fleet/infra details. On-device fleet validation (sustained capture + hammer, portal-side probes) happens after merge and is tracked on the ticket, not in the PR. diff --git a/docs/superpowers/specs/2026-08-19-runner-reliability-capture-load-design.md b/docs/superpowers/specs/2026-08-19-runner-reliability-capture-load-design.md deleted file mode 100644 index e041c253c9..0000000000 --- a/docs/superpowers/specs/2026-08-19-runner-reliability-capture-load-design.md +++ /dev/null @@ -1,161 +0,0 @@ -# Runner reliability under capture load — design - -Date: 2026-08-19 - -## Problem - -On lower-end devices (A12 and older), heavy screen-capture load (ReplayKit broadcast + -high-resolution hardware encoding) can make backboardd shed synthesized HID events -(`kIOReturnNoMemory` enqueue drops). When a synthesized touch event is dropped, -testmanagerd never delivers the `TouchEventsCompleted` confirmation, and the runner-side -wait blocks forever: - -- `FBXCTestDaemonsProxy synthesizeEventWithRecord:` waits via - `+[FBRunLoopSpinner spinUntilCompletion:]`, which has **no timeout** and spins the main - run loop indefinitely. -- Every WDA route is `dispatch_sync`ed onto the **main queue** - (`[FBWebServer startHTTPServer]` sets `routeQueue` to the main queue), so one lost - completion permanently blocks all automation endpoints. -- All HTTP connections share **one serial socket queue** (`HTTPServer.m` creates a single - `connectionQueue` passed to every `HTTPConnection`), so the blocked `dispatch_sync` - also prevents parsing of any further request on any connection — even `/status` and - keyframe requests wedge. The runner never self-recovers. - -The fix has three independent parts: keep the HTTP layer responsive (queue split), make -lost waits fail a single request instead of the whole agent (bounded waits), and reduce -the capture load that triggers event shedding in the first place (capture pixel cap). - -## Goals - -- A lost/wedged XCUI synthesis wait fails that one request with a 5xx; `/status`, - keyframe, and subsequent requests keep working. -- Control endpoints stay responsive even while an automation request is blocked. -- Capture requests are capped to a safe pixel budget on older chips by default, with an - explicit API override. - -## Non-goals - -- Portal-side recovery orchestration (separate component). -- Bounding native `XCUIElement` gesture waits (element tap/swipe/pinch go through - XCTest-internal wait machinery, not our spinner; unchanged). -- MJPEG server changes (already runs on its own socket/queues). - -## Part 1 — HTTP layer: per-connection queues + control-route split - -### Per-connection socket queues - -`HTTPServer` currently creates one serial `connectionQueue` and hands it to every -connection via `HTTPConfig`. Change the vendored `HTTPServer` to pass a nil queue so each -`HTTPConnection` creates its own queue (this is stock CocoaHTTPServer behavior when no -queue is provided). One blocked request then only stalls its own TCP connection. - -### Route dispatch split - -Remove the global `setRouteQueue(main)`. Dispatch per route inside -`-[FBWebServer registerRouteHandlers:]`: - -- **Automation routes** (default): `dispatch_sync` onto the main queue — semantics - identical to today for everything that touches XCUI / testmanagerd. These requests are - additionally serialized through a dedicated funnel queue so at most one is ever in - flight, preserving pre-change one-at-a-time semantics against nested run-loop draining - (a second request could otherwise execute reentrantly inside a first handler that spins - the run loop, e.g. `FBRunLoopSpinner`). -- **Control routes**: run inline on the connection's own queue. Marked with a new - chainable `FBRoute` flag (`.onControlQueue`). Only routes whose handlers never touch - XCUI and whose backing state is thread-safe qualify: - - `/status` (reads bundle/env/UIDevice info only) - - `/health`, `/calibrate`, `/wda/shutdown` (registered directly on the server; run on - the connection queue automatically once no global route queue is set — the shutdown - delegate hop must be audited for thread safety) - - `/mobilerun/screencapture` family: stop / stop-all / list / get / keyframe - (`FBVideoStreamManager` is `@synchronized`-guarded and does its work on its own - background queue). **start** stays on the automation queue — it reads - `XCUIScreen.mainScreen`, which violates the never-touches-XCUI rule. Only the - sessionless variants are served on the connection queue — session-bound lookups stay - on the automation queue because the session store is main-queue state. - - The unknown-endpoint fallback (`FBUnknownCommands`) — it only builds an error - payload, and a wedged agent should still say "no such route" instead of hanging. - - `GET /mobilerun/screencapture/broadcast` (status read; `FBBroadcastManager` state - reads must be audited/made atomic). Like the rest of the capture family, only the - sessionless variant is served on the connection queue. - -Broadcast **start/stop** stay on the main queue: they drive the system broadcast picker -through XCUI. `/mobilerun/state` also stays on the main queue **deliberately**: it is the -liveness probe that must reflect a wedged automation queue by timing out or erroring. - -## Part 2 — Bounded synthesis waits - -- Add `+[FBRunLoopSpinner spinUntilCompletion:timeout:]` returning `BOOL` (`NO` when the - deadline passes before the completion fires). The existing no-timeout variant remains - for callers not yet migrated. -- `+[FBXCTestDaemonsProxy synthesizeEventWithRecord:error:]` computes - `timeout = record.maximumOffset + margin`: - - `maximumOffset` is the total scheduled duration of the synthesized event record, so - quick taps get a short deadline while long W3C action chains still fit. - - `margin` is a new `FBConfiguration` property (default **15 s**), overridable via env - var so it can be tuned without rebuilding. -- On timeout the method fails with a descriptive `NSError`; command handlers already map - that to a 5xx (`FBResponseWithUnknownError`). The portal treats 5xx as - relaunch-worthy, which is the desired escalation. -- A late completion after the deadline is harmless: the completion block only flips a - heap-allocated atomic flag that nobody reads anymore. -- No attempt is made to "clean up" a possibly stuck touch (e.g. down without up); after - a synthesis failure the client is expected to recover the session. -- Covered call sites (all funnel through this one proxy method): `/mobilerun/actions`, - W3C `/actions`, and typing (`XCUIElement+FBTyping` / `FBKeyboard`). - -## Part 3 — Capture pixel cap - -- `POST /mobilerun/screencapture/start` gains an optional integer argument - `maxPixels` (`0` = explicitly uncapped). -- When `width × height > maxPixels`, WDA scales the requested dimensions down - aspect-preserving (`scale = sqrt(maxPixels / (w·h))`), rounding to even values (HW - encoder requirement). -- When `maxPixels` is absent, a device-class default applies: - - A12-and-older chips → **370 944 px** (equivalent to 414×896, a budget verified safe - for sustained 60 fps HEVC capture on that hardware class). - - Newer chips → uncapped. - - Detection via `hw.machine` sysctl: iPhone models with a major version of 11 or - lower (`iPhone11,*` = A12) are classified A12-and-older. Non-iPhone and unknown - models are treated as uncapped — only this hardware class has shown HID event - shedding, and mis-capping newer devices would silently degrade capture quality. -- fps is not touched: the pixel budget alone is what distinguishes the safe from the - wedging configuration on the affected hardware class. -- The clamped dimensions flow through the existing config → session → `SESSION_ADD` - path, and the actual size is already reported back via the session dictionary and the - stream's `VIDEO_PARAMS`, so consumers adapt without changes. - -## Files touched (expected) - -- `WebDriverAgentLib/Vendor/CocoaHTTPServer/HTTPServer.m` — per-connection queues -- `WebDriverAgentLib/Routing/FBWebServer.m` — remove global route queue, per-route dispatch -- `WebDriverAgentLib/Routing/FBRoute.{h,m}` — `.onControlQueue` chainable flag -- `WebDriverAgentLib/Utilities/FBRunLoopSpinner.{h,m}` — timeout variant -- `WebDriverAgentLib/Utilities/FBXCTestDaemonsProxy.m` — bounded synthesis wait -- `WebDriverAgentLib/Utilities/FBConfiguration.{h,m}` — synthesis margin property -- `WebDriverAgentLib/Commands/FBScreenCaptureCommands.m` — `maxPixels` argument -- `WebDriverAgentLib/Utilities/FBVideoStreamSession.{h,m}` — device-class detection + clamp - math as class methods on `FBScreenCaptureConfiguration` (unit-testable pure functions; no - new files so the Xcode project file stays untouched) -- `docs/mobilerun-screencapture.md`, `docs/mobilerun-actions.md` — API docs - -## Error handling - -- Synthesis timeout → `NSError` → 5xx on the single request; agent keeps serving. -- Capture clamp never fails a request: it only shrinks dimensions (invalid `maxPixels` - values, e.g. negative or non-numeric, are rejected as `invalid argument`). -- Queue split changes no response semantics; control handlers must not throw XCUI-related - exceptions since they never call XCUI. - -## Testing - -- **Unit** (`UnitTests` target): spinner timeout (fires/expires), clamp math (aspect - ratio, even alignment, no-op when under budget), device-model→budget mapping with - injected model strings. -- **Integration** (simulator): with the server running, saturate the main queue with a - long-running block and assert control routes (`/status`, screencapture list) still - answer while an automation route blocks; assert a synthesis wait that never completes - returns a 5xx within its deadline. -- **On-device validation** (manual, fleet): sustained broadcast capture + continuous - automation on an A12 device without a wedge; wedge injection recovers per request - instead of killing the runner.