Skip to content

Commit bcc8233

Browse files
author
ComputelessComputer
committed
Serialize overlapping accessibility captures
Guard collector captures so timer and app-activation snapshots cannot overlap, reduce repeated AX child sorting lookups, and add coverage for the capture gate.
1 parent 5b42fe4 commit bcc8233

3 files changed

Lines changed: 120 additions & 21 deletions

File tree

Sources/OpenbirdKit/Capture/AccessibilitySnapshotter.swift

Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -178,29 +178,30 @@ public struct AccessibilitySnapshotter: Sendable {
178178
}
179179

180180
private func prioritizedChildren(for element: AXUIElement) -> [AXUIElement] {
181-
copyChildren(for: element).sorted { lhs, rhs in
182-
let lhsRole = stringAttribute(kAXRoleAttribute, on: lhs) ?? ""
183-
let rhsRole = stringAttribute(kAXRoleAttribute, on: rhs) ?? ""
184-
let lhsPriority = childPriority(for: lhsRole)
185-
let rhsPriority = childPriority(for: rhsRole)
186-
if lhsPriority != rhsPriority {
187-
return lhsPriority > rhsPriority
181+
copyChildren(for: element)
182+
.map { child in
183+
let role = stringAttribute(kAXRoleAttribute, on: child) ?? ""
184+
return PrioritizedChild(
185+
element: child,
186+
role: role,
187+
priority: childPriority(for: role),
188+
area: elementArea(for: child),
189+
originX: elementOriginX(for: child)
190+
)
188191
}
189-
190-
let lhsArea = elementArea(for: lhs)
191-
let rhsArea = elementArea(for: rhs)
192-
if lhsArea != rhsArea {
193-
return lhsArea > rhsArea
194-
}
195-
196-
let lhsX = elementOriginX(for: lhs)
197-
let rhsX = elementOriginX(for: rhs)
198-
if lhsX != rhsX {
199-
return lhsX > rhsX
192+
.sorted { lhs, rhs in
193+
if lhs.priority != rhs.priority {
194+
return lhs.priority > rhs.priority
195+
}
196+
if lhs.area != rhs.area {
197+
return lhs.area > rhs.area
198+
}
199+
if lhs.originX != rhs.originX {
200+
return lhs.originX > rhs.originX
201+
}
202+
return lhs.role < rhs.role
200203
}
201-
202-
return lhsRole < rhsRole
203-
}
204+
.map(\.element)
204205
}
205206

206207
private func stringAttribute(_ attribute: String, on element: AXUIElement) -> String? {
@@ -296,6 +297,14 @@ public struct AccessibilitySnapshotter: Sendable {
296297
}
297298
}
298299

300+
private struct PrioritizedChild {
301+
let element: AXUIElement
302+
let role: String
303+
let priority: Int
304+
let area: CGFloat
305+
let originX: CGFloat
306+
}
307+
299308
private extension Array where Element: Hashable {
300309
func removingDuplicates() -> [Element] {
301310
var seen = Set<Element>()

Sources/OpenbirdKit/Capture/CollectorRuntime.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ public final class CollectorRuntime: NSObject, @unchecked Sendable {
88
private let snapshotter = AccessibilitySnapshotter()
99
private let browserURLResolver = BrowserURLResolver()
1010
private let exclusionEngine = ExclusionEngine()
11+
private let captureGate = CaptureGate()
1112
private let captureInterval: TimeInterval
1213
private let ownerID: String
1314
private let ownerName: String
@@ -73,6 +74,12 @@ public final class CollectorRuntime: NSObject, @unchecked Sendable {
7374
}
7475

7576
public func captureNow() async {
77+
await captureGate.runIfIdle {
78+
await performCaptureNow()
79+
}
80+
}
81+
82+
private func performCaptureNow() async {
7683
do {
7784
let now = Date()
7885
let claimedLease = try await store.claimCollectorLease(
@@ -134,3 +141,14 @@ public final class CollectorRuntime: NSObject, @unchecked Sendable {
134141
}
135142
}
136143
}
144+
145+
actor CaptureGate {
146+
private var isRunning = false
147+
148+
func runIfIdle(_ operation: @Sendable () async -> Void) async {
149+
guard isRunning == false else { return }
150+
isRunning = true
151+
defer { isRunning = false }
152+
await operation()
153+
}
154+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import Foundation
2+
import Testing
3+
@testable import OpenbirdKit
4+
5+
struct CaptureGateTests {
6+
@Test func dropsOverlappingWork() async throws {
7+
let gate = CaptureGate()
8+
let recorder = ConcurrencyRecorder()
9+
10+
let first = Task {
11+
await gate.runIfIdle {
12+
await recorder.started()
13+
try? await Task.sleep(for: .milliseconds(100))
14+
await recorder.finished()
15+
}
16+
}
17+
18+
try await Task.sleep(for: .milliseconds(20))
19+
20+
let second = Task {
21+
await gate.runIfIdle {
22+
await recorder.started()
23+
await recorder.finished()
24+
}
25+
}
26+
27+
await first.value
28+
await second.value
29+
30+
let snapshot = await recorder.snapshot()
31+
#expect(snapshot.completed == 1)
32+
#expect(snapshot.maxConcurrent == 1)
33+
}
34+
35+
@Test func acceptsNewWorkAfterCompletion() async {
36+
let gate = CaptureGate()
37+
let recorder = ConcurrencyRecorder()
38+
39+
await gate.runIfIdle {
40+
await recorder.started()
41+
await recorder.finished()
42+
}
43+
44+
await gate.runIfIdle {
45+
await recorder.started()
46+
await recorder.finished()
47+
}
48+
49+
let snapshot = await recorder.snapshot()
50+
#expect(snapshot.completed == 2)
51+
}
52+
}
53+
54+
private actor ConcurrencyRecorder {
55+
private var concurrentCount = 0
56+
private var maxConcurrentCount = 0
57+
private var completedCount = 0
58+
59+
func started() {
60+
concurrentCount += 1
61+
maxConcurrentCount = max(maxConcurrentCount, concurrentCount)
62+
}
63+
64+
func finished() {
65+
concurrentCount -= 1
66+
completedCount += 1
67+
}
68+
69+
func snapshot() -> (completed: Int, maxConcurrent: Int) {
70+
(completedCount, maxConcurrentCount)
71+
}
72+
}

0 commit comments

Comments
 (0)