@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.
- package/README.md +1 -1
- package/bin/argent-android-devtools-0.1.0.apk +0 -0
- package/bin/darwin/ax-service +0 -0
- package/bin/darwin/tvos-ax-service +0 -0
- package/bin/darwin/tvos-hid-daemon +0 -0
- package/bin/tcp/ax-service +0 -0
- package/dist/cli-cmds.mjs +17 -2
- package/dist/installer.mjs +17 -2
- package/dist/ios-device-runner/ArgentRunner/ArgentRunner/RunnerHostApp.swift +37 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunner.xcodeproj/project.pbxproj +389 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunner.xcodeproj/xcshareddata/xcschemes/ArgentRunner.xcscheme +88 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentExceptionGuard.h +16 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentExceptionGuard.m +16 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentRunnerSession+Commands.swift +280 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentRunnerSession+Gestures.swift +149 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentRunnerSession+Screenshot.swift +21 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentRunnerSession+Snapshot.swift +327 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentRunnerSession+TextEntry.swift +157 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentRunnerSession.swift +410 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentRunnerUITests-Bridging-Header.h +1 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/CommandJournal.swift +134 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/MainThreadGate.swift +111 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/RunnerHTTPServer.swift +267 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/RunnerProtocol.swift +331 -0
- package/dist/mcp-server.mjs +16 -1
- package/dist/tool-server.cjs +4224 -1388
- package/dylibs/libArgentInjectionBootstrap.dylib +0 -0
- package/dylibs/libKeyboardPatch.dylib +0 -0
- package/dylibs/libNativeDevtoolsIos.dylib +0 -0
- package/dylibs/tcp/libArgentInjectionBootstrap.dylib +0 -0
- package/dylibs/tcp/libKeyboardPatch.dylib +0 -0
- package/dylibs/tcp/libNativeDevtoolsIos.dylib +0 -0
- package/dylibs/tvos/libArgentInjectionBootstrap.dylib +0 -0
- package/dylibs/tvos/libKeyboardPatch.dylib +0 -0
- package/dylibs/tvos/libNativeDevtoolsIos.dylib +0 -0
- package/package.json +1 -1
- package/rules/argent.md +8 -3
- package/skills/argent-create-flow/SKILL.md +1 -0
- package/skills/argent-device-interact/SKILL.md +2 -0
- package/skills/argent-ios-device-interact/SKILL.md +18 -0
- package/skills/argent-ios-device-setup/SKILL.md +20 -0
- package/skills/argent-qa-flows/SKILL.md +2 -0
- 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 @@
|
|
|
1
|
+
#import "ArgentExceptionGuard.h"
|
|
@@ -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
|
+
}
|