appium-webdriveragent 16.7.2 → 16.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## [16.7.3](https://github.com/appium/WebDriverAgent/compare/v16.7.2...v16.7.3) (2026-08-24)
2
+
3
+ ### Bug Fixes
4
+
5
+ * let /status, /screenshot, and DELETE /session API methods to bypass the dispatch queue ([#1222](https://github.com/appium/WebDriverAgent/issues/1222)) ([f99b011](https://github.com/appium/WebDriverAgent/commit/f99b0111bba6f5cceabbddf1f9f0144d69d8f168))
6
+
1
7
  ## [16.7.2](https://github.com/appium/WebDriverAgent/compare/v16.7.1...v16.7.2) (2026-08-24)
2
8
 
3
9
  ### Bug Fixes
package/README.md CHANGED
@@ -42,15 +42,5 @@ Then, you find `WebDriverAgentRunner-Runner-sim-<version>.zip` for iOS and `Web
42
42
 
43
43
  [`WebDriverAgent` is BSD-licensed](LICENSE).
44
44
 
45
- ## Third Party Sources
46
-
47
- WebDriverAgent depends on the following third-party frameworks:
48
- - [CocoaHTTPServer](https://github.com/robbiehanson/CocoaHTTPServer)
49
- - [RoutingHTTPServer](https://github.com/mattstevens/RoutingHTTPServer)
50
-
51
- These projects haven't been maintained in a while. That's why the source code of these
52
- projects has been integrated directly in the WebDriverAgent source tree.
53
-
54
- You can find the source files and their licenses in the `WebDriverAgentLib/Vendor` directory.
55
45
 
56
46
  Have fun!
@@ -18,8 +18,8 @@
18
18
  {
19
19
  return
20
20
  @[
21
- [[FBRoute GET:@"/screenshot"].withoutSession respondWithTarget:self action:@selector(handleGetScreenshot:)],
22
- [[FBRoute GET:@"/screenshot"] respondWithTarget:self action:@selector(handleGetScreenshot:)],
21
+ [[FBRoute GET:@"/screenshot"].withoutSession.standalone respondWithTarget:self action:@selector(handleGetScreenshot:)],
22
+ [[FBRoute GET:@"/screenshot"].standalone respondWithTarget:self action:@selector(handleGetScreenshot:)],
23
23
  ];
24
24
  }
25
25
 
@@ -47,8 +47,8 @@
47
47
  [[FBRoute POST:@"/wda/apps/state"] respondWithTarget:self action:@selector(handleSessionAppState:)],
48
48
  [[FBRoute GET:@"/wda/apps/list"] respondWithTarget:self action:@selector(handleGetActiveAppsList:)],
49
49
  [[FBRoute GET:@""] respondWithTarget:self action:@selector(handleGetActiveSession:)],
50
- [[FBRoute DELETE:@""] respondWithTarget:self action:@selector(handleDeleteSession:)],
51
- [[FBRoute GET:@"/status"].withoutSession respondWithTarget:self action:@selector(handleGetStatus:)],
50
+ [[FBRoute DELETE:@""].standalone respondWithTarget:self action:@selector(handleDeleteSession:)],
51
+ [[FBRoute GET:@"/status"].withoutSession.standalone respondWithTarget:self action:@selector(handleGetStatus:)],
52
52
 
53
53
  // Health check might modify simulator state so it should only be called in-between testing sessions
54
54
  [[FBRoute GET:@"/wda/healthcheck"].withoutSession respondWithTarget:self action:@selector(handleGetHealthCheck:)],
@@ -89,9 +89,7 @@
89
89
 
90
90
  + (id<FBResponsePayload>)handleCreateSession:(FBRouteRequest *)request
91
91
  {
92
- if (nil != FBSession.activeSession) {
93
- [FBSession.activeSession kill];
94
- }
92
+ [FBSession killActiveSessionAndWaitForTeardown];
95
93
 
96
94
  NSDictionary<NSString *, id> *capabilities;
97
95
  id<FBResponsePayload> errorResponse = [self capabilitiesFromCreateSessionRequest:request
@@ -15,11 +15,11 @@
15
15
  <key>CFBundlePackageType</key>
16
16
  <string>FMWK</string>
17
17
  <key>CFBundleShortVersionString</key>
18
- <string>16.7.2</string>
18
+ <string>16.7.3</string>
19
19
  <key>CFBundleSignature</key>
20
20
  <string>????</string>
21
21
  <key>CFBundleVersion</key>
22
- <string>16.7.2</string>
22
+ <string>16.7.3</string>
23
23
  <key>NSPrincipalClass</key>
24
24
  <string/>
25
25
  </dict>
@@ -47,17 +47,41 @@ NS_ASSUME_NONNULL_BEGIN
47
47
 
48
48
  /**
49
49
  Registers a route handler for the given HTTP method and path pattern (":param" segments are
50
- captured into the request's `params`).
50
+ captured into the request's `params`). Equivalent to -handleMethod:withPath:standalone:block:
51
+ with standalone:NO.
51
52
  */
52
53
  - (void)handleMethod:(NSString *)method
53
54
  withPath:(NSString *)path
54
55
  block:(void (^)(RouteRequest *request, RouteResponse *response))block;
55
56
 
57
+ /**
58
+ Registers a route handler that, when `standalone` is YES, bypasses -routeQueue entirely so a
59
+ handler stuck on that queue can never block it. Concurrent requests to the same method+path are
60
+ coalesced into a single in-flight execution, whose response is delivered to all of them; anything
61
+ else runs on its own queue, so distinct standalone endpoints always execute in parallel with each
62
+ other and with whatever is stuck on -routeQueue.
63
+ */
64
+ - (void)handleMethod:(NSString *)method
65
+ withPath:(NSString *)path
66
+ standalone:(BOOL)standalone
67
+ block:(void (^)(RouteRequest *request, RouteResponse *response))block;
68
+
56
69
  /**
57
70
  Convenience for -handleMethod:@"GET" withPath:path block:block.
58
71
  */
59
72
  - (void)get:(NSString *)path withBlock:(void (^)(RouteRequest *request, RouteResponse *response))block;
60
73
 
74
+ /**
75
+ Immediately sends `response` to every non-standalone request currently pending for the given
76
+ "sessionID" path param - whether still queued on -routeQueue or already executing - instead of
77
+ leaving their HTTP clients waiting on a session that no longer exists. A request that has already
78
+ started executing keeps running to completion in the background regardless (GCD gives no way to
79
+ abort a block once it starts), but its eventual result is discarded rather than ever reaching a
80
+ client. `response` is written as-is to every pending client, so the caller is expected to supply
81
+ a fully-populated, protocol-correct error response (e.g. a W3C-shaped JSON body).
82
+ */
83
+ - (void)abandonPendingRequestsForSessionID:(NSString *)sessionID withResponse:(RouteResponse *)response;
84
+
61
85
  /**
62
86
  Starts listening on `port`.
63
87
  */
@@ -35,6 +35,7 @@ static NSData * _Nonnull FBUTF8Data(NSString *string)
35
35
  @property (nonatomic, strong) NSRegularExpression *regex;
36
36
  @property (nonatomic, copy, nullable) NSArray<NSString *> *keys;
37
37
  @property (nonatomic, copy) void (^block)(RouteRequest *request, RouteResponse *response);
38
+ @property (nonatomic, assign) BOOL isStandalone;
38
39
  @end
39
40
 
40
41
  @implementation FBHTTPRoute
@@ -55,6 +56,25 @@ static NSData * _Nonnull FBUTF8Data(NSString *string)
55
56
  @end
56
57
 
57
58
 
59
+ // One dispatched-but-not-yet-answered request. Default (pointer) identity, so two pipelined
60
+ // requests sharing a connection are never conflated into a single tracked entry.
61
+ @interface FBPendingRequest : NSObject
62
+ @property (nonatomic, strong, readonly) nw_connection_t client;
63
+ @end
64
+
65
+ @implementation FBPendingRequest
66
+
67
+ - (instancetype)initWithClient:(nw_connection_t)client
68
+ {
69
+ if ((self = [super init])) {
70
+ _client = client;
71
+ }
72
+ return self;
73
+ }
74
+
75
+ @end
76
+
77
+
58
78
  @interface FBHTTPServer () <FBTCPSocketDelegate>
59
79
 
60
80
  @property (nonatomic, nullable, strong) FBTCPSocket *socket;
@@ -67,6 +87,20 @@ static NSData * _Nonnull FBUTF8Data(NSString *string)
67
87
  // Per-client cache of the already-parsed request line + headers while its body is still
68
88
  // arriving; nil while a client's next unread bytes start with an unparsed header block.
69
89
  @property (nonatomic, strong) NSMapTable<id, FBPendingHTTPRequestHeader *> *pendingRequestHeaders;
90
+ // All buffer access - appending new data and -processBufferForClient:'s unlocked parse - is
91
+ // funneled through this one serial queue, so appends can never race a parse.
92
+ @property (nonatomic, strong) dispatch_queue_t bufferProcessingQueue;
93
+ // Connections with a request parsed off the buffer but not yet answered. Blocks
94
+ // -processBufferForClient: from starting the next pipelined request, so responses on one
95
+ // connection can't be written out of order. Guarded by @synchronized(self.connectionBuffers).
96
+ @property (nonatomic, strong) NSMutableSet *connectionsAwaitingResponse;
97
+ // Keyed by "METHOD path" - requests waiting on an already in-flight standalone request for that
98
+ // endpoint. Guarded by @synchronized(self.standaloneWaiters).
99
+ @property (nonatomic, strong) NSMutableDictionary<NSString *, NSMutableArray<FBPendingRequest *> *> *standaloneWaiters;
100
+ // Keyed by the "sessionID" path param - requests currently queued or executing for that session,
101
+ // standalone or not (except DELETE /session itself - see -dispatchMethod:). See
102
+ // -abandonPendingRequestsForSessionID:. Guarded by @synchronized(self.pendingSessionRequests).
103
+ @property (nonatomic, strong) NSMutableDictionary<NSString *, NSMutableSet<FBPendingRequest *> *> *pendingSessionRequests;
70
104
 
71
105
  @end
72
106
 
@@ -81,6 +115,10 @@ static NSData * _Nonnull FBUTF8Data(NSString *string)
81
115
  valueOptions:(NSPointerFunctionsOptions)NSMapTableStrongMemory];
82
116
  _pendingRequestHeaders = [NSMapTable mapTableWithKeyOptions:(NSPointerFunctionsOptions)(NSMapTableObjectPointerPersonality | NSMapTableStrongMemory)
83
117
  valueOptions:(NSPointerFunctionsOptions)NSMapTableStrongMemory];
118
+ _bufferProcessingQueue = dispatch_queue_create("com.facebook.wda.http.bufferProcessing", DISPATCH_QUEUE_SERIAL);
119
+ _connectionsAwaitingResponse = [NSMutableSet set];
120
+ _standaloneWaiters = [NSMutableDictionary dictionary];
121
+ _pendingSessionRequests = [NSMutableDictionary dictionary];
84
122
  }
85
123
  return self;
86
124
  }
@@ -107,8 +145,7 @@ static NSData * _Nonnull FBUTF8Data(NSString *string)
107
145
  FBHTTPRoute *route = [FBHTTPRoute new];
108
146
  NSMutableArray<NSString *> *keys = [NSMutableArray array];
109
147
 
110
- // Escape regex-significant characters before substituting :param placeholders, like
111
- // RoutingHTTPServer.m used to.
148
+ // Escape regex-significant characters before substituting :param placeholders.
112
149
  NSRegularExpression *escapeRegex = [NSRegularExpression regularExpressionWithPattern:@"[.+()]"
113
150
  options:(NSRegularExpressionOptions)0
114
151
  error:nil];
@@ -157,10 +194,19 @@ static NSData * _Nonnull FBUTF8Data(NSString *string)
157
194
  - (void)handleMethod:(NSString *)method
158
195
  withPath:(NSString *)path
159
196
  block:(void (^)(RouteRequest *request, RouteResponse *response))block
197
+ {
198
+ [self handleMethod:method withPath:path standalone:NO block:block];
199
+ }
200
+
201
+ - (void)handleMethod:(NSString *)method
202
+ withPath:(NSString *)path
203
+ standalone:(BOOL)standalone
204
+ block:(void (^)(RouteRequest *request, RouteResponse *response))block
160
205
  {
161
206
  FBHTTPRoute *route = [self compiledRouteWithPath:path];
162
207
  route.verb = method.uppercaseString;
163
208
  route.block = block;
209
+ route.isStandalone = standalone;
164
210
  [self.routes addObject:route];
165
211
  }
166
212
 
@@ -191,6 +237,7 @@ static NSData * _Nonnull FBUTF8Data(NSString *string)
191
237
  @synchronized (self.connectionBuffers) {
192
238
  [self.connectionBuffers removeAllObjects];
193
239
  [self.pendingRequestHeaders removeAllObjects];
240
+ [self.connectionsAwaitingResponse removeAllObjects];
194
241
  }
195
242
  _isRunning = NO;
196
243
  }
@@ -209,123 +256,136 @@ static NSData * _Nonnull FBUTF8Data(NSString *string)
209
256
  @synchronized (self.connectionBuffers) {
210
257
  [self.connectionBuffers removeObjectForKey:client];
211
258
  [self.pendingRequestHeaders removeObjectForKey:client];
259
+ [self.connectionsAwaitingResponse removeObject:client];
212
260
  }
213
261
  }
214
262
 
215
263
  - (void)client:(nw_connection_t)client didReceiveData:(NSData *)data
216
264
  {
217
- NSMutableData *buffer;
218
- @synchronized (self.connectionBuffers) {
219
- buffer = [self.connectionBuffers objectForKey:client];
220
- if (nil == buffer) {
265
+ // The append itself, not just the parse, must run on bufferProcessingQueue: otherwise a receive
266
+ // callback here could still mutate the buffer while -processBufferForClient: is reading it
267
+ // unlocked on that queue.
268
+ __weak typeof(self) weakSelf = self;
269
+ dispatch_async(self.bufferProcessingQueue, ^{
270
+ __strong typeof(weakSelf) strongSelf = weakSelf;
271
+ if (nil == strongSelf) {
221
272
  return;
222
273
  }
223
- [buffer appendData:data];
224
- }
225
- [self processBufferForClient:client];
274
+ @synchronized (strongSelf.connectionBuffers) {
275
+ NSMutableData *buffer = [strongSelf.connectionBuffers objectForKey:client];
276
+ if (nil == buffer) {
277
+ return;
278
+ }
279
+ [buffer appendData:data];
280
+ }
281
+ [strongSelf processBufferForClient:client];
282
+ });
226
283
  }
227
284
 
228
285
  #pragma mark - HTTP parsing
229
286
 
287
+ // Parses and dispatches at most one request per call; a connection with one already in flight is
288
+ // left alone (see -connectionsAwaitingResponse) until its response is written.
230
289
  - (void)processBufferForClient:(nw_connection_t)client
231
290
  {
232
- while (YES) {
233
- NSMutableData *buffer;
234
- FBPendingHTTPRequestHeader *pending;
235
- @synchronized (self.connectionBuffers) {
236
- buffer = [self.connectionBuffers objectForKey:client];
237
- if (nil == buffer) {
238
- return;
239
- }
240
- pending = [self.pendingRequestHeaders objectForKey:client];
291
+ NSMutableData *buffer;
292
+ FBPendingHTTPRequestHeader *pending;
293
+ @synchronized (self.connectionBuffers) {
294
+ if ([self.connectionsAwaitingResponse containsObject:client]) {
295
+ return;
241
296
  }
297
+ buffer = [self.connectionBuffers objectForKey:client];
298
+ if (nil == buffer) {
299
+ return;
300
+ }
301
+ pending = [self.pendingRequestHeaders objectForKey:client];
302
+ }
242
303
 
243
- if (nil == pending) {
244
- NSRange headerEndRange = [buffer rangeOfData:FBCRLFCRLFData() options:(NSDataSearchOptions)0 range:NSMakeRange(0, buffer.length)];
245
- if (NSNotFound == headerEndRange.location) {
246
- // Wait for the rest of the header block to arrive.
247
- return;
248
- }
304
+ if (nil == pending) {
305
+ NSRange headerEndRange = [buffer rangeOfData:FBCRLFCRLFData() options:(NSDataSearchOptions)0 range:NSMakeRange(0, buffer.length)];
306
+ if (NSNotFound == headerEndRange.location) {
307
+ // Wait for the rest of the header block to arrive.
308
+ return;
309
+ }
249
310
 
250
- NSData *headerData = [buffer subdataWithRange:NSMakeRange(0, headerEndRange.location)];
251
- NSString *headerString = [[NSString alloc] initWithData:headerData encoding:NSUTF8StringEncoding];
252
- NSArray<NSString *> *lines = [headerString componentsSeparatedByString:@"\r\n"];
253
- if (lines.count < 1) {
254
- [self respondBadRequestToClient:client];
255
- return;
256
- }
311
+ NSData *headerData = [buffer subdataWithRange:NSMakeRange(0, headerEndRange.location)];
312
+ NSString *headerString = [[NSString alloc] initWithData:headerData encoding:NSUTF8StringEncoding];
313
+ NSArray<NSString *> *lines = [headerString componentsSeparatedByString:@"\r\n"];
314
+ if (lines.count < 1) {
315
+ [self respondBadRequestToClient:client];
316
+ return;
317
+ }
257
318
 
258
- NSArray<NSString *> *requestLineParts = [lines.firstObject componentsSeparatedByString:@" "];
259
- if (requestLineParts.count < 2) {
260
- [self respondBadRequestToClient:client];
261
- return;
262
- }
319
+ NSArray<NSString *> *requestLineParts = [lines.firstObject componentsSeparatedByString:@" "];
320
+ if (requestLineParts.count < 2) {
321
+ [self respondBadRequestToClient:client];
322
+ return;
323
+ }
263
324
 
264
- NSMutableDictionary<NSString *, NSString *> *requestHeaders = [NSMutableDictionary dictionary];
265
- for (NSUInteger i = 1; i < lines.count; i++) {
266
- NSString *line = lines[i];
267
- NSRange colonRange = [line rangeOfString:@":"];
268
- if (NSNotFound == colonRange.location) {
269
- continue;
270
- }
271
- NSString *name = [line substringToIndex:colonRange.location];
272
- NSString *value = [[line substringFromIndex:colonRange.location + 1]
273
- stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceCharacterSet];
274
- requestHeaders[name.lowercaseString] = value;
325
+ NSMutableDictionary<NSString *, NSString *> *requestHeaders = [NSMutableDictionary dictionary];
326
+ for (NSUInteger i = 1; i < lines.count; i++) {
327
+ NSString *line = lines[i];
328
+ NSRange colonRange = [line rangeOfString:@":"];
329
+ if (NSNotFound == colonRange.location) {
330
+ continue;
275
331
  }
332
+ NSString *name = [line substringToIndex:colonRange.location];
333
+ NSString *value = [[line substringFromIndex:colonRange.location + 1]
334
+ stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceCharacterSet];
335
+ requestHeaders[name.lowercaseString] = value;
336
+ }
276
337
 
277
- NSString *transferEncoding = requestHeaders[@"transfer-encoding"];
278
- if (transferEncoding.length > 0) {
279
- // No transfer decoder is implemented at all, so any encoding (chunked or otherwise -
280
- // including a value only introduced by a duplicate header overwriting "chunked" above)
281
- // is rejected rather than risking the body being misread as empty and desyncing the rest
282
- // of the connection's request stream.
283
- RouteResponse *notImplemented = [RouteResponse new];
284
- id<FBResponsePayload> notImplementedPayload = FBResponseWithStatus([FBCommandStatus unknownCommandErrorWithMessage:@"Transfer-Encoding is not supported"
338
+ NSString *transferEncoding = requestHeaders[@"transfer-encoding"];
339
+ if (transferEncoding.length > 0) {
340
+ // No transfer decoder is implemented at all, so any encoding (chunked or otherwise -
341
+ // including a value only introduced by a duplicate header overwriting "chunked" above)
342
+ // is rejected rather than risking the body being misread as empty and desyncing the rest
343
+ // of the connection's request stream.
344
+ RouteResponse *notImplemented = [RouteResponse new];
345
+ id<FBResponsePayload> notImplementedPayload = FBResponseWithStatus([FBCommandStatus invalidArgumentErrorWithMessage:@"Transfer-Encoding is not supported"
285
346
  traceback:nil]);
286
- [notImplementedPayload dispatchWithResponse:notImplemented];
287
- [self failClient:client withResponse:notImplemented];
288
- return;
289
- }
290
-
291
- NSUInteger contentLength = (NSUInteger)requestHeaders[@"content-length"].integerValue;
292
- if (contentLength > FBConfiguration.sharedInstance.httpRequestBodySizeLimit) {
293
- // Mirrors CocoaHTTPServer's maxRequestBodySize enforcement. Closes the connection after
294
- // responding, since the rest of the oversized body is still incoming.
295
- RouteResponse *tooLarge = [RouteResponse new];
296
- id<FBResponsePayload> tooLargePayload = FBResponseWithStatus([FBCommandStatus unknownCommandErrorWithMessage:@"Request Entity Too Large"
297
- traceback:nil]);
298
- [tooLargePayload dispatchWithResponse:tooLarge];
299
- [self failClient:client withResponse:tooLarge];
300
- return;
301
- }
302
-
303
- pending = [FBPendingHTTPRequestHeader new];
304
- pending.method = requestLineParts[0].uppercaseString;
305
- pending.pathAndQuery = requestLineParts[1];
306
- pending.bodyStart = headerEndRange.location + headerEndRange.length;
307
- pending.contentLength = contentLength;
308
- @synchronized (self.connectionBuffers) {
309
- [self.pendingRequestHeaders setObject:pending forKey:client];
310
- }
347
+ [notImplementedPayload dispatchWithResponse:notImplemented];
348
+ [self failClient:client withResponse:notImplemented];
349
+ return;
311
350
  }
312
351
 
313
- NSUInteger totalRequestLength = pending.bodyStart + pending.contentLength;
314
- if (buffer.length < totalRequestLength) {
315
- // Wait for the rest of the body to arrive - the parsed header stays cached above, so this
316
- // doesn't re-scan/re-parse the header block on every subsequently arriving chunk.
352
+ NSUInteger contentLength = (NSUInteger)requestHeaders[@"content-length"].integerValue;
353
+ if (contentLength > FBConfiguration.sharedInstance.httpRequestBodySizeLimit) {
354
+ // Closes the connection after responding, since the rest of the oversized body is still incoming.
355
+ RouteResponse *tooLarge = [RouteResponse new];
356
+ id<FBResponsePayload> tooLargePayload = FBResponseWithStatus([FBCommandStatus invalidArgumentErrorWithMessage:@"The request body exceeds the configured size limit"
357
+ traceback:nil]);
358
+ [tooLargePayload dispatchWithResponse:tooLarge];
359
+ [self failClient:client withResponse:tooLarge];
317
360
  return;
318
361
  }
319
362
 
320
- NSData *body = pending.contentLength > 0 ? [buffer subdataWithRange:NSMakeRange(pending.bodyStart, pending.contentLength)] : [NSData data];
321
-
363
+ pending = [FBPendingHTTPRequestHeader new];
364
+ pending.method = requestLineParts[0].uppercaseString;
365
+ pending.pathAndQuery = requestLineParts[1];
366
+ pending.bodyStart = headerEndRange.location + headerEndRange.length;
367
+ pending.contentLength = contentLength;
322
368
  @synchronized (self.connectionBuffers) {
323
- [buffer replaceBytesInRange:NSMakeRange(0, totalRequestLength) withBytes:NULL length:0];
324
- [self.pendingRequestHeaders removeObjectForKey:client];
369
+ [self.pendingRequestHeaders setObject:pending forKey:client];
325
370
  }
371
+ }
326
372
 
327
- [self dispatchMethod:pending.method pathAndQuery:pending.pathAndQuery body:body client:client];
373
+ NSUInteger totalRequestLength = pending.bodyStart + pending.contentLength;
374
+ if (buffer.length < totalRequestLength) {
375
+ // Wait for the rest of the body to arrive - the parsed header stays cached above, so this
376
+ // doesn't re-scan/re-parse the header block on every subsequently arriving chunk.
377
+ return;
328
378
  }
379
+
380
+ NSData *body = pending.contentLength > 0 ? [buffer subdataWithRange:NSMakeRange(pending.bodyStart, pending.contentLength)] : [NSData data];
381
+
382
+ @synchronized (self.connectionBuffers) {
383
+ [buffer replaceBytesInRange:NSMakeRange(0, totalRequestLength) withBytes:NULL length:0];
384
+ [self.pendingRequestHeaders removeObjectForKey:client];
385
+ [self.connectionsAwaitingResponse addObject:client];
386
+ }
387
+
388
+ [self dispatchMethod:pending.method pathAndQuery:pending.pathAndQuery body:body client:client];
329
389
  }
330
390
 
331
391
  // Removes the client's buffered state and responds with a closing error response. Removing the
@@ -344,8 +404,8 @@ static NSData * _Nonnull FBUTF8Data(NSString *string)
344
404
  - (void)respondBadRequestToClient:(nw_connection_t)client
345
405
  {
346
406
  RouteResponse *badRequest = [RouteResponse new];
347
- id<FBResponsePayload> payload = FBResponseWithStatus([FBCommandStatus unknownCommandErrorWithMessage:@"The request could not be parsed as valid HTTP"
348
- traceback:nil]);
407
+ id<FBResponsePayload> payload = FBResponseWithStatus([FBCommandStatus invalidArgumentErrorWithMessage:@"The request could not be parsed as valid HTTP"
408
+ traceback:nil]);
349
409
  [payload dispatchWithResponse:badRequest];
350
410
  [self failClient:client withResponse:badRequest];
351
411
  }
@@ -388,9 +448,29 @@ static NSData * _Nonnull FBUTF8Data(NSString *string)
388
448
  RouteResponse *response = [RouteResponse new];
389
449
  [self applyDefaultHeadersToResponse:response];
390
450
 
451
+ NSString *sessionID = params[@"sessionID"];
452
+ if (route.isStandalone) {
453
+ // DELETE triggers -abandonPendingRequestsForSessionID: itself; tracking its own request
454
+ // would make it abandon itself and write a response twice.
455
+ NSString *trackedSessionID = [route.verb isEqualToString:@"DELETE"] ? nil : sessionID;
456
+ [self dispatchStandaloneRoute:route request:request response:response client:client method:method pathAndQuery:pathAndQuery sessionID:trackedSessionID];
457
+ return;
458
+ }
459
+
460
+ FBPendingRequest *pendingRequest = nil;
461
+ if (nil != sessionID) {
462
+ pendingRequest = [[FBPendingRequest alloc] initWithClient:client];
463
+ [self trackPendingRequest:pendingRequest forSessionID:sessionID];
464
+ }
465
+
391
466
  void (^invoke)(void) = ^{
392
467
  route.block(request, response);
393
- [self writeResponse:response toClient:client];
468
+ // Whoever untracks `pendingRequest` first "wins" and gets to respond - either this normal
469
+ // completion, or -abandonPendingRequestsForSessionID: on another thread.
470
+ BOOL shouldRespond = (nil == pendingRequest) || [self untrackPendingRequest:pendingRequest forSessionID:sessionID];
471
+ if (shouldRespond) {
472
+ [self writeResponse:response toClient:client];
473
+ }
394
474
  };
395
475
  dispatch_queue_t routeQueue = self.routeQueue;
396
476
  if (routeQueue) {
@@ -409,6 +489,103 @@ static NSData * _Nonnull FBUTF8Data(NSString *string)
409
489
  [self writeResponse:notFound toClient:client];
410
490
  }
411
491
 
492
+ #pragma mark - Session-scoped request cancellation
493
+
494
+ - (void)trackPendingRequest:(FBPendingRequest *)pendingRequest forSessionID:(NSString *)sessionID
495
+ {
496
+ @synchronized (self.pendingSessionRequests) {
497
+ NSMutableSet<FBPendingRequest *> *pendingRequests = self.pendingSessionRequests[sessionID];
498
+ if (nil == pendingRequests) {
499
+ pendingRequests = [NSMutableSet set];
500
+ self.pendingSessionRequests[sessionID] = pendingRequests;
501
+ }
502
+ [pendingRequests addObject:pendingRequest];
503
+ }
504
+ }
505
+
506
+ // Returns YES if this caller won the race to respond, vs. -abandonPendingRequestsForSessionID:
507
+ // already having claimed `pendingRequest` on another thread.
508
+ - (BOOL)untrackPendingRequest:(FBPendingRequest *)pendingRequest forSessionID:(NSString *)sessionID
509
+ {
510
+ @synchronized (self.pendingSessionRequests) {
511
+ NSMutableSet<FBPendingRequest *> *pendingRequests = self.pendingSessionRequests[sessionID];
512
+ BOOL wasPending = [pendingRequests containsObject:pendingRequest];
513
+ if (wasPending) {
514
+ [pendingRequests removeObject:pendingRequest];
515
+ if (0 == pendingRequests.count) {
516
+ [self.pendingSessionRequests removeObjectForKey:sessionID];
517
+ }
518
+ }
519
+ return wasPending;
520
+ }
521
+ }
522
+
523
+ - (void)abandonPendingRequestsForSessionID:(NSString *)sessionID withResponse:(RouteResponse *)response
524
+ {
525
+ NSSet<FBPendingRequest *> *pendingRequests;
526
+ @synchronized (self.pendingSessionRequests) {
527
+ pendingRequests = [self.pendingSessionRequests[sessionID] copy];
528
+ [self.pendingSessionRequests removeObjectForKey:sessionID];
529
+ }
530
+ for (FBPendingRequest *pendingRequest in pendingRequests) {
531
+ [self writeResponse:response toClient:pendingRequest.client];
532
+ }
533
+ }
534
+
535
+ #pragma mark - Standalone route dispatch
536
+
537
+ - (void)dispatchStandaloneRoute:(FBHTTPRoute *)route
538
+ request:(RouteRequest *)request
539
+ response:(RouteResponse *)response
540
+ client:(nw_connection_t)client
541
+ method:(NSString *)method
542
+ pathAndQuery:(NSString *)pathAndQuery
543
+ sessionID:(nullable NSString *)sessionID
544
+ {
545
+ // Includes the query string so requests with different params are never coalesced together.
546
+ NSString *key = [NSString stringWithFormat:@"%@ %@", method, pathAndQuery];
547
+ FBPendingRequest *waiter = [[FBPendingRequest alloc] initWithClient:client];
548
+ if (nil != sessionID) {
549
+ [self trackPendingRequest:waiter forSessionID:sessionID];
550
+ }
551
+
552
+ BOOL isInFlight = NO;
553
+ @synchronized (self.standaloneWaiters) {
554
+ NSMutableArray<FBPendingRequest *> *waiters = self.standaloneWaiters[key];
555
+ if (nil != waiters) {
556
+ [waiters addObject:waiter];
557
+ isInFlight = YES;
558
+ } else {
559
+ self.standaloneWaiters[key] = [NSMutableArray array];
560
+ }
561
+ }
562
+ if (isInFlight) {
563
+ // An identical request is already executing; it will deliver this connection's response too.
564
+ return;
565
+ }
566
+
567
+ dispatch_queue_t queue = dispatch_queue_create(key.UTF8String, DISPATCH_QUEUE_SERIAL);
568
+ __weak typeof(self) weakSelf = self;
569
+ dispatch_async(queue, ^{
570
+ route.block(request, response);
571
+ __strong typeof(weakSelf) strongSelf = weakSelf;
572
+ if (nil == strongSelf) {
573
+ return;
574
+ }
575
+ NSArray<FBPendingRequest *> *joinedWaiters;
576
+ @synchronized (strongSelf.standaloneWaiters) {
577
+ joinedWaiters = [strongSelf.standaloneWaiters[key] copy];
578
+ [strongSelf.standaloneWaiters removeObjectForKey:key];
579
+ }
580
+ for (FBPendingRequest *joinedWaiter in [@[waiter] arrayByAddingObjectsFromArray:joinedWaiters]) {
581
+ BOOL shouldRespond = (nil == sessionID) || [strongSelf untrackPendingRequest:joinedWaiter forSessionID:sessionID];
582
+ if (shouldRespond) {
583
+ [strongSelf writeResponse:response toClient:joinedWaiter.client];
584
+ }
585
+ }
586
+ });
587
+ }
588
+
412
589
  - (void)writeResponse:(RouteResponse *)response toClient:(nw_connection_t)client
413
590
  {
414
591
  [self writeResponse:response toClient:client thenCloseConnection:NO];
@@ -439,7 +616,15 @@ static NSData * _Nonnull FBUTF8Data(NSString *string)
439
616
  [weakSelf closeClient:client];
440
617
  }];
441
618
  } else {
619
+ // Sent before unblocking the next pipelined request, so responses can't reach the wire out of order.
442
620
  [self.socket writeData:payload toClient:client];
621
+ @synchronized (self.connectionBuffers) {
622
+ [self.connectionsAwaitingResponse removeObject:client];
623
+ }
624
+ __weak typeof(self) weakSelf = self;
625
+ dispatch_async(self.bufferProcessingQueue, ^{
626
+ [weakSelf processBufferForClient:client];
627
+ });
443
628
  }
444
629
  }
445
630
 
@@ -447,6 +632,7 @@ static NSData * _Nonnull FBUTF8Data(NSString *string)
447
632
  {
448
633
  @synchronized (self.connectionBuffers) {
449
634
  [self.connectionBuffers removeObjectForKey:client];
635
+ [self.connectionsAwaitingResponse removeObject:client];
450
636
  }
451
637
  nw_connection_cancel(client);
452
638
  }
@@ -27,6 +27,9 @@ typedef __nonnull id<FBResponsePayload> (^FBRouteSyncHandler)(FBRouteRequest *re
27
27
  /*! Route's path */
28
28
  @property (nonatomic, copy, readonly) NSString *path;
29
29
 
30
+ /*! Whether this route bypasses the shared route queue - see -standalone */
31
+ @property (nonatomic, assign, readonly) BOOL isStandalone;
32
+
30
33
  /**
31
34
  Convenience constructor for GET route with given pathPattern
32
35
  */
@@ -67,6 +70,12 @@ typedef __nonnull id<FBResponsePayload> (^FBRouteSyncHandler)(FBRouteRequest *re
67
70
  */
68
71
  - (instancetype)withoutSession;
69
72
 
73
+ /**
74
+ Chain-able constructor for a route that bypasses the shared route queue - see FBHTTPServer.h's
75
+ -handleMethod:withPath:standalone:block: for what that changes about how/when the handler runs.
76
+ */
77
+ - (instancetype)standalone;
78
+
70
79
  /**
71
80
  Dispatches response for request
72
81
  */
@@ -18,6 +18,7 @@
18
18
 
19
19
  @interface FBRoute ()
20
20
  @property (nonatomic, assign, readwrite) BOOL requiresSession;
21
+ @property (nonatomic, assign, readwrite) BOOL isStandalone;
21
22
  @property (nonatomic, copy, readwrite) NSString *verb;
22
23
  @property (nonatomic, copy, readwrite) NSString *path;
23
24
 
@@ -126,10 +127,17 @@ static NSString *const FBRouteSessionPrefix = @"/session/:sessionID";
126
127
  return self;
127
128
  }
128
129
 
130
+ - (instancetype)standalone
131
+ {
132
+ self.isStandalone = YES;
133
+ return self;
134
+ }
135
+
129
136
  - (instancetype)respondWithBlock:(FBRouteSyncHandler)handler
130
137
  {
131
138
  FBRoute_Sync *route = [FBRoute_Sync withVerb:self.verb path:self.path requiresSession:self.requiresSession];
132
139
  route.handler = handler;
140
+ route.isStandalone = self.isStandalone;
133
141
  return route;
134
142
  }
135
143
 
@@ -138,6 +146,7 @@ static NSString *const FBRouteSessionPrefix = @"/session/:sessionID";
138
146
  FBRoute_TargetAction *route = [FBRoute_TargetAction withVerb:self.verb path:self.path requiresSession:self.requiresSession];
139
147
  route.target = target;
140
148
  route.action = action;
149
+ route.isStandalone = self.isStandalone;
141
150
  return route;
142
151
  }
143
152
 
@@ -16,6 +16,12 @@ NS_ASSUME_NONNULL_BEGIN
16
16
  /** Bundle identifier of Mobile Safari browser */
17
17
  extern NSString* const FB_SAFARI_BUNDLE_ID;
18
18
 
19
+ /**
20
+ Posted (synchronously, on whatever thread calls -kill) once a session has been torn down. The
21
+ notification's object is the FBSession instance that was killed - see -identifier.
22
+ */
23
+ extern NSString* const FBSessionWasKilledNotification;
24
+
19
25
  /**
20
26
  Class that represents testing session
21
27
  */
@@ -44,6 +50,13 @@ extern NSString* const FB_SAFARI_BUNDLE_ID;
44
50
 
45
51
  + (nullable instancetype)activeSession;
46
52
 
53
+ /**
54
+ Kills the active session, if any, and blocks until its teardown - including one already started
55
+ by a concurrent caller - is fully finished. Call this before preparing/launching a replacement
56
+ application, so it can't race a still-in-progress termination of the outgoing one.
57
+ */
58
+ + (void)killActiveSessionAndWaitForTeardown;
59
+
47
60
  /**
48
61
  Fetches session for given identifier.
49
62
  If identifier doesn't match activeSession identifier, will return nil.
@@ -34,12 +34,23 @@ NSString *const FBDefaultApplicationAuto = @"auto";
34
34
 
35
35
  NSString *const FB_SAFARI_BUNDLE_ID = @"com.apple.mobilesafari";
36
36
 
37
+ // FBXCAXClientProxy's shared accessibility channel can be stuck servicing another request.
38
+ static const NSTimeInterval FB_IS_SYSTEM_APP_CHECK_TIMEOUT_SEC = 5.;
39
+ // -terminate hard-asserts off the main thread, which may itself be busy - see -fb_terminate...:.
40
+ static const NSTimeInterval FB_APP_TERMINATE_TIMEOUT_SEC = 5.;
41
+ // How long a -kill caller that lost the race below waits for the winner's teardown to finish.
42
+ static const NSTimeInterval FB_KILL_WAIT_TIMEOUT_SEC = 35.;
43
+ NSString *const FBSessionWasKilledNotification = @"FBSessionWasKilledNotification";
44
+
37
45
  @interface FBSession ()
38
46
  @property (nullable, nonatomic) XCUIApplication *testedApplication;
39
47
  @property (nonatomic) BOOL isTestedApplicationExpectedToRun;
40
48
  @property (nonatomic) BOOL shouldAppsWaitForQuiescence;
41
49
  @property (nonatomic, nullable) FBAlertsMonitor *alertsMonitor;
42
50
  @property (nonatomic, readwrite) NSMutableDictionary<NSNumber *, NSMutableDictionary<NSString *, NSNumber *> *> *elementsVisibilityCache;
51
+
52
+ - (BOOL)fb_isTestedApplicationSameAsSystemAppWithTimeout:(NSTimeInterval)timeout;
53
+ - (void)fb_terminateTestedApplicationWithTimeout:(NSTimeInterval)timeout;
43
54
  @end
44
55
 
45
56
  @interface FBSession (FBAlertsMonitorDelegate)
@@ -90,17 +101,54 @@ NSString *const FB_SAFARI_BUNDLE_ID = @"com.apple.mobilesafari";
90
101
  @implementation FBSession
91
102
 
92
103
  static FBSession *_activeSession = nil;
104
+ // Class-level, not per-instance: a caller that finds _activeSession already nil (a concurrent
105
+ // -kill beat it there) still needs to know whether that -kill's teardown is done, since it cleared
106
+ // the pointer before running it. See +waitForActiveTeardownWithTimeout:.
107
+ static BOOL _isTeardownInProgress = NO;
108
+
109
+ + (NSCondition *)teardownCondition
110
+ {
111
+ static NSCondition *condition;
112
+ static dispatch_once_t onceToken;
113
+ dispatch_once(&onceToken, ^{
114
+ condition = [NSCondition new];
115
+ });
116
+ return condition;
117
+ }
118
+
119
+ // Waits (bounded) for any -kill teardown currently in progress to finish.
120
+ + (void)waitForActiveTeardownWithTimeout:(NSTimeInterval)timeout
121
+ {
122
+ NSCondition *condition = self.teardownCondition;
123
+ [condition lock];
124
+ NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:timeout];
125
+ while (_isTeardownInProgress && [condition waitUntilDate:deadline]) {
126
+ }
127
+ [condition unlock];
128
+ }
93
129
 
94
130
  + (instancetype)activeSession
95
131
  {
96
132
  return _activeSession;
97
133
  }
98
134
 
99
- + (void)markSessionActive:(FBSession *)session
135
+ + (void)killActiveSessionAndWaitForTeardown
100
136
  {
101
- if (_activeSession) {
102
- [_activeSession kill];
137
+ FBSession *session = _activeSession;
138
+ if (nil != session) {
139
+ // Runs the real teardown synchronously if this call wins the race in -kill, or waits for
140
+ // whoever did to finish if it lost - either way, blocks until torn down.
141
+ [session kill];
142
+ } else {
143
+ // _activeSession is already nil, but a concurrent -kill (e.g. from DELETE /session) may still
144
+ // be mid-teardown - wait for it, so we don't launch a replacement app too early.
145
+ [self waitForActiveTeardownWithTimeout:FB_KILL_WAIT_TIMEOUT_SEC];
103
146
  }
147
+ }
148
+
149
+ + (void)markSessionActive:(FBSession *)session
150
+ {
151
+ [self killActiveSessionAndWaitForTeardown];
104
152
  _activeSession = session;
105
153
  }
106
154
 
@@ -169,33 +217,58 @@ static FBSession *_activeSession = nil;
169
217
 
170
218
  - (void)kill
171
219
  {
172
- if (nil == _activeSession) {
220
+ // DELETE /session and session creation can now run concurrently, so a session already
221
+ // superseded by a newer one can still reach here via a stale reference. Check-and-clear must be
222
+ // atomic, else a belated -kill could null out the new session's pointer instead of its own.
223
+ BOOL wasActive;
224
+ @synchronized (self.class) {
225
+ wasActive = (self == _activeSession);
226
+ if (wasActive) {
227
+ _activeSession = nil;
228
+ }
229
+ }
230
+ if (!wasActive) {
231
+ // Someone else is already tearing this session down - wait for that to finish (bounded), so
232
+ // we don't act as if it's gone (e.g. launch a new app) while its -terminate is still in flight.
233
+ [self.class waitForActiveTeardownWithTimeout:FB_KILL_WAIT_TIMEOUT_SEC];
173
234
  return;
174
235
  }
175
236
 
176
- [self disableAlertsMonitor];
237
+ NSCondition *teardownCondition = self.class.teardownCondition;
238
+ [teardownCondition lock];
239
+ _isTeardownInProgress = YES;
240
+ [teardownCondition unlock];
241
+
242
+ @try {
243
+ // Posted before teardown so pending HTTP requests for this session can stop waiting sooner.
244
+ [NSNotificationCenter.defaultCenter postNotificationName:FBSessionWasKilledNotification object:self];
177
245
 
178
- FBScreenRecordingPromise *activeScreenRecording = FBScreenRecordingContainer.sharedInstance.screenRecordingPromise;
179
- if (nil != activeScreenRecording) {
180
- NSError *error;
181
- if (![FBXCTestDaemonsProxy stopScreenRecordingWithUUID:activeScreenRecording.identifier error:&error]) {
182
- [FBLogger logFmt:@"%@", error];
246
+ [self disableAlertsMonitor];
247
+
248
+ FBScreenRecordingPromise *activeScreenRecording = FBScreenRecordingContainer.sharedInstance.screenRecordingPromise;
249
+ if (nil != activeScreenRecording) {
250
+ NSError *error;
251
+ if (![FBXCTestDaemonsProxy stopScreenRecordingWithUUID:activeScreenRecording.identifier error:&error]) {
252
+ [FBLogger logFmt:@"%@", error];
253
+ }
254
+ [FBScreenRecordingContainer.sharedInstance reset];
183
255
  }
184
- [FBScreenRecordingContainer.sharedInstance reset];
185
- }
186
256
 
187
- if (nil != self.testedApplication
188
- && FBConfiguration.sharedInstance.shouldTerminateApp
189
- && self.testedApplication.running
190
- && ![self.testedApplication fb_isSameAppAs:XCUIApplication.fb_systemApplication]) {
191
- @try {
192
- [self.testedApplication terminate];
193
- } @catch (NSException *e) {
194
- [FBLogger logFmt:@"%@", e.description];
257
+ if (nil != self.testedApplication
258
+ && FBConfiguration.sharedInstance.shouldTerminateApp
259
+ && self.testedApplication.running
260
+ && ![self fb_isTestedApplicationSameAsSystemAppWithTimeout:FB_IS_SYSTEM_APP_CHECK_TIMEOUT_SEC]) {
261
+ // Blocks until the app is either actually terminated or durably given up on (never left
262
+ // pending) - see -fb_terminateTestedApplicationWithTimeout: - so it's safe to report this
263
+ // teardown as finished as soon as this returns.
264
+ [self fb_terminateTestedApplicationWithTimeout:FB_APP_TERMINATE_TIMEOUT_SEC];
195
265
  }
266
+ } @finally {
267
+ [teardownCondition lock];
268
+ _isTeardownInProgress = NO;
269
+ [teardownCondition broadcast];
270
+ [teardownCondition unlock];
196
271
  }
197
-
198
- _activeSession = nil;
199
272
  }
200
273
 
201
274
  - (XCUIApplication *)activeApplication
@@ -290,4 +363,62 @@ static FBSession *_activeSession = nil;
290
363
  : [[XCUIApplication alloc] initWithBundleIdentifier:bundleIdentifier];
291
364
  }
292
365
 
366
+ // Has no async variant and can block on the shared accessibility channel. Run off-thread and give
367
+ // up after `timeout`, assuming the tested app IS the system app - safer, since it means skipping
368
+ // termination rather than risking terminating springboard.
369
+ - (BOOL)fb_isTestedApplicationSameAsSystemAppWithTimeout:(NSTimeInterval)timeout
370
+ {
371
+ __block XCUIApplication *systemApp = nil;
372
+ __block NSException *caughtException = nil;
373
+ dispatch_semaphore_t sem = dispatch_semaphore_create(0);
374
+ dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
375
+ // Undocumented private API; guard in case it hard-asserts off-main like -terminate does on
376
+ // some Xcode/iOS version - uncaught, that would crash the whole process.
377
+ @try {
378
+ systemApp = XCUIApplication.fb_systemApplication;
379
+ } @catch (NSException *e) {
380
+ caughtException = e;
381
+ }
382
+ dispatch_semaphore_signal(sem);
383
+ });
384
+ int64_t timeoutNs = (int64_t)(timeout * NSEC_PER_SEC);
385
+ if (0 != dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, timeoutNs)) || nil != caughtException) {
386
+ [FBLogger logFmt:@"Could not determine the system application within %@ seconds%@; assuming '%@' might be it and skipping its termination", @(timeout), nil == caughtException ? @"" : [NSString stringWithFormat:@" (%@)", caughtException.description], self.testedApplication.bundleID];
387
+ return YES;
388
+ }
389
+ return [self.testedApplication fb_isSameAppAs:systemApp];
390
+ }
391
+
392
+ // -terminate hard-asserts off-main, but -kill can now run on a background queue. Dispatching to
393
+ // main and waiting indefinitely could hang just as long as main is stuck, so give up after
394
+ // `timeout` - but a "given up on" call must never still terminate whatever's running by the time
395
+ // main gets to it (e.g. a replacement session's app), so cancellation and the actual terminate
396
+ // call share a lock: whichever gets there first - the dispatched block, or the timeout - wins.
397
+ - (void)fb_terminateTestedApplicationWithTimeout:(NSTimeInterval)timeout
398
+ {
399
+ XCUIApplication *application = self.testedApplication;
400
+ NSObject *lock = [NSObject new];
401
+ __block BOOL isAllowedToTerminate = YES;
402
+ dispatch_semaphore_t sem = dispatch_semaphore_create(0);
403
+ dispatch_async(dispatch_get_main_queue(), ^{
404
+ @synchronized (lock) {
405
+ if (isAllowedToTerminate) {
406
+ @try {
407
+ [application terminate];
408
+ } @catch (NSException *e) {
409
+ [FBLogger logFmt:@"%@", e.description];
410
+ }
411
+ }
412
+ }
413
+ dispatch_semaphore_signal(sem);
414
+ });
415
+ int64_t timeoutNs = (int64_t)(timeout * NSEC_PER_SEC);
416
+ if (0 != dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, timeoutNs))) {
417
+ @synchronized (lock) {
418
+ isAllowedToTerminate = NO;
419
+ }
420
+ [FBLogger logFmt:@"Could not terminate '%@' within %@ seconds; giving up on it rather than risk terminating a possible replacement session's app later", application.bundleID, @(timeout)];
421
+ }
422
+ }
423
+
293
424
  @end
@@ -135,7 +135,8 @@
135
135
  }
136
136
  [strongSelf scheduleReceiveForConnection:connection];
137
137
  } else if (nw_connection_state_failed == state || nw_connection_state_cancelled == state) {
138
- [weakSelf handleDisconnectForConnection:connection];
138
+ __strong typeof(weakSelf) strongSelf = weakSelf;
139
+ [strongSelf handleDisconnectForConnection:connection];
139
140
  }
140
141
  });
141
142
  nw_connection_start(connection);
@@ -13,8 +13,10 @@
13
13
  #import "FBTCPSocket.h"
14
14
 
15
15
  #import "FBCommandHandler.h"
16
+ #import "FBCommandStatus.h"
16
17
  #import "FBErrorBuilder.h"
17
18
  #import "FBExceptionHandler.h"
19
+ #import "FBResponsePayload.h"
18
20
  #import "FBRouteRequest.h"
19
21
  #import "FBRuntimeUtils.h"
20
22
  #import "FBSession.h"
@@ -80,6 +82,11 @@ static NSString *const FBServerURLEndMarker = @"<-ServerURLHere";
80
82
  [self.server setDefaultHeader:@"Access-Control-Allow-Origin" value:@"*"];
81
83
  [self.server setDefaultHeader:@"Access-Control-Allow-Headers" value:@"Content-Type, X-Requested-With"];
82
84
 
85
+ [NSNotificationCenter.defaultCenter addObserver:self
86
+ selector:@selector(sessionWasKilled:)
87
+ name:FBSessionWasKilledNotification
88
+ object:nil];
89
+
83
90
  [self registerRouteHandlers:[self.class collectCommandHandlerClasses]];
84
91
  [self registerServerKeyRouteHandlers];
85
92
 
@@ -167,8 +174,26 @@ static NSString *const FBServerURLEndMarker = @"<-ServerURLHere";
167
174
  }
168
175
  }
169
176
 
177
+ - (void)sessionWasKilled:(NSNotification *)notification
178
+ {
179
+ FBSession *session = notification.object;
180
+ if (![session isKindOfClass:FBSession.class]) {
181
+ return;
182
+ }
183
+ // Same "invalid session id" shape a still-queued request would eventually get anyway, once
184
+ // -routeQueue drains and FBRoute.decorateRequest: finds the session gone - just delivered now
185
+ // instead of after however long the request would otherwise have been stuck waiting.
186
+ NSString *message = [NSString stringWithFormat:@"Session %@ was deleted while this request was still pending", session.identifier];
187
+ id<FBResponsePayload> payload = FBResponseWithStatus([FBCommandStatus noSuchDriverErrorWithMessage:message
188
+ traceback:nil]);
189
+ RouteResponse *response = [RouteResponse new];
190
+ [payload dispatchWithResponse:response];
191
+ [self.server abandonPendingRequestsForSessionID:session.identifier withResponse:response];
192
+ }
193
+
170
194
  - (void)stopServing
171
195
  {
196
+ [NSNotificationCenter.defaultCenter removeObserver:self name:FBSessionWasKilledNotification object:nil];
172
197
  [FBSession.activeSession kill];
173
198
  [self stopScreenshotsBroadcaster];
174
199
  if (self.server.isRunning) {
@@ -208,7 +233,7 @@ static NSString *const FBServerURLEndMarker = @"<-ServerURLHere";
208
233
  for (Class<FBCommandHandler> commandHandler in commandHandlerClasses) {
209
234
  NSArray *routes = [commandHandler routes];
210
235
  for (FBRoute *route in routes) {
211
- [self.server handleMethod:route.verb withPath:route.path block:^(RouteRequest *request, RouteResponse *response) {
236
+ [self.server handleMethod:route.verb withPath:route.path standalone:route.isStandalone block:^(RouteRequest *request, RouteResponse *response) {
212
237
  __strong typeof(weakSelf) strongSelf = weakSelf;
213
238
  if (nil == strongSelf) {
214
239
  return;
@@ -9,7 +9,7 @@
9
9
  #import "RouteResponse.h"
10
10
 
11
11
  @interface RouteResponse ()
12
- @property (nonatomic, copy) NSMutableDictionary<NSString *, NSString *> *mutableHeaders;
12
+ @property (nonatomic, strong) NSMutableDictionary<NSString *, NSString *> *mutableHeaders;
13
13
  @end
14
14
 
15
15
  @implementation RouteResponse
@@ -124,17 +124,14 @@ static NSString *const axSettingsClassName = @"AXSettings";
124
124
  {
125
125
  // 'WebDriverAgent --port 8080' can be passed via the arguments to the process
126
126
  NSRange rangeFromArguments = [self.class bindingPortRangeFromArguments];
127
- if (rangeFromArguments.location != NSNotFound) {
128
- return rangeFromArguments;
127
+ if (rangeFromArguments.location == NSNotFound) {
128
+ // Existence of USE_PORT in the environment implies the port range is managed by the launching process.
129
+ NSString *usePort = NSProcessInfo.processInfo.environment[@"USE_PORT"];
130
+ rangeFromArguments = usePort.length > 0
131
+ ? NSMakeRange((NSUInteger)usePort.integerValue, 1)
132
+ : NSMakeRange(DefaultStartingPort, DefaultPortRange);
129
133
  }
130
-
131
- // Existence of USE_PORT in the environment implies the port range is managed by the launching process.
132
- if (NSProcessInfo.processInfo.environment[@"USE_PORT"] &&
133
- [NSProcessInfo.processInfo.environment[@"USE_PORT"] length] > 0) {
134
- return NSMakeRange([NSProcessInfo.processInfo.environment[@"USE_PORT"] integerValue] , 1);
135
- }
136
-
137
- return NSMakeRange(DefaultStartingPort, DefaultPortRange);
134
+ return rangeFromArguments;
138
135
  }
139
136
 
140
137
  - (NSString *)bindingIPAddress
@@ -23,6 +23,7 @@
23
23
  #import "XCUIDevice.h"
24
24
 
25
25
  #define LAUNCH_APP_TIMEOUT_SEC 300
26
+ #define STOP_SCREEN_RECORDING_TIMEOUT_SEC 20
26
27
 
27
28
  static void (*originalLaunchAppMethod)(id, SEL, NSString*, NSString*, NSArray*, NSDictionary*, void (^)(_Bool, NSError *));
28
29
 
@@ -342,14 +343,16 @@ static void swizzledLaunchApp(id self, SEL _cmd, NSString *path, NSString *bundl
342
343
  }
343
344
 
344
345
  __block NSError *innerError = nil;
345
- [FBRunLoopSpinner spinUntilCompletion:^(void(^completion)(void)){
346
- [session stopScreenRecordingWithUUID:uuid withReply:^(NSError *invokeError) {
347
- if (nil != invokeError) {
348
- innerError = invokeError;
349
- }
350
- completion();
351
- }];
346
+ dispatch_semaphore_t sem = dispatch_semaphore_create(0);
347
+ [session stopScreenRecordingWithUUID:uuid withReply:^(NSError *invokeError) {
348
+ innerError = invokeError;
349
+ dispatch_semaphore_signal(sem);
352
350
  }];
351
+ int64_t timeoutNs = (int64_t)(STOP_SCREEN_RECORDING_TIMEOUT_SEC * NSEC_PER_SEC);
352
+ if (0 != dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, timeoutNs)) && nil == innerError) {
353
+ NSString *message = [NSString stringWithFormat:@"Did not receive a reply to stop screen recording within %d seconds", STOP_SCREEN_RECORDING_TIMEOUT_SEC];
354
+ innerError = [[[FBErrorBuilder builder] withDescription:message] build];
355
+ }
353
356
  if (nil != innerError && error) {
354
357
  *error = innerError;
355
358
  }
@@ -77,32 +77,53 @@
77
77
 
78
78
  @end
79
79
 
80
+ #define TESTMANAGERD_VERSION_TIMEOUT_SEC 20
81
+
80
82
  NSInteger FBTestmanagerdVersion(void)
81
83
  {
82
- static dispatch_once_t getTestmanagerdVersion;
83
- static NSInteger testmanagerdVersion;
84
- dispatch_once(&getTestmanagerdVersion, ^{
84
+ // Not dispatch_once: that would permanently cache the timeout fallback below if the first call's
85
+ // reply merely arrived late. -1 means "not yet determined"; a timeout isn't cached, so it retries.
86
+ static NSInteger cachedVersion = -1;
87
+ static dispatch_queue_t syncQueue;
88
+ static dispatch_once_t onceToken;
89
+ dispatch_once(&onceToken, ^{
90
+ syncQueue = dispatch_queue_create("com.facebook.wda.testmanagerdVersion", DISPATCH_QUEUE_SERIAL);
91
+ });
92
+
93
+ __block NSInteger result;
94
+ dispatch_sync(syncQueue, ^{
95
+ if (cachedVersion >= 0) {
96
+ result = cachedVersion;
97
+ return;
98
+ }
99
+
85
100
  id<XCTMessagingChannel_RunnerToDaemon> proxy = [FBXCTestDaemonsProxy testRunnerProxy];
86
101
  if ([(NSObject *)proxy respondsToSelector:@selector(_XCT_exchangeProtocolVersion:reply:)]) {
87
102
  id<FBXCTestManagerLegacyProtocolVersionExchanging> legacyProxy = (id<FBXCTestManagerLegacyProtocolVersionExchanging>)proxy;
88
- [FBRunLoopSpinner spinUntilCompletion:^(void(^completion)(void)){
89
- [legacyProxy _XCT_exchangeProtocolVersion:testmanagerdVersion reply:^(unsigned long long code) {
90
- testmanagerdVersion = (NSInteger) code;
91
- completion();
92
- }];
103
+ __block NSInteger receivedVersion = -1;
104
+ dispatch_semaphore_t sem = dispatch_semaphore_create(0);
105
+ [legacyProxy _XCT_exchangeProtocolVersion:0 reply:^(unsigned long long code) {
106
+ receivedVersion = (NSInteger) code;
107
+ dispatch_semaphore_signal(sem);
93
108
  }];
109
+ int64_t timeoutNs = (int64_t)(TESTMANAGERD_VERSION_TIMEOUT_SEC * NSEC_PER_SEC);
110
+ if (0 != dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, timeoutNs))) {
111
+ // Assume newest/full-featured on timeout, but don't cache it - retry on the next call.
112
+ [FBLogger logFmt:@"Did not receive a testmanagerd protocol version reply within %d seconds; assuming the newest/full-featured protocol", TESTMANAGERD_VERSION_TIMEOUT_SEC];
113
+ result = 0xFFFF;
114
+ return;
115
+ }
116
+ result = receivedVersion;
94
117
  } else {
95
- // Modern testmanagerd (Xcode 15+) has already negotiated named XCTCapabilities by the time
96
- // a daemon session exists, instead of a single scalar protocol version. There is no direct
97
- // integer equivalent to report here (this value is diagnostic-only, surfaced via the
98
- // 'testmanagerdVersion' session capability), so keep reporting the existing "assume
99
- // newest/full-featured" sentinel, while confirming capabilities did negotiate successfully.
118
+ // Modern testmanagerd (Xcode 15+) negotiates named XCTCapabilities instead of a scalar
119
+ // version; there's no direct integer equivalent, so just confirm capabilities negotiated.
100
120
  XCTCapabilities *capabilities = [XCTRunnerDaemonSession sharedSession].remoteInterfaceCapabilities;
101
121
  if (nil == capabilities) {
102
122
  [FBLogger log:@"Could not retrieve testmanagerd capabilities"];
103
123
  }
104
- testmanagerdVersion = 0xFFFF;
124
+ result = 0xFFFF;
105
125
  }
126
+ cachedVersion = result;
106
127
  });
107
- return testmanagerdVersion;
128
+ return result;
108
129
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appium-webdriveragent",
3
- "version": "16.7.2",
3
+ "version": "16.7.3",
4
4
  "description": "Package bundling WebDriverAgent",
5
5
  "keywords": [
6
6
  "Appium",