Skip to content

Commit 2fd5cd8

Browse files
committed
fix(ios): run recovery probes after returning accepted captures
1 parent 96d03c4 commit 2fd5cd8

3 files changed

Lines changed: 50 additions & 6 deletions

File tree

‎apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift‎

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,7 @@ extension RunnerTests {
317317
&& deadline.timeIntervalSince(now) >= Self.penalizedXCTestProbeTreeSliceBudget
318318
}
319319

320-
private func runScheduledSnapshotXCTestRecoveryProbeIfNeeded(
320+
private func scheduleSnapshotXCTestRecoveryProbeIfNeeded(
321321
effective: EffectiveSnapshotCapturePlan,
322322
acceptedBackend: SnapshotBackendKind,
323323
target: SnapshotCaptureTarget,
@@ -338,10 +338,34 @@ extension RunnerTests {
338338
hasAbandonedWork: abandoned
339339
)
340340
if scheduled {
341-
snapshotXCTestChannelNextRecoveryProbeAt = now.addingTimeInterval(Self.snapshotXCTestRecoveryProbeInterval)
341+
snapshotXCTestChannelNextRecoveryProbeAt = .distantFuture
342342
}
343+
let penaltyUntil = snapshotXCTestChannelPenaltyUntil
343344
snapshotXCTestChannelPenaltyLock.unlock()
344345
guard scheduled else { return }
346+
commandExecutionQueue.async {
347+
self.snapshotXCTestChannelPenaltyLock.lock()
348+
let stillCurrent = self.snapshotXCTestChannelPenaltyUntil == penaltyUntil
349+
&& Date() < penaltyUntil
350+
if stillCurrent {
351+
self.snapshotXCTestChannelNextRecoveryProbeAt = Date().addingTimeInterval(Self.snapshotXCTestRecoveryProbeInterval)
352+
}
353+
self.snapshotXCTestChannelPenaltyLock.unlock()
354+
guard stillCurrent, !self.hasAbandonedMainThreadWork(),
355+
deadline.timeIntervalSinceNow >= Self.penalizedXCTestProbeTreeSliceBudget
356+
else { return }
357+
self.runScheduledSnapshotXCTestRecoveryProbe(
358+
target: target, options: options, channelState: effective.xCTestChannelState
359+
)
360+
}
361+
}
362+
363+
private func runScheduledSnapshotXCTestRecoveryProbe(
364+
target: SnapshotCaptureTarget,
365+
options: PresentationOptions,
366+
channelState: SnapshotXCTestChannelPlanState
367+
) {
368+
let now = Date()
345369
let slice = Self.penalizedXCTestProbeTreeSliceBudget
346370
NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_XCTEST_RECOVERY_PROBE_SCHEDULED bundle=%@", target.bundleId ?? "")
347371
defer {
@@ -368,7 +392,7 @@ extension RunnerTests {
368392
attempt: attempt,
369393
bundleId: target.bundleId,
370394
penaltySuppressed: false,
371-
channelState: effective.xCTestChannelState
395+
channelState: channelState
372396
)
373397
}
374398

@@ -512,7 +536,7 @@ extension RunnerTests {
512536
verdictReason?.reason ?? "sparse tree"
513537
)
514538
}
515-
runScheduledSnapshotXCTestRecoveryProbeIfNeeded(
539+
scheduleSnapshotXCTestRecoveryProbeIfNeeded(
516540
effective: effective,
517541
acceptedBackend: kind,
518542
target: target,

‎apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanOccupancyTests.swift‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -548,19 +548,37 @@ extension RunnerTests {
548548
snapshotXCTestChannelNextRecoveryProbeAt = .distantPast
549549
let expiry = snapshotXCTestChannelPenaltyUntil
550550
let started = Date()
551-
let payload = try runDeferredPlanOffMain(target: captureTarget) {
551+
let payload = try runDeferredPlanOffMain(target: captureTarget)
552+
XCTAssertLessThan(Date().timeIntervalSince(started), 0.5, "the accepted capture must answer before the optional probe")
553+
let probeFinished = expectation(description: "scheduled probe answered")
554+
commandExecutionQueue.async {
552555
RunnerBlockingSnapshotGate.release.signal()
556+
probeFinished.fulfill()
553557
}
558+
wait(for: [probeFinished], timeout: 3)
554559
XCTAssertEqual(payload.snapshotQuality?.backend, SnapshotBackendKind.privateAX.rawValue)
555560
XCTAssertEqual(RunnerBlockingSnapshotGate.entered.wait(timeout: .now()), .success)
556561
XCTAssertLessThan(Date().timeIntervalSince(started), 2)
557562
XCTAssertEqual(snapshotXCTestChannelPenaltyUntil, expiry)
558563
let next = snapshotXCTestChannelNextRecoveryProbeAt
559564
XCTAssertGreaterThan(next.timeIntervalSince(started), 29)
560565
_ = try runDeferredPlanOffMain(target: captureTarget)
566+
let queueDrained = expectation(description: "second capture queued no probe")
567+
commandExecutionQueue.async { queueDrained.fulfill() }
568+
wait(for: [queueDrained], timeout: 3)
561569
XCTAssertEqual(snapshotXCTestChannelNextRecoveryProbeAt, next)
562570
XCTAssertEqual(snapshotXCTestChannelPenaltyUntil, expiry)
563571
XCTAssertEqual(RunnerBlockingSnapshotGate.entered.wait(timeout: .now()), .timedOut)
572+
573+
snapshotXCTestChannelNextRecoveryProbeAt = .distantPast
574+
_ = try runDeferredPlanOffMain(target: captureTarget) {
575+
self.clearSnapshotXCTestChannelPenalty(reason: "replace-before-probe")
576+
self.penalizeSnapshotXCTestChannel(bundleId: captureTarget.bundleId, reason: "new-penalty")
577+
}
578+
let staleProbeDrained = expectation(description: "stale queued probe refused")
579+
commandExecutionQueue.async { staleProbeDrained.fulfill() }
580+
wait(for: [staleProbeDrained], timeout: 3)
581+
XCTAssertEqual(RunnerBlockingSnapshotGate.entered.wait(timeout: .now()), .timedOut)
564582
}
565583

566584
private func launchPenalizedPrivateAXBlindTarget(bundleId: String) throws -> SnapshotCaptureTarget {
@@ -602,7 +620,7 @@ extension RunnerTests {
602620
}
603621
let box = ResultBox()
604622
let planned = expectation(description: "deferred plan answered")
605-
DispatchQueue(label: "agent-device.runner.tests.deferred-plan").async {
623+
commandExecutionQueue.async {
606624
do {
607625
box.payload = try self.runSnapshotCapturePlan(
608626
Self.regularVisiblePlan,

‎docs/adr/0004-ios-snapshot-backend-strategy.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,8 @@ After an independent backend succeeds, a penalized regular plan can schedule the
246246
The interval starts when the penalty is armed.
247247
The one-second idle-only probe bounds all acquisition and processing work.
248248
The accepted independent capture remains the response.
249+
The existing serial command queue runs the optional probe after that response.
250+
A queued probe reserves the interval and checks that its penalty is still current before it starts.
249251
The schedule excludes physical-device plans, explicit backend pins, occupied main-thread work, and plans with less than one second remaining.
250252
The interval limits hostile-screen probe cost while giving healthy screens a recovery opportunity before penalty expiry.
251253
A normal-plan query-sweep timeout rejects the partial capture and penalizes the channel.

0 commit comments

Comments
 (0)