serve-sim 0.1.50-beta.145.1 → 0.1.50-beta.149.1

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 CHANGED
@@ -209,6 +209,8 @@ serve-sim camera --stop-webcam
209
209
 
210
210
  Multiple booted simulators are supported — pass several device names, or leave it empty to attach to all of them.
211
211
 
212
+ Helpers started by `--detach` or `--no-preview` exit after their target simulators remain shut down beyond the grace period. A failed check keeps the helper alive.
213
+
212
214
  ### Remote access (`--auth-token`)
213
215
 
214
216
  Requests that don't come straight from this machine — LAN clients, tunnels, reverse proxies — are refused unless the server is started with `--auth-token <token>` (or `SERVE_SIM_AUTH_TOKEN`). CLI and scripted clients send `Authorization: Bearer <token>`. To share the preview with a browser, send the link with the token in the URL fragment:
@@ -21,6 +21,12 @@ private func hidLog(_ message: @autoclosure () -> String) {
21
21
  fflush(stdout)
22
22
  }
23
23
 
24
+ /// Carries the `SimDevice` to `SpringBoardLauncher`'s queue. The object is
25
+ /// never mutated from Swift; the launch only sends it one message.
26
+ private struct UncheckedSimDevice: @unchecked Sendable {
27
+ let device: NSObject
28
+ }
29
+
24
30
  /// Injects touch, button, and orientation HID events into the iOS Simulator.
25
31
  ///
26
32
  /// Uses IndigoHIDMessageForMouseNSEvent to create touch messages and
@@ -41,6 +47,7 @@ actor HIDInjector {
41
47
  private var hidClient: NSObject?
42
48
  private var sendSel: Selector?
43
49
  private var simDevice: NSObject?
50
+ private let springBoardLauncher = SpringBoardLauncher()
44
51
  private var deviceHubKeyboardBridge: DeviceHubKeyboardBridge?
45
52
 
46
53
  // IndigoHIDMessageForMouseNSEvent(CGPoint*, CGPoint*, IndigoHIDTarget, NSEventType, NSSize, IndigoHIDEdge)
@@ -473,7 +480,7 @@ actor HIDInjector {
473
480
  // and is functionally identical to a single home press, so use it
474
481
  // unconditionally rather than depending on whether the HID symbol
475
482
  // resolved.
476
- launchSpringBoard(deviceUDID: deviceUDID)
483
+ await launchSpringBoard(deviceUDID: deviceUDID)
477
484
 
478
485
  case "swipe_home":
479
486
  sendSwipeHome()
@@ -618,11 +625,37 @@ actor HIDInjector {
618
625
  rawSendTouch(type: "end", x: xPos, y: yEnd, edge: edge)
619
626
  }
620
627
 
621
- private func launchSpringBoard(deviceUDID: String) {
622
- let process = Process()
623
- process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun")
624
- process.arguments = DeviceSet.simctlArguments(["launch", deviceUDID, "com.apple.springboard"])
625
- try? process.run()
628
+ /// Launches SpringBoard off the actor, falling back to `simctl`, and returns
629
+ /// once the attempt finishes.
630
+ private func launchSpringBoard(deviceUDID: String) async {
631
+ let device = simDevice.map(UncheckedSimDevice.init)
632
+ await springBoardLauncher.launch(
633
+ direct: { Self.launchSpringBoardDirectly(device: device?.device) },
634
+ fallback: {
635
+ // Older CoreSimulator versions may not expose the direct launch API.
636
+ let process = Process()
637
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun")
638
+ process.arguments = DeviceSet.simctlArguments(["launch", deviceUDID, "com.apple.springboard"])
639
+ // Already on the launcher's queue; waiting keeps repeat presses
640
+ // coalesced instead of starting overlapping simctl processes.
641
+ SpringBoardLauncher.runToExit(process, timeout: 30)
642
+ }
643
+ )
644
+ }
645
+
646
+ /// Blocks until CoreSimulator answers, so it must stay off the actor's executor.
647
+ private nonisolated static func launchSpringBoardDirectly(device: NSObject?) -> SpringBoardLauncher.DirectResult {
648
+ let sel = NSSelectorFromString("launchApplicationWithID:options:pid:error:")
649
+ guard let device, device.responds(to: sel) else { return .unavailable }
650
+ typealias Fn = @convention(c) (
651
+ AnyObject, Selector, NSString, NSDictionary,
652
+ UnsafeMutablePointer<pid_t>, AutoreleasingUnsafeMutablePointer<NSError?>
653
+ ) -> ObjCBool
654
+ let fn = unsafeBitCast(device.method(for: sel), to: Fn.self)
655
+ var pid: pid_t = 0
656
+ var error: NSError?
657
+ if fn(device, sel, "com.apple.springboard", [:], &pid, &error).boolValue { return .launched }
658
+ return .failed(error?.localizedDescription ?? "unknown error")
626
659
  }
627
660
 
628
661
  // MARK: - Orientation (GSEvent via PurpleWorkspacePort)
@@ -0,0 +1,102 @@
1
+ import Foundation
2
+
3
+ /// Runs the Home button's SpringBoard launch on its own serial queue so a slow
4
+ /// launch never occupies the HID actor's executor, where it would queue later
5
+ /// touches, buttons, and display operations behind it.
6
+ ///
7
+ /// The direct CoreSimulator call is tried first; the fallback runs only if that
8
+ /// API is unavailable or the launch fails. Presses that arrive while a launch
9
+ /// is in flight join it rather than queueing another one, and resume with it.
10
+ public final class SpringBoardLauncher: @unchecked Sendable {
11
+ public enum DirectResult: Sendable {
12
+ case launched
13
+ /// The direct launch API doesn't exist on this CoreSimulator version.
14
+ case unavailable
15
+ case failed(String)
16
+ }
17
+
18
+ private let queue = DispatchQueue(
19
+ label: "serve-sim.springboard-launch",
20
+ qos: .userInteractive,
21
+ autoreleaseFrequency: .workItem
22
+ )
23
+ private let log: @Sendable (String) -> Void
24
+ private let lock = NSLock()
25
+ private var waiters: [CheckedContinuation<Void, Never>] = []
26
+
27
+ public init(log: @escaping @Sendable (String) -> Void = SpringBoardLauncher.logToStderr) {
28
+ self.log = log
29
+ }
30
+
31
+ /// Presses currently waiting on the in-flight launch, including its initiator.
32
+ var waiterCount: Int {
33
+ lock.lock()
34
+ defer { lock.unlock() }
35
+ return waiters.count
36
+ }
37
+
38
+ /// Runs `direct`, then `fallback` if it didn't launch, on the launcher's
39
+ /// queue. Returns when the attempt this press started or joined finishes.
40
+ public func launch(
41
+ direct: @escaping @Sendable () -> DirectResult,
42
+ fallback: @escaping @Sendable () -> Void
43
+ ) async {
44
+ await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
45
+ lock.lock()
46
+ waiters.append(continuation)
47
+ let isFirst = waiters.count == 1
48
+ lock.unlock()
49
+ guard isFirst else { return }
50
+
51
+ queue.async { [self] in
52
+ switch direct() {
53
+ case .launched:
54
+ break
55
+ case .unavailable:
56
+ fallback()
57
+ case .failed(let message):
58
+ log("[hid] Direct SpringBoard launch failed: \(message)")
59
+ fallback()
60
+ }
61
+
62
+ lock.lock()
63
+ let finished = waiters
64
+ waiters = []
65
+ lock.unlock()
66
+ finished.forEach { $0.resume() }
67
+ }
68
+ }
69
+ }
70
+
71
+ /// Starts `process` and blocks until it exits, terminating it after `timeout`
72
+ /// so a hung child can't hold up later launches forever. Call only from a
73
+ /// thread that may block, such as a `fallback`. Returns whether the process
74
+ /// started and exited within `timeout`.
75
+ @discardableResult
76
+ public static func runToExit(
77
+ _ process: Process,
78
+ timeout: TimeInterval,
79
+ log: @Sendable (String) -> Void = SpringBoardLauncher.logToStderr
80
+ ) -> Bool {
81
+ let exited = DispatchSemaphore(value: 0)
82
+ process.terminationHandler = { _ in exited.signal() }
83
+ guard (try? process.run()) != nil else { return false }
84
+ if exited.wait(timeout: .now() + timeout) == .success { return true }
85
+
86
+ log("[hid] \(process.executableURL?.path ?? "process") timed out after \(timeout)s; terminating")
87
+ process.terminate()
88
+ // Give SIGTERM a short grace period, then SIGKILL (which can't be ignored)
89
+ // so a timed-out child is gone before the next launch can start.
90
+ if exited.wait(timeout: .now() + 1) == .timedOut {
91
+ kill(process.processIdentifier, SIGKILL)
92
+ _ = exited.wait(timeout: .now() + 5)
93
+ }
94
+ return false
95
+ }
96
+
97
+ /// Writes one line to stderr; the default `log` destination.
98
+ @Sendable
99
+ public static func logToStderr(_ message: String) {
100
+ FileHandle.standardError.write(Data((message + "\n").utf8))
101
+ }
102
+ }
@@ -0,0 +1,294 @@
1
+ import Foundation
2
+ import XCTest
3
+ @testable import SimNativeSupport
4
+
5
+ /// Thread-safe call log; the launcher runs its closures on its own queue.
6
+ private final class Recorder: @unchecked Sendable {
7
+ private let lock = NSLock()
8
+ private var entries: [String] = []
9
+
10
+ func record(_ entry: String) {
11
+ lock.lock()
12
+ entries.append(entry)
13
+ lock.unlock()
14
+ }
15
+
16
+ var all: [String] {
17
+ lock.lock()
18
+ defer { lock.unlock() }
19
+ return entries
20
+ }
21
+ }
22
+
23
+ /// Stands in for `HIDInjector`: if the launcher blocked its caller, `ping()`
24
+ /// could not run on this actor until the slow launch finished.
25
+ private actor FakeInjector {
26
+ let launcher: SpringBoardLauncher
27
+
28
+ init(launcher: SpringBoardLauncher) {
29
+ self.launcher = launcher
30
+ }
31
+
32
+ func pressHome(
33
+ direct: @escaping @Sendable () -> SpringBoardLauncher.DirectResult,
34
+ fallback: @escaping @Sendable () -> Void
35
+ ) async {
36
+ await launcher.launch(direct: direct, fallback: fallback)
37
+ }
38
+
39
+ func ping() -> Bool { true }
40
+ }
41
+
42
+ final class SpringBoardLauncherTests: XCTestCase {
43
+ func testDirectSuccessSkipsFallback() async {
44
+ let recorder = Recorder()
45
+ let launcher = SpringBoardLauncher()
46
+
47
+ await launcher.launch(
48
+ direct: { recorder.record("direct"); return .launched },
49
+ fallback: { recorder.record("fallback") }
50
+ )
51
+
52
+ XCTAssertEqual(recorder.all, ["direct"])
53
+ }
54
+
55
+ func testMissingSelectorFallsBack() async {
56
+ let recorder = Recorder()
57
+ let launcher = SpringBoardLauncher()
58
+
59
+ await launcher.launch(
60
+ direct: { recorder.record("direct"); return .unavailable },
61
+ fallback: { recorder.record("fallback") }
62
+ )
63
+
64
+ XCTAssertEqual(recorder.all, ["direct", "fallback"])
65
+ }
66
+
67
+ func testDirectFailureFallsBackAndLogs() async {
68
+ let recorder = Recorder()
69
+ let launcher = SpringBoardLauncher(log: { recorder.record("log: \($0)") })
70
+
71
+ await launcher.launch(
72
+ direct: { recorder.record("direct"); return .failed("boom") },
73
+ fallback: { recorder.record("fallback") }
74
+ )
75
+
76
+ XCTAssertEqual(recorder.all.first, "direct")
77
+ XCTAssertEqual(recorder.all.last, "fallback")
78
+ XCTAssertEqual(recorder.all.count, 3)
79
+ XCTAssertTrue(recorder.all[1].contains("boom"), "unexpected log: \(recorder.all[1])")
80
+ }
81
+
82
+ func testActorStaysResponsiveDuringSlowDirectLaunch() async {
83
+ let started = DispatchSemaphore(value: 0)
84
+ let release = DispatchSemaphore(value: 0)
85
+ let injector = FakeInjector(launcher: SpringBoardLauncher())
86
+
87
+ let press = Task {
88
+ await injector.pressHome(
89
+ direct: {
90
+ started.signal()
91
+ release.wait()
92
+ return .launched
93
+ },
94
+ fallback: {}
95
+ )
96
+ }
97
+
98
+ // Wait off the cooperative pool so a blocked launcher can't starve it.
99
+ let didStart = await Self.wait(for: started, timeout: 5)
100
+ XCTAssertTrue(didStart, "direct launch never started")
101
+
102
+ // Safety release so a launcher that blocks the actor fails this test
103
+ // rather than deadlocking it.
104
+ let recorder = Recorder()
105
+ DispatchQueue.global().asyncAfter(deadline: .now() + 2) {
106
+ recorder.record("released")
107
+ release.signal()
108
+ }
109
+
110
+ // The direct launch is still blocked here; the actor must serve this.
111
+ let responded = await injector.ping()
112
+ recorder.record("pinged")
113
+ XCTAssertTrue(responded)
114
+ XCTAssertEqual(recorder.all.first, "pinged", "actor was blocked by the direct launch")
115
+
116
+ release.signal()
117
+ await press.value
118
+ }
119
+
120
+ func testLaunchRunsOnDedicatedQueueNotTheCooperativePool() async {
121
+ let recorder = Recorder()
122
+ let launcher = SpringBoardLauncher()
123
+
124
+ await launcher.launch(
125
+ direct: {
126
+ recorder.record(String(cString: __dispatch_queue_get_label(nil)))
127
+ return .unavailable
128
+ },
129
+ fallback: { recorder.record(String(cString: __dispatch_queue_get_label(nil))) }
130
+ )
131
+
132
+ XCTAssertEqual(recorder.all, Array(repeating: "serve-sim.springboard-launch", count: 2))
133
+ }
134
+
135
+ func testConcurrentPressesCoalesceWhileLaunchIsInFlight() async {
136
+ let recorder = Recorder()
137
+ let started = DispatchSemaphore(value: 0)
138
+ let release = DispatchSemaphore(value: 0)
139
+ let launcher = SpringBoardLauncher()
140
+
141
+ let first = Task {
142
+ await launcher.launch(
143
+ direct: {
144
+ recorder.record("direct")
145
+ started.signal()
146
+ release.wait()
147
+ return .launched
148
+ },
149
+ fallback: { recorder.record("fallback") }
150
+ )
151
+ }
152
+ let didStart = await Self.wait(for: started, timeout: 5)
153
+ XCTAssertTrue(didStart, "direct launch never started")
154
+
155
+ let extras = (0..<8).map { _ in
156
+ Task {
157
+ await launcher.launch(
158
+ direct: { recorder.record("extra-direct"); return .launched },
159
+ fallback: { recorder.record("extra-fallback") }
160
+ )
161
+ }
162
+ }
163
+
164
+ // Release only once every extra has joined; release even on failure so
165
+ // the launcher's queue can't stay blocked past the end of the test.
166
+ let didJoin = await Self.waitForWaiters(on: launcher, count: 9, timeout: 5)
167
+ release.signal()
168
+ XCTAssertTrue(didJoin, "extra presses never joined the in-flight launch")
169
+
170
+ await first.value
171
+ for extra in extras { await extra.value }
172
+ XCTAssertEqual(recorder.all, ["direct"])
173
+ }
174
+
175
+ func testLaunchesAgainAfterPreviousCompletes() async {
176
+ let recorder = Recorder()
177
+ let launcher = SpringBoardLauncher()
178
+
179
+ for _ in 0..<3 {
180
+ await launcher.launch(
181
+ direct: { recorder.record("direct"); return .launched },
182
+ fallback: { recorder.record("fallback") }
183
+ )
184
+ }
185
+
186
+ XCTAssertEqual(recorder.all, ["direct", "direct", "direct"])
187
+ }
188
+
189
+ func testRunToExitWaitsForProcessToFinish() throws {
190
+ let marker = FileManager.default.temporaryDirectory
191
+ .appendingPathComponent("springboard-launcher-\(UUID().uuidString)")
192
+ defer { try? FileManager.default.removeItem(at: marker) }
193
+ let process = Self.shell("sleep 0.2; touch '\(marker.path)'")
194
+
195
+ XCTAssertTrue(SpringBoardLauncher.runToExit(process, timeout: 10))
196
+
197
+ XCTAssertFalse(process.isRunning)
198
+ XCTAssertTrue(FileManager.default.fileExists(atPath: marker.path), "returned before the process finished")
199
+ }
200
+
201
+ func testRunToExitTerminatesHungProcessAtTimeout() {
202
+ let recorder = Recorder()
203
+ let process = Process()
204
+ process.executableURL = URL(fileURLWithPath: "/bin/sleep")
205
+ process.arguments = ["30"]
206
+
207
+ XCTAssertFalse(SpringBoardLauncher.runToExit(process, timeout: 0.1, log: { recorder.record($0) }))
208
+
209
+ XCTAssertFalse(process.isRunning, "hung process was left running")
210
+ XCTAssertEqual(recorder.all.count, 1, "unexpected log: \(recorder.all)")
211
+ }
212
+
213
+ func testRunToExitKillsProcessThatIgnoresSIGTERM() {
214
+ // `Process` resets signal dispositions and the signal mask in the child,
215
+ // so the shell can't ignore SIGTERM from its first instruction, and
216
+ // `runToExit` starts its timeout as soon as `run()` returns. The shell
217
+ // creates `trapped` only after installing its trap. If it died without
218
+ // `trapped`, the timeout won the startup race (no fault of runToExit),
219
+ // so retry. Once `trapped` exists, the SIGKILL assertions must hold.
220
+ let trapped = FileManager.default.temporaryDirectory
221
+ .appendingPathComponent("springboard-launcher-trap-\(UUID().uuidString)")
222
+ defer { try? FileManager.default.removeItem(at: trapped) }
223
+
224
+ for _ in 0..<5 {
225
+ let recorder = Recorder()
226
+ // The loop is bounded (~30s) so the shell can't outlive a crashed test run.
227
+ let process = Self.shell(
228
+ "trap '' TERM; : > '\(trapped.path)'; i=0; while [ $i -lt 600 ]; do sleep 0.05; i=$((i+1)); done"
229
+ )
230
+ // Only reached with a live child if the escalation regresses.
231
+ defer { if process.isRunning { kill(process.processIdentifier, SIGKILL) } }
232
+
233
+ XCTAssertFalse(SpringBoardLauncher.runToExit(process, timeout: 1, log: { recorder.record($0) }))
234
+
235
+ if !process.isRunning, !FileManager.default.fileExists(atPath: trapped.path) {
236
+ continue
237
+ }
238
+ return Self.assertKilledDespiteSIGTERMTrap(process, log: recorder.all)
239
+ }
240
+ XCTFail("shell never installed its SIGTERM trap before the timeout")
241
+ }
242
+
243
+ private static func assertKilledDespiteSIGTERMTrap(_ process: Process, log: [String]) {
244
+ // `terminationStatus` raises if read while the process is still running.
245
+ guard !process.isRunning else {
246
+ return XCTFail("process that ignored SIGTERM was left running")
247
+ }
248
+ // SIGTERM would report 15, so this proves the trap held and SIGKILL landed.
249
+ XCTAssertEqual(process.terminationReason, .uncaughtSignal)
250
+ XCTAssertEqual(process.terminationStatus, SIGKILL)
251
+ // Capture errno immediately; XCTest calls in between could overwrite it.
252
+ let probe = kill(process.processIdentifier, 0)
253
+ let probeErrno = errno
254
+ XCTAssertEqual(probe, -1, "process still exists")
255
+ XCTAssertEqual(probeErrno, ESRCH)
256
+ XCTAssertEqual(log.count, 1, "unexpected log: \(log)")
257
+ }
258
+
259
+ func testRunToExitReturnsWhenProcessCannotStart() {
260
+ let process = Process()
261
+ process.executableURL = URL(fileURLWithPath: "/nonexistent/serve-sim-test-binary")
262
+
263
+ XCTAssertFalse(SpringBoardLauncher.runToExit(process, timeout: 10))
264
+ }
265
+
266
+ private static func shell(_ command: String) -> Process {
267
+ let process = Process()
268
+ process.executableURL = URL(fileURLWithPath: "/bin/sh")
269
+ process.arguments = ["-c", command]
270
+ return process
271
+ }
272
+
273
+ /// Polls until `count` presses are waiting on the in-flight launch.
274
+ private static func waitForWaiters(
275
+ on launcher: SpringBoardLauncher,
276
+ count: Int,
277
+ timeout: TimeInterval
278
+ ) async -> Bool {
279
+ let deadline = Date().addingTimeInterval(timeout)
280
+ while launcher.waiterCount < count {
281
+ guard Date() < deadline else { return false }
282
+ try? await Task.sleep(for: .milliseconds(1))
283
+ }
284
+ return true
285
+ }
286
+
287
+ private static func wait(for semaphore: DispatchSemaphore, timeout: TimeInterval) async -> Bool {
288
+ await withCheckedContinuation { continuation in
289
+ DispatchQueue.global().async {
290
+ continuation.resume(returning: semaphore.wait(timeout: .now() + timeout) == .success)
291
+ }
292
+ }
293
+ }
294
+ }