appium-webdriveragent 16.11.3 → 16.11.4

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.11.4](https://github.com/appium/WebDriverAgent/compare/v16.11.3...v16.11.4) (2026-08-30)
2
+
3
+ ### Bug Fixes
4
+
5
+ * prevent a stale teardown from affecting a newer session generation ([#1231](https://github.com/appium/WebDriverAgent/issues/1231)) ([83642a1](https://github.com/appium/WebDriverAgent/commit/83642a11218fd2b2801af0a04d3b253125246bd7))
6
+
1
7
  ## [16.11.3](https://github.com/appium/WebDriverAgent/compare/v16.11.2...v16.11.3) (2026-08-30)
2
8
 
3
9
  ### Miscellaneous Chores
@@ -15,11 +15,11 @@
15
15
  <key>CFBundlePackageType</key>
16
16
  <string>FMWK</string>
17
17
  <key>CFBundleShortVersionString</key>
18
- <string>16.11.3</string>
18
+ <string>16.11.4</string>
19
19
  <key>CFBundleSignature</key>
20
20
  <string>????</string>
21
21
  <key>CFBundleVersion</key>
22
- <string>16.11.3</string>
22
+ <string>16.11.4</string>
23
23
  <key>NSPrincipalClass</key>
24
24
  <string/>
25
25
  </dict>
@@ -44,6 +44,15 @@ NS_ASSUME_NONNULL_BEGIN
44
44
  */
45
45
  - (void)reset;
46
46
 
47
+ /**
48
+ Resets the container, but only if it still keeps the given promise. The comparison and the
49
+ reset are performed atomically, so a promise stored concurrently is never dropped.
50
+
51
+ @param screenRecordingPromise the promise the caller expects to be still active
52
+ @return YES if the container has been reset
53
+ */
54
+ - (BOOL)resetIfPromiseIs:(FBScreenRecordingPromise *)screenRecordingPromise;
55
+
47
56
  /**
48
57
  Transforms the container content to a dictionary.
49
58
 
@@ -36,23 +36,39 @@
36
36
  fps:(NSUInteger)fps
37
37
  codec:(long long)codec;
38
38
  {
39
- self.fps = fps;
40
- self.codec = codec;
41
- self.screenRecordingPromise = screenRecordingPromise;
42
- self.startedAt = @([NSDate.date timeIntervalSince1970]);
39
+ @synchronized (self) {
40
+ self.fps = fps;
41
+ self.codec = codec;
42
+ self.screenRecordingPromise = screenRecordingPromise;
43
+ self.startedAt = @([NSDate.date timeIntervalSince1970]);
44
+ }
43
45
  }
44
46
 
45
47
  - (void)reset;
46
48
  {
47
- self.fps = 0;
48
- self.codec = 0;
49
- if (nil != self.screenRecordingPromise) {
50
- [XCTContext runActivityNamed:@"Video Cleanup" block:^(id<XCTActivity> activity){
51
- [activity addAttachment:(XCTAttachment *)self.screenRecordingPromise.nativePromise];
52
- }];
53
- self.screenRecordingPromise = nil;
49
+ @synchronized (self) {
50
+ self.fps = 0;
51
+ self.codec = 0;
52
+ if (nil != self.screenRecordingPromise) {
53
+ [XCTContext runActivityNamed:@"Video Cleanup" block:^(id<XCTActivity> activity){
54
+ [activity addAttachment:(XCTAttachment *)self.screenRecordingPromise.nativePromise];
55
+ }];
56
+ self.screenRecordingPromise = nil;
57
+ }
58
+ self.startedAt = nil;
59
+ }
60
+ }
61
+
62
+ - (BOOL)resetIfPromiseIs:(FBScreenRecordingPromise *)screenRecordingPromise
63
+ {
64
+ // @synchronized is recursive, so -reset may take the very same lock again below.
65
+ @synchronized (self) {
66
+ if (self.screenRecordingPromise != screenRecordingPromise) {
67
+ return NO;
68
+ }
69
+ [self reset];
70
+ return YES;
54
71
  }
55
- self.startedAt = nil;
56
72
  }
57
73
 
58
74
  - (nullable NSDictionary *)toDictionary
@@ -54,6 +54,9 @@ extern NSString* const FBSessionWasKilledNotification;
54
54
  Kills the active session, if any, and blocks until its teardown - including one already started
55
55
  by a concurrent caller - is fully finished. Call this before preparing/launching a replacement
56
56
  application, so it can't race a still-in-progress termination of the outgoing one.
57
+
58
+ @throws FBSessionCreationException if the outgoing application's termination is still in flight
59
+ after the wait, since a replacement must never start while that termination can still land.
57
60
  */
58
61
  + (void)killActiveSessionAndWaitForTeardown;
59
62
 
@@ -50,7 +50,7 @@ NSString *const FBSessionWasKilledNotification = @"FBSessionWasKilledNotificatio
50
50
  @property (nonatomic, readwrite) NSMutableDictionary<NSNumber *, NSMutableDictionary<NSString *, NSNumber *> *> *elementsVisibilityCache;
51
51
 
52
52
  - (BOOL)fb_isTestedApplicationSameAsSystemAppWithTimeout:(NSTimeInterval)timeout;
53
- - (void)fb_terminateTestedApplicationWithTimeout:(NSTimeInterval)timeout;
53
+ - (void)fb_terminateTestedApplicationWithTimeout:(NSTimeInterval)timeout generation:(NSUInteger)generation;
54
54
  @end
55
55
 
56
56
  @interface FBSession (FBAlertsMonitorDelegate)
@@ -100,11 +100,20 @@ NSString *const FBSessionWasKilledNotification = @"FBSessionWasKilledNotificatio
100
100
 
101
101
  @implementation FBSession
102
102
 
103
+ // Guarded, together with the two counters below, by +teardownCondition.
103
104
  static FBSession *_activeSession = nil;
104
105
  // Class-level, not per-instance: a caller that finds _activeSession already nil (a concurrent
105
106
  // -kill beat it there) still needs to know whether that -kill's teardown is done, since it cleared
106
107
  // the pointer before running it. See +waitForActiveTeardownWithTimeout:.
107
- static BOOL _isTeardownInProgress = NO;
108
+ // A count, not a flag: the bounded wait below lets teardowns overlap, so one of them finishing
109
+ // must not wake waiters while another still runs.
110
+ static NSUInteger _activeTeardownCount = 0;
111
+ // Bumped once a caller owns the device, before it launches anything; a teardown still running past
112
+ // the bounded wait re-checks it before touching process-wide state.
113
+ static NSUInteger _sessionGeneration = 0;
114
+ // Teardowns that have claimed the current generation and are committed to terminating the app.
115
+ // The generation bump waits for these to drain, so a claim and a bump can never interleave.
116
+ static NSUInteger _committedTerminationCount = 0;
108
117
 
109
118
  + (NSCondition *)teardownCondition
110
119
  {
@@ -116,25 +125,29 @@ static BOOL _isTeardownInProgress = NO;
116
125
  return condition;
117
126
  }
118
127
 
119
- // Waits (bounded) for any -kill teardown currently in progress to finish.
128
+ // Waits (bounded) for every -kill teardown currently in progress to finish.
120
129
  + (void)waitForActiveTeardownWithTimeout:(NSTimeInterval)timeout
121
130
  {
122
131
  NSCondition *condition = self.teardownCondition;
123
132
  [condition lock];
124
133
  NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:timeout];
125
- while (_isTeardownInProgress && [condition waitUntilDate:deadline]) {
134
+ while (_activeTeardownCount > 0 && [condition waitUntilDate:deadline]) {
126
135
  }
127
136
  [condition unlock];
128
137
  }
129
138
 
130
139
  + (instancetype)activeSession
131
140
  {
132
- return _activeSession;
141
+ NSCondition *condition = self.teardownCondition;
142
+ [condition lock];
143
+ FBSession *session = _activeSession;
144
+ [condition unlock];
145
+ return session;
133
146
  }
134
147
 
135
148
  + (void)killActiveSessionAndWaitForTeardown
136
149
  {
137
- FBSession *session = _activeSession;
150
+ FBSession *session = self.activeSession;
138
151
  if (nil != session) {
139
152
  // Runs the real teardown synchronously if this call wins the race in -kill, or waits for
140
153
  // whoever did to finish if it lost - either way, blocks until torn down.
@@ -144,12 +157,69 @@ static BOOL _isTeardownInProgress = NO;
144
157
  // be mid-teardown - wait for it, so we don't launch a replacement app too early.
145
158
  [self waitForActiveTeardownWithTimeout:FB_KILL_WAIT_TIMEOUT_SEC];
146
159
  }
160
+ // Claimed before the caller launches its replacement app: if the bounded wait expired with a
161
+ // teardown still running, that teardown must be stale by the time the new app exists.
162
+ NSCondition *condition = self.teardownCondition;
163
+ [condition lock];
164
+ // A committed -terminate cannot be revoked, so the next generation must never be handed out
165
+ // while one is in flight - give up on the new session instead of racing it.
166
+ NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:FB_APP_TERMINATE_TIMEOUT_SEC];
167
+ while (_committedTerminationCount > 0 && [condition waitUntilDate:deadline]) {
168
+ }
169
+ BOOL isTerminationPending = _committedTerminationCount > 0;
170
+ if (!isTerminationPending) {
171
+ _sessionGeneration++;
172
+ }
173
+ [condition unlock];
174
+ if (isTerminationPending) {
175
+ NSString *reason = [NSString stringWithFormat:@"The termination of a previous session's application is still in progress after %@ seconds. Please retry the session creation later", @(FB_APP_TERMINATE_TIMEOUT_SEC)];
176
+ @throw [NSException exceptionWithName:FBSessionCreationException reason:reason userInfo:nil];
177
+ }
147
178
  }
148
179
 
149
180
  + (void)markSessionActive:(FBSession *)session
150
181
  {
151
182
  [self killActiveSessionAndWaitForTeardown];
183
+ NSCondition *condition = self.teardownCondition;
184
+ [condition lock];
152
185
  _activeSession = session;
186
+ [condition unlock];
187
+ }
188
+
189
+ // Validates and claims `generation` in one critical section, so no replacement can be handed the
190
+ // next generation until the matching +endTermination. NO means this teardown is already stale.
191
+ + (BOOL)beginTerminationForGeneration:(NSUInteger)generation
192
+ {
193
+ NSCondition *condition = self.teardownCondition;
194
+ [condition lock];
195
+ BOOL isCurrent = generation == _sessionGeneration;
196
+ if (isCurrent) {
197
+ _committedTerminationCount++;
198
+ }
199
+ [condition unlock];
200
+ return isCurrent;
201
+ }
202
+
203
+ + (void)endTermination
204
+ {
205
+ NSCondition *condition = self.teardownCondition;
206
+ [condition lock];
207
+ _committedTerminationCount--;
208
+ [condition broadcast];
209
+ [condition unlock];
210
+ }
211
+
212
+ // Read in the same critical section that validates the generation: a replacement would have bumped
213
+ // the generation before storing anything, so a promise captured here can never be its.
214
+ + (FBScreenRecordingPromise *)activeScreenRecordingForGeneration:(NSUInteger)generation
215
+ {
216
+ NSCondition *condition = self.teardownCondition;
217
+ [condition lock];
218
+ FBScreenRecordingPromise *promise = generation == _sessionGeneration
219
+ ? FBScreenRecordingContainer.sharedInstance.screenRecordingPromise
220
+ : nil;
221
+ [condition unlock];
222
+ return promise;
153
223
  }
154
224
 
155
225
  + (instancetype)sessionWithIdentifier:(NSString *)identifier
@@ -157,10 +227,9 @@ static BOOL _isTeardownInProgress = NO;
157
227
  if (!identifier) {
158
228
  return nil;
159
229
  }
160
- if (![identifier isEqualToString:_activeSession.identifier]) {
161
- return nil;
162
- }
163
- return _activeSession;
230
+ // A single snapshot: reading the global twice could validate one session and return another.
231
+ FBSession *session = self.activeSession;
232
+ return [identifier isEqualToString:session.identifier] ? session : nil;
164
233
  }
165
234
 
166
235
  + (instancetype)initWithApplication:(XCUIApplication *)application
@@ -220,13 +289,20 @@ static BOOL _isTeardownInProgress = NO;
220
289
  // DELETE /session and session creation can now run concurrently, so a session already
221
290
  // superseded by a newer one can still reach here via a stale reference. Check-and-clear must be
222
291
  // atomic, else a belated -kill could null out the new session's pointer instead of its own.
292
+ NSCondition *teardownCondition = self.class.teardownCondition;
223
293
  BOOL wasActive;
224
- @synchronized (self.class) {
225
- wasActive = (self == _activeSession);
226
- if (wasActive) {
227
- _activeSession = nil;
228
- }
294
+ NSUInteger generation;
295
+ [teardownCondition lock];
296
+ wasActive = (self == _activeSession);
297
+ // Captured so the teardown steps below can tell whether a replacement has claimed the device.
298
+ generation = _sessionGeneration;
299
+ if (wasActive) {
300
+ _activeSession = nil;
301
+ // Registered in the same critical section as the clear above, else a concurrent session
302
+ // creation could observe neither an active session nor a teardown and skip its wait.
303
+ _activeTeardownCount++;
229
304
  }
305
+ [teardownCondition unlock];
230
306
  if (!wasActive) {
231
307
  // Someone else is already tearing this session down - wait for that to finish (bounded), so
232
308
  // we don't act as if it's gone (e.g. launch a new app) while its -terminate is still in flight.
@@ -234,24 +310,23 @@ static BOOL _isTeardownInProgress = NO;
234
310
  return;
235
311
  }
236
312
 
237
- NSCondition *teardownCondition = self.class.teardownCondition;
238
- [teardownCondition lock];
239
- _isTeardownInProgress = YES;
240
- [teardownCondition unlock];
241
-
242
313
  @try {
243
314
  // Posted before teardown so pending HTTP requests for this session can stop waiting sooner.
244
315
  [NSNotificationCenter.defaultCenter postNotificationName:FBSessionWasKilledNotification object:self];
245
316
 
246
317
  [self disableAlertsMonitor];
247
318
 
248
- FBScreenRecordingPromise *activeScreenRecording = FBScreenRecordingContainer.sharedInstance.screenRecordingPromise;
319
+ // The container is process-wide, so only act on a promise captured while this teardown still
320
+ // owned the generation - nil here means it is stale and must leave the recording alone.
321
+ FBScreenRecordingPromise *activeScreenRecording = [self.class activeScreenRecordingForGeneration:generation];
249
322
  if (nil != activeScreenRecording) {
250
323
  NSError *error;
251
324
  if (![FBXCTestDaemonsProxy stopScreenRecordingWithUUID:activeScreenRecording.identifier error:&error]) {
252
325
  [FBLogger logFmt:@"%@", error];
253
326
  }
254
- [FBScreenRecordingContainer.sharedInstance reset];
327
+ // Identity, not generation: the stop above may outlast a replacement storing its own promise.
328
+ // Compare-and-reset, so that replacement's store cannot land between the check and the reset.
329
+ [FBScreenRecordingContainer.sharedInstance resetIfPromiseIs:activeScreenRecording];
255
330
  }
256
331
 
257
332
  if (nil != self.testedApplication
@@ -259,13 +334,14 @@ static BOOL _isTeardownInProgress = NO;
259
334
  && self.testedApplication.running
260
335
  && ![self fb_isTestedApplicationSameAsSystemAppWithTimeout:FB_IS_SYSTEM_APP_CHECK_TIMEOUT_SEC]) {
261
336
  // 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];
337
+ // pending) - see -fb_terminateTestedApplicationWithTimeout:generation: - so it's safe to
338
+ // report this teardown as finished as soon as this returns.
339
+ [self fb_terminateTestedApplicationWithTimeout:FB_APP_TERMINATE_TIMEOUT_SEC generation:generation];
265
340
  }
266
341
  } @finally {
267
342
  [teardownCondition lock];
268
- _isTeardownInProgress = NO;
343
+ _activeTeardownCount--;
344
+ // Unconditional: waiters re-check the count, so a wake-up mid-teardown just puts them back.
269
345
  [teardownCondition broadcast];
270
346
  [teardownCondition unlock];
271
347
  }
@@ -394,7 +470,7 @@ static BOOL _isTeardownInProgress = NO;
394
470
  // `timeout` - but a "given up on" call must never still terminate whatever's running by the time
395
471
  // main gets to it (e.g. a replacement session's app), so cancellation and the actual terminate
396
472
  // call share a lock: whichever gets there first - the dispatched block, or the timeout - wins.
397
- - (void)fb_terminateTestedApplicationWithTimeout:(NSTimeInterval)timeout
473
+ - (void)fb_terminateTestedApplicationWithTimeout:(NSTimeInterval)timeout generation:(NSUInteger)generation
398
474
  {
399
475
  XCUIApplication *application = self.testedApplication;
400
476
  NSObject *lock = [NSObject new];
@@ -402,11 +478,16 @@ static BOOL _isTeardownInProgress = NO;
402
478
  dispatch_semaphore_t sem = dispatch_semaphore_create(0);
403
479
  dispatch_async(dispatch_get_main_queue(), ^{
404
480
  @synchronized (lock) {
405
- if (isAllowedToTerminate) {
481
+ // Re-checked here, not before dispatching: this block can sit on a busy main queue past the
482
+ // teardown wait, and a replacement usually runs the same bundle ID as the app to terminate.
483
+ // The claim is held across -terminate, so no replacement can take the next generation mid-call.
484
+ if (isAllowedToTerminate && [self.class beginTerminationForGeneration:generation]) {
406
485
  @try {
407
486
  [application terminate];
408
487
  } @catch (NSException *e) {
409
488
  [FBLogger logFmt:@"%@", e.description];
489
+ } @finally {
490
+ [self.class endTermination];
410
491
  }
411
492
  }
412
493
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appium-webdriveragent",
3
- "version": "16.11.3",
3
+ "version": "16.11.4",
4
4
  "description": "Package bundling WebDriverAgent",
5
5
  "keywords": [
6
6
  "Appium",