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 +2 -0
- package/Sources/SimNative/HIDInjector.swift +39 -6
- package/Sources/SimNativeSupport/SpringBoardLauncher.swift +102 -0
- package/Tests/SimNativeSupportTests/SpringBoardLauncherTests.swift +294 -0
- package/dist/middleware.js +41 -41
- package/dist/native/serve-sim-native.node +0 -0
- package/dist/serve-sim.js +79 -79
- package/package.json +1 -1
- package/src/ax.ts +19 -3
- package/src/exec-ws.ts +24 -3
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
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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
|
+
}
|