@swmansion/argent 0.23.0 → 0.23.1-next.0

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.
Files changed (44) hide show
  1. package/README.md +1 -1
  2. package/bin/argent-android-devtools-0.1.0.apk +0 -0
  3. package/bin/darwin/ax-service +0 -0
  4. package/bin/darwin/tvos-ax-service +0 -0
  5. package/bin/darwin/tvos-hid-daemon +0 -0
  6. package/bin/tcp/ax-service +0 -0
  7. package/dist/cli-cmds.mjs +17 -2
  8. package/dist/installer.mjs +17 -2
  9. package/dist/ios-device-runner/ArgentRunner/ArgentRunner/RunnerHostApp.swift +37 -0
  10. package/dist/ios-device-runner/ArgentRunner/ArgentRunner.xcodeproj/project.pbxproj +389 -0
  11. package/dist/ios-device-runner/ArgentRunner/ArgentRunner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  12. package/dist/ios-device-runner/ArgentRunner/ArgentRunner.xcodeproj/xcshareddata/xcschemes/ArgentRunner.xcscheme +88 -0
  13. package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentExceptionGuard.h +16 -0
  14. package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentExceptionGuard.m +16 -0
  15. package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentRunnerSession+Commands.swift +280 -0
  16. package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentRunnerSession+Gestures.swift +149 -0
  17. package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentRunnerSession+Screenshot.swift +21 -0
  18. package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentRunnerSession+Snapshot.swift +327 -0
  19. package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentRunnerSession+TextEntry.swift +157 -0
  20. package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentRunnerSession.swift +410 -0
  21. package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentRunnerUITests-Bridging-Header.h +1 -0
  22. package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/CommandJournal.swift +134 -0
  23. package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/MainThreadGate.swift +111 -0
  24. package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/RunnerHTTPServer.swift +267 -0
  25. package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/RunnerProtocol.swift +331 -0
  26. package/dist/mcp-server.mjs +16 -1
  27. package/dist/tool-server.cjs +4224 -1388
  28. package/dylibs/libArgentInjectionBootstrap.dylib +0 -0
  29. package/dylibs/libKeyboardPatch.dylib +0 -0
  30. package/dylibs/libNativeDevtoolsIos.dylib +0 -0
  31. package/dylibs/tcp/libArgentInjectionBootstrap.dylib +0 -0
  32. package/dylibs/tcp/libKeyboardPatch.dylib +0 -0
  33. package/dylibs/tcp/libNativeDevtoolsIos.dylib +0 -0
  34. package/dylibs/tvos/libArgentInjectionBootstrap.dylib +0 -0
  35. package/dylibs/tvos/libKeyboardPatch.dylib +0 -0
  36. package/dylibs/tvos/libNativeDevtoolsIos.dylib +0 -0
  37. package/package.json +1 -1
  38. package/rules/argent.md +8 -3
  39. package/skills/argent-create-flow/SKILL.md +1 -0
  40. package/skills/argent-device-interact/SKILL.md +2 -0
  41. package/skills/argent-ios-device-interact/SKILL.md +18 -0
  42. package/skills/argent-ios-device-setup/SKILL.md +20 -0
  43. package/skills/argent-qa-flows/SKILL.md +2 -0
  44. package/skills/argent-screenshot-diff/SKILL.md +2 -0
@@ -0,0 +1,410 @@
1
+ import Network
2
+ import XCTest
3
+
4
+ /// The Argent on-device automation server, hosted inside an XCUITest.
5
+ ///
6
+ /// XCUITest is the only Apple-supported way to drive arbitrary apps on a
7
+ /// physical iOS device, so this "test" starts an HTTP command server and
8
+ /// stays in a long wait instead of running one scripted scenario.
9
+ ///
10
+ /// Layering: RunnerHTTPServer (framing) → dispatch here (decode, journal,
11
+ /// duplicate-send coalescing, busy gate) → command extensions (XCTest work).
12
+ final class ArgentRunnerSession: XCTestCase {
13
+ /// Uptime anchor for the health payload, captured when the session
14
+ /// instance is created.
15
+ private let launchedAt = Date()
16
+
17
+ let gate = MainThreadGate()
18
+ private let journal = CommandJournal()
19
+ /// Commands execute here one at a time, in arrival order. `status` never
20
+ /// enters this queue, so health checks and lost-reply recovery stay
21
+ /// responsive while a command runs.
22
+ private let executionQueue = DispatchQueue(label: "argent.runner.execution")
23
+
24
+ private let finishLock = NSLock()
25
+ private var done: XCTestExpectation?
26
+
27
+ /// Duplicate transport sends of one commandId (a client retry racing a slow
28
+ /// execution) attach to the in-flight execution and share its reply instead
29
+ /// of executing twice.
30
+ private let inFlightLock = NSLock()
31
+ private var inFlightReplies: [String: [(RunnerHTTPServer.Reply) -> Void]] =
32
+ [:]
33
+
34
+ private let suppressedIssuesLock = NSLock()
35
+ private var suppressedIssueCount = 0
36
+
37
+ /// Keeps the session alive after a recorded failure.
38
+ override func setUp() {
39
+ continueAfterFailure = true
40
+ }
41
+
42
+ // MARK: - Issue filtering
43
+
44
+ /// The suppression matchers, verbatim. XCTIssue exposes no stable code for
45
+ /// these shapes, so classification substring-matches Apple-owned wording in
46
+ /// `compactDescription`. PROTOCOL.md's `status` section pins these exact
47
+ /// strings as part of the contract.
48
+ enum SuppressedIssueWording {
49
+ /// Every suppression candidate must contain this snapshot-fetch wording.
50
+ static let gate = "Failed to get matching snapshot"
51
+ /// Marks a genuinely hung query. Issues containing it stay recorded.
52
+ static let keepRecorded = "Timed out while evaluating UI query"
53
+ /// Pure accessibility noise: an AX server error, and the variant a stale
54
+ /// element produces after the UI it referenced moved on.
55
+ static let noise = ["kAXError", "No matches found for"]
56
+ }
57
+
58
+ /// Whether an issue description is known accessibility noise that should be
59
+ /// muted rather than recorded. XCTest tears the test down once recorded
60
+ /// issues accumulate, and the server has to survive long sessions.
61
+ static func isSuppressedAccessibilityIssue(_ description: String) -> Bool {
62
+ guard description.contains(SuppressedIssueWording.gate) else {
63
+ return false
64
+ }
65
+
66
+ // A hung UI query must stay recorded so the recorded-failure check keeps
67
+ // seeing it.
68
+ if description.contains(SuppressedIssueWording.keepRecorded) {
69
+ return false
70
+ }
71
+
72
+ return SuppressedIssueWording.noise.contains {
73
+ description.contains($0)
74
+ }
75
+ }
76
+
77
+ /// Intercepts every XCTest issue, counting and muting known accessibility
78
+ /// noise and recording the rest.
79
+ override func record(_ issue: XCTIssue) {
80
+ let description = issue.compactDescription
81
+
82
+ if Self.isSuppressedAccessibilityIssue(description) {
83
+ suppressedIssuesLock.lock()
84
+ suppressedIssueCount += 1
85
+ let count = suppressedIssueCount
86
+ suppressedIssuesLock.unlock()
87
+
88
+ NSLog(
89
+ "ARGENT_RUNNER_AX_ISSUE_SUPPRESSED count=%ld description=%@",
90
+ count,
91
+ description
92
+ )
93
+
94
+ return
95
+ }
96
+
97
+ super.record(issue)
98
+ }
99
+
100
+ // MARK: - Entry point
101
+
102
+ /// The session entry point: starts the HTTP command server and parks
103
+ /// until a shutdown command or a listener failure ends the session.
104
+ @MainActor
105
+ func testServeCommands() throws {
106
+ let port = Self.configuredPort()
107
+ NSLog("ARGENT_RUNNER_STARTING requestedPort=%d", Int(port))
108
+
109
+ let done = expectation(description: "argent runner shutdown")
110
+ finishLock.lock()
111
+ self.done = done
112
+ finishLock.unlock()
113
+
114
+ let server = RunnerHTTPServer(
115
+ dispatch: { [weak self] body, deliver in
116
+ self?.dispatch(body: body, deliver: deliver)
117
+ },
118
+ onFinish: { [weak self] in self?.finish() },
119
+ onListenerFailure: { [weak self] description in
120
+ self?.abort(listenerFailure: description)
121
+ }
122
+ )
123
+
124
+ try server.start(port: port)
125
+ NSLog("ARGENT_RUNNER_SERVING")
126
+
127
+ // The wait pumps the main run loop, so command handlers can hop onto the
128
+ // main thread while Network.framework serves connections on background
129
+ // queues. Every XCTest UI call must run on the main thread.
130
+ let outcome = XCTWaiter.wait(for: [done], timeout: 24 * 60 * 60)
131
+ NSLog("ARGENT_RUNNER_STOPPED outcome=%@", String(describing: outcome))
132
+ server.stop()
133
+
134
+ if outcome != .completed {
135
+ XCTFail(
136
+ "runner session ended without a shutdown command (\(outcome))"
137
+ )
138
+ }
139
+ }
140
+
141
+ /// The port requested through the environment, forwarded by xcodebuild
142
+ /// from TEST_RUNNER_ARGENT_RUNNER_PORT with the prefix stripped. Returning
143
+ /// 0 lets the system pick one when the session runs directly from Xcode.
144
+ static func configuredPort() -> UInt16 {
145
+ if let raw = ProcessInfo.processInfo.environment["ARGENT_RUNNER_PORT"],
146
+ let port = UInt16(raw)
147
+ {
148
+ return port
149
+ }
150
+
151
+ return 0
152
+ }
153
+
154
+ /// Ends the session wait, fulfilling the shutdown expectation exactly
155
+ /// once. Reached from the shutdown reply; this is the clean end. A
156
+ /// listener failure goes through `abort(listenerFailure:)` instead.
157
+ private func finish() {
158
+ finishLock.lock()
159
+ let expectation = done
160
+ done = nil
161
+ finishLock.unlock()
162
+
163
+ expectation?.fulfill()
164
+ }
165
+
166
+ /// Ends the session as a failed test after the listener failed. The
167
+ /// failure is recorded before the wait is released, so the xcresult and
168
+ /// the xcodebuild exit code carry the cause.
169
+ private func abort(listenerFailure description: String) {
170
+ DispatchQueue.main.async {
171
+ XCTFail("listener failed: \(description)")
172
+ self.finish()
173
+ }
174
+ }
175
+
176
+ // MARK: - Dispatch (transport queue)
177
+
178
+ /// Routes one request body from the HTTP server: answers `status` inline,
179
+ /// coalesces duplicate sends, and queues every other command for serial
180
+ /// execution.
181
+ private func dispatch(
182
+ body: Data,
183
+ deliver: @escaping (RunnerHTTPServer.Reply) -> Void
184
+ ) {
185
+ let request: CommandRequest
186
+
187
+ do {
188
+ request = try JSONDecoder().decode(CommandRequest.self, from: body)
189
+ } catch {
190
+ deliver(
191
+ Self.encodeReply(
192
+ status: 400,
193
+ envelope: .failure(
194
+ .invalidRequest,
195
+ "unrecognized command payload: \(error)",
196
+ hint:
197
+ "Check the command name and fields against PROTOCOL.md."
198
+ )
199
+ )
200
+ )
201
+ return
202
+ }
203
+
204
+ // Status is answered inline on the transport queue so it keeps answering
205
+ // while the execution queue is busy.
206
+ if request.command == .status {
207
+ deliver(
208
+ Self.encodeReply(
209
+ status: 200,
210
+ envelope: statusEnvelope(for: request)
211
+ )
212
+ )
213
+ return
214
+ }
215
+
216
+ if attachToInFlight(request, deliver: deliver) {
217
+ return
218
+ }
219
+
220
+ NSLog(
221
+ "ARGENT_RUNNER_COMMAND_ACCEPTED command=%@ commandId=%@",
222
+ request.command.rawValue,
223
+ request.normalizedCommandId ?? ""
224
+ )
225
+
226
+ journal.accept(request)
227
+
228
+ executionQueue.async {
229
+ self.journal.started(request)
230
+
231
+ let envelope = self.executeGated(request)
232
+ let reply = Self.encodeReply(
233
+ status: envelope.ok ? 200 : 500,
234
+ envelope: envelope,
235
+ finishAfterSend: request.command == .shutdown && envelope.ok
236
+ )
237
+
238
+ self.journal.finished(
239
+ request,
240
+ envelope: envelope,
241
+ encodedEnvelope: reply.body
242
+ )
243
+
244
+ NSLog(
245
+ "ARGENT_RUNNER_COMMAND_FINISHED command=%@ commandId=%@ ok=%d",
246
+ request.command.rawValue,
247
+ request.normalizedCommandId ?? "",
248
+ envelope.ok ? 1 : 0
249
+ )
250
+
251
+ self.deliverReleasingInFlight(
252
+ request,
253
+ reply: reply,
254
+ deliver: deliver
255
+ )
256
+ }
257
+ }
258
+
259
+ /// Builds the `status` reply: a journal lookup when `statusCommandId` is
260
+ /// present, otherwise the runner health payload.
261
+ private func statusEnvelope(for request: CommandRequest) -> Envelope {
262
+ if let id = request.statusCommandId?.trimmingCharacters(
263
+ in: .whitespacesAndNewlines
264
+ ),
265
+ !id.isEmpty
266
+ {
267
+ return .success(journal.status(commandId: id))
268
+ }
269
+
270
+ let state: String
271
+
272
+ switch gate.availability() {
273
+ case .idle: state = "idle"
274
+ case .busy: state = "busy"
275
+ case .wedged: state = "wedged"
276
+ }
277
+
278
+ return .success(
279
+ HealthPayload(
280
+ uptimeMs: Date().timeIntervalSince(launchedAt) * 1000,
281
+ state: state,
282
+ suppressedIssues: currentSuppressedIssueCount(),
283
+ // Status must answer while a command runs, so testRun is read off the
284
+ // main thread. That is acceptable for an informational counter.
285
+ recordedFailures: testRun?.totalFailureCount ?? 0
286
+ )
287
+ )
288
+ }
289
+
290
+ /// The number of issues muted so far. Feeds the health payload, and
291
+ /// `performOnMain` reads it before and after each command to surface a
292
+ /// suppression delta as an envelope warning.
293
+ func currentSuppressedIssueCount() -> Int {
294
+ suppressedIssuesLock.lock()
295
+ defer { suppressedIssuesLock.unlock() }
296
+ return suppressedIssueCount
297
+ }
298
+
299
+ /// Returns true after attaching a duplicate send to the execution already
300
+ /// running under the same commandId, otherwise marks the id as in flight.
301
+ private func attachToInFlight(
302
+ _ request: CommandRequest,
303
+ deliver: @escaping (RunnerHTTPServer.Reply) -> Void
304
+ ) -> Bool {
305
+ guard let id = request.normalizedCommandId else { return false }
306
+
307
+ inFlightLock.lock()
308
+ defer { inFlightLock.unlock() }
309
+
310
+ if inFlightReplies[id] != nil {
311
+ inFlightReplies[id]?.append(deliver)
312
+ NSLog("ARGENT_RUNNER_DUPLICATE_SEND_COALESCED commandId=%@", id)
313
+ return true
314
+ }
315
+
316
+ inFlightReplies[id] = []
317
+
318
+ return false
319
+ }
320
+
321
+ /// Delivers the reply to the original sender and to every duplicate send
322
+ /// that attached while the command ran.
323
+ private func deliverReleasingInFlight(
324
+ _ request: CommandRequest,
325
+ reply: RunnerHTTPServer.Reply,
326
+ deliver: (RunnerHTTPServer.Reply) -> Void
327
+ ) {
328
+ var waiters: [(RunnerHTTPServer.Reply) -> Void] = []
329
+
330
+ if let id = request.normalizedCommandId {
331
+ inFlightLock.lock()
332
+ waiters = inFlightReplies.removeValue(forKey: id) ?? []
333
+ inFlightLock.unlock()
334
+ }
335
+
336
+ deliver(reply)
337
+
338
+ for waiter in waiters {
339
+ waiter(reply)
340
+ }
341
+ }
342
+
343
+ // MARK: - Execution (serial queue → main thread)
344
+
345
+ /// Runs the command on the main thread under its watchdog budget. Refuses
346
+ /// immediately while the main thread is still occupied by abandoned work.
347
+ private func executeGated(_ request: CommandRequest) -> Envelope {
348
+ switch gate.availability() {
349
+ case .busy(let seconds):
350
+ NSLog(
351
+ "ARGENT_RUNNER_BUSY command=%@ abandonedFor=%.1f",
352
+ request.command.rawValue,
353
+ seconds
354
+ )
355
+ return .failure(
356
+ .runnerBusy,
357
+ "The runner is still finishing a previous command that overran its watchdog "
358
+ + "(usually an accessibility capture on a heavy or animating screen).",
359
+ hint:
360
+ "Wait a few seconds and retry; if snapshots keep failing on this screen, use "
361
+ + "screenshot as visual truth and interact by coordinates."
362
+ )
363
+ case .wedged(let seconds):
364
+ NSLog("ARGENT_RUNNER_WEDGED abandonedFor=%.1f", seconds)
365
+ return .failure(
366
+ .runnerWedged,
367
+ "The runner's main thread has been stuck in abandoned work for \(Int(seconds))s "
368
+ + "and cannot recover on its own.",
369
+ hint: "Restart the runner session, then retry the command."
370
+ )
371
+ case .idle:
372
+ break
373
+ }
374
+
375
+ do {
376
+ return try gate.run(timeout: request.command.executionTimeout) {
377
+ self.performOnMain(request)
378
+ }
379
+ } catch MainThreadGate.Failure.timedOut {
380
+ return .failure(
381
+ .commandTimedOut,
382
+ "\(request.command.rawValue) exceeded its \(Int(request.command.executionTimeout))s "
383
+ + "main-thread budget; the work was abandoned and may still complete on the device.",
384
+ hint:
385
+ "Retry after a few seconds; the runner reports busy until the abandoned work drains."
386
+ )
387
+ } catch {
388
+ return .failure(.commandFailed, String(describing: error))
389
+ }
390
+ }
391
+
392
+ /// Encodes an envelope into the HTTP reply handed to the transport layer.
393
+ static func encodeReply(
394
+ status: Int,
395
+ envelope: Envelope,
396
+ finishAfterSend: Bool = false
397
+ ) -> RunnerHTTPServer.Reply {
398
+ let body =
399
+ (try? JSONEncoder().encode(envelope))
400
+ ?? Data(
401
+ #"{"ok":false,"error":{"code":"COMMAND_FAILED","message":"response encoding failed"}}"#
402
+ .utf8
403
+ )
404
+ return RunnerHTTPServer.Reply(
405
+ status: status,
406
+ body: body,
407
+ finishAfterSend: finishAfterSend
408
+ )
409
+ }
410
+ }
@@ -0,0 +1,134 @@
1
+ import Foundation
2
+
3
+ /// Records the fate of every identified command. A client whose reply was
4
+ /// lost in transit asks what happened via `status` + `statusCommandId`
5
+ /// instead of replaying a mutation that may already have run.
6
+ final class CommandJournal {
7
+ enum State: String {
8
+ case notAccepted
9
+ case accepted
10
+ case started
11
+ case completed
12
+ case failed
13
+ }
14
+
15
+ private struct Entry {
16
+ let commandId: String
17
+ let command: CommandKind
18
+ var state: State
19
+ var responseOk: Bool? = nil
20
+ var responseJson: String? = nil
21
+ var errorCode: String? = nil
22
+ var errorMessage: String? = nil
23
+ var errorHint: String? = nil
24
+ }
25
+
26
+ private let lock = NSLock()
27
+ private var entries: [String: Entry] = [:]
28
+ private var order: [String] = []
29
+ /// Enough to cover any plausible in-flight window. Recovery asks about
30
+ /// the command that just failed.
31
+ private let maxEntries = 64
32
+ private let maxRetainedResponseBytes = 16 * 1024
33
+
34
+ /// Records an identified command as accepted, before it is queued.
35
+ func accept(_ request: CommandRequest) {
36
+ guard let id = request.normalizedCommandId else { return }
37
+
38
+ lock.lock()
39
+ defer { lock.unlock() }
40
+
41
+ entries[id] = Entry(
42
+ commandId: id,
43
+ command: request.command,
44
+ state: .accepted
45
+ )
46
+
47
+ touch(id)
48
+ }
49
+
50
+ /// Marks the command as started on the execution queue.
51
+ func started(_ request: CommandRequest) {
52
+ guard let id = request.normalizedCommandId else { return }
53
+
54
+ lock.lock()
55
+ defer { lock.unlock() }
56
+
57
+ entries[id]?.state = .started
58
+ }
59
+
60
+ /// Records the command's outcome, retaining the encoded reply when the
61
+ /// command qualifies and the reply is small enough.
62
+ func finished(
63
+ _ request: CommandRequest,
64
+ envelope: Envelope,
65
+ encodedEnvelope: Data
66
+ ) {
67
+ guard let id = request.normalizedCommandId else { return }
68
+
69
+ lock.lock()
70
+ defer { lock.unlock() }
71
+
72
+ var entry =
73
+ entries[id]
74
+ ?? Entry(commandId: id, command: request.command, state: .accepted)
75
+
76
+ entry.state = envelope.ok ? .completed : .failed
77
+ entry.responseOk = envelope.ok
78
+ entry.responseJson =
79
+ request.command.retainsResponseInJournal
80
+ && encodedEnvelope.count <= maxRetainedResponseBytes
81
+ ? String(data: encodedEnvelope, encoding: .utf8)
82
+ : nil
83
+ entry.errorCode = envelope.error?.code
84
+ entry.errorMessage = envelope.error?.message
85
+ entry.errorHint = envelope.error?.hint
86
+
87
+ entries[id] = entry
88
+ touch(id)
89
+ }
90
+
91
+ /// The journaled fate of one commandId, `notAccepted` when the id is
92
+ /// unknown or was evicted.
93
+ func status(commandId: String) -> CommandStatusPayload {
94
+ let trimmed = commandId.trimmingCharacters(in: .whitespacesAndNewlines)
95
+
96
+ lock.lock()
97
+ let entry = entries[trimmed]
98
+ lock.unlock()
99
+
100
+ guard let entry else {
101
+ return CommandStatusPayload(
102
+ commandId: trimmed,
103
+ state: State.notAccepted.rawValue,
104
+ command: nil,
105
+ responseOk: nil,
106
+ responseJson: nil,
107
+ errorCode: nil,
108
+ errorMessage: nil,
109
+ errorHint: nil
110
+ )
111
+ }
112
+
113
+ return CommandStatusPayload(
114
+ commandId: entry.commandId,
115
+ state: entry.state.rawValue,
116
+ command: entry.command.rawValue,
117
+ responseOk: entry.responseOk,
118
+ responseJson: entry.responseJson,
119
+ errorCode: entry.errorCode,
120
+ errorMessage: entry.errorMessage,
121
+ errorHint: entry.errorHint
122
+ )
123
+ }
124
+
125
+ /// Callers hold `lock`.
126
+ private func touch(_ id: String) {
127
+ order.removeAll { $0 == id }
128
+ order.append(id)
129
+
130
+ while order.count > maxEntries {
131
+ entries.removeValue(forKey: order.removeFirst())
132
+ }
133
+ }
134
+ }
@@ -0,0 +1,111 @@
1
+ import Foundation
2
+
3
+ /// Runs command work on the main thread under a per-command timeout and
4
+ /// tracks work the timeout abandoned. While abandoned work is still
5
+ /// running the gate reports `busy`, and past `wedgeThreshold` it reports
6
+ /// `wedged`. A `wedged` report tells the host to recycle the runner.
7
+ final class MainThreadGate {
8
+ enum Availability {
9
+ case idle
10
+ case busy(abandonedForSeconds: TimeInterval)
11
+ case wedged(abandonedForSeconds: TimeInterval)
12
+ }
13
+
14
+ enum Failure: Error {
15
+ case timedOut
16
+ }
17
+
18
+ /// Lifecycle flags for one dispatched block. The lock guarantees that
19
+ /// exactly one of `finished` and `abandoned` ends up set.
20
+ private final class WorkState {
21
+ var finished = false
22
+ var abandoned = false
23
+ }
24
+
25
+ private let lock = NSLock()
26
+ private var abandonedCount = 0
27
+ private var abandonedSince: Date?
28
+ private let wedgeThreshold: TimeInterval
29
+
30
+ init(wedgeThreshold: TimeInterval = 120) {
31
+ self.wedgeThreshold = wedgeThreshold
32
+ }
33
+
34
+ /// The gate's current view of the main thread: idle, busy, or wedged.
35
+ func availability() -> Availability {
36
+ lock.lock()
37
+ defer { lock.unlock() }
38
+
39
+ guard abandonedCount > 0 else { return .idle }
40
+ let stuckFor = abandonedSince.map { Date().timeIntervalSince($0) } ?? 0
41
+
42
+ return stuckFor > wedgeThreshold
43
+ ? .wedged(abandonedForSeconds: stuckFor)
44
+ : .busy(abandonedForSeconds: stuckFor)
45
+ }
46
+
47
+ /// Runs the work on the main thread, throwing `Failure.timedOut` after
48
+ /// marking work that overran the budget as abandoned.
49
+ func run<T>(timeout: TimeInterval, _ work: @escaping () throws -> T) throws
50
+ -> T
51
+ {
52
+ if Thread.isMainThread {
53
+ return try work()
54
+ }
55
+
56
+ let semaphore = DispatchSemaphore(value: 0)
57
+ let state = WorkState()
58
+ var result: Result<T, Error>?
59
+
60
+ // The main run loop is pumped by the XCTWaiter in ArgentRunnerSession.
61
+ DispatchQueue.main.async {
62
+ do {
63
+ result = .success(try work())
64
+ } catch {
65
+ result = .failure(error)
66
+ }
67
+
68
+ self.lock.lock()
69
+ if state.abandoned {
70
+ self.abandonedCount -= 1
71
+
72
+ if self.abandonedCount == 0 {
73
+ self.abandonedSince = nil
74
+ NSLog("ARGENT_RUNNER_ABANDONED_WORK_DRAINED")
75
+ }
76
+ } else {
77
+ state.finished = true
78
+ }
79
+ self.lock.unlock()
80
+
81
+ semaphore.signal()
82
+ }
83
+
84
+ if semaphore.wait(timeout: .now() + timeout) == .timedOut {
85
+ lock.lock()
86
+ if !state.finished {
87
+ // The block cannot be cancelled and keeps running on the main
88
+ // thread. Marking it abandoned makes availability() report busy
89
+ // until it drains.
90
+ state.abandoned = true
91
+ abandonedCount += 1
92
+
93
+ if abandonedSince == nil {
94
+ abandonedSince = Date()
95
+ }
96
+ }
97
+ lock.unlock()
98
+
99
+ throw Failure.timedOut
100
+ }
101
+
102
+ switch result {
103
+ case .success(let value):
104
+ return value
105
+ case .failure(let error):
106
+ throw error
107
+ case .none:
108
+ throw Failure.timedOut
109
+ }
110
+ }
111
+ }