Skip to content

Commit b93230f

Browse files
lawrencelomaxmeta-codesync[bot]
authored andcommitted
Fail fast when a REPL target is not a launchable app
Summary: `idb-repl app --bundle-id com.apple.springboard` now fails immediately, naming the app and explaining why, instead of hanging for the client's full two-minute deadline and then reporting only a hashed socket path. SpringBoard is a launchd job, not a launchable app. Launching one still reports success, because CoreSimulator resolves it to the process already running -- so the injected environment is dropped, `libRepl` is never loaded, and nothing ever binds the control socket the client is waiting for. The wait cannot succeed, and the message it eventually produced named neither the app nor the cause. The guard runs after the launch and asks whether the bundle id now has a `UIKitApplication:` service in the simulator's launchd. A real app registers one; a launchd-managed target never does. This is the same lookup `confirmApplicationLaunchState` already trusts to decide whether an app is running, so it introduces no new signal -- it just stops discarding the one case the two cannot distinguish, where "no service" means "not an app" rather than "not running". Deliberately not keyed on install type. Apple's system apps are ordinary launchable apps as far as injection is concerned -- Safari, Photos, Settings, Messages, Maps, Calendar, Contacts, Reminders, Files and Health all host a REPL with full access to their own UIKit state -- so refusing a target for being Apple-signed would be wrong. A comment at the call site says so, because the install-type predicate already exists nearby for uninstall and is the tempting thing to reach for. Reviewed By: williamtwilson Differential Revision: D119189291 fbshipit-source-id: 805ad2d2efb356f96ce98daecf95b98e54330d72
1 parent f7807a4 commit b93230f

2 files changed

Lines changed: 92 additions & 1 deletion

File tree

FBSimulatorControl/Commands/SimulatorReplCommands.swift

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import Foundation
1212
public enum FBSimulatorReplError: Error {
1313
case bundledResourceMissing(item: String)
1414
case socketDirectoryCreationFailed(path: String)
15+
case targetIsNotALaunchableApp(bundleID: String, processIdentifier: pid_t)
1516
}
1617

1718
extension FBSimulatorReplError: LocalizedError {
@@ -21,6 +22,12 @@ extension FBSimulatorReplError: LocalizedError {
2122
return "\(item) not found in the companion Resources directory"
2223
case let .socketDirectoryCreationFailed(path):
2324
return "Could not create a private REPL socket directory at \(path)"
25+
case let .targetIsNotALaunchableApp(bundleID, processIdentifier):
26+
return """
27+
\(bundleID) is managed by launchd (pid \(processIdentifier)), not launched as an app, \
28+
so launching it returns the process already running and the REPL dylib is never injected. \
29+
Target an app bundle instead.
30+
"""
2431
}
2532
}
2633
}
@@ -168,7 +175,24 @@ public final class FBSimulatorReplCommands {
168175
io: io,
169176
launchMode: .relaunchIfRunning
170177
)
171-
_ = try await simulator.launchApplication(configuration)
178+
let launched = try await simulator.launchApplication(configuration)
179+
180+
// A launched app registers a `UIKitApplication:` service with the simulator's
181+
// launchd; a launchd-managed target such as SpringBoard never does. CoreSimulator
182+
// still reports success for one -- it resolves to the process already running, so
183+
// the injected environment is dropped and nothing ever binds the control socket.
184+
// Without this the client spends its entire deadline waiting for a socket that
185+
// cannot appear, then reports only a hashed path.
186+
//
187+
// This is the same lookup `confirmApplicationLaunchState` already trusts to decide
188+
// whether an app is running, so it introduces no new signal. It is deliberately not
189+
// `installType == .system`: Apple's system apps host a REPL perfectly well, and
190+
// refusing them would be wrong.
191+
guard (try? await simulator.processID(forBundleID: bundleID)) != nil else {
192+
throw FBSimulatorReplError.targetIsNotALaunchableApp(
193+
bundleID: bundleID,
194+
processIdentifier: launched.processIdentifier)
195+
}
172196

173197
// The app outlives the REPL session -- it keeps running and resets for the
174198
// next client on disconnect -- so `run` is already resolved: teardown must not
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
@testable import FBSimulatorControl
9+
import Foundation
10+
import Testing
11+
12+
/// Coverage of the REPL error descriptions. These strings are the only thing a
13+
/// user sees when a session fails to start, so each is pinned to keep it naming
14+
/// the thing that went wrong rather than an internal detail.
15+
@Suite
16+
struct FBSimulatorReplErrorTests {
17+
18+
@Test
19+
func bundledResourceMissingNamesTheItem() throws {
20+
let message = try #require(
21+
FBSimulatorReplError.bundledResourceMissing(item: "libRepl-iOS.dylib").errorDescription)
22+
#expect(message.contains("libRepl-iOS.dylib"))
23+
}
24+
25+
@Test
26+
func socketDirectoryCreationFailedNamesThePath() throws {
27+
let message = try #require(
28+
FBSimulatorReplError.socketDirectoryCreationFailed(path: "/tmp/idb_repl_501").errorDescription)
29+
#expect(message.contains("/tmp/idb_repl_501"))
30+
}
31+
32+
// MARK: - targetIsNotALaunchableApp
33+
34+
@Test
35+
func notALaunchableAppNamesTheBundleIDAndProcess() throws {
36+
let message = try #require(
37+
FBSimulatorReplError
38+
.targetIsNotALaunchableApp(bundleID: "com.apple.springboard", processIdentifier: 53_820)
39+
.errorDescription)
40+
#expect(message.contains("com.apple.springboard"))
41+
#expect(message.contains("53820"))
42+
}
43+
44+
@Test
45+
func notALaunchableAppExplainsTheCauseAndTheRemedy() throws {
46+
// The whole point of this error is that the previous behaviour -- waiting out
47+
// the client's deadline and then reporting a hashed socket path -- told the
48+
// reader neither what had failed nor what to do instead.
49+
let message = try #require(
50+
FBSimulatorReplError
51+
.targetIsNotALaunchableApp(bundleID: "com.apple.springboard", processIdentifier: 1)
52+
.errorDescription)
53+
#expect(message.contains("launchd"))
54+
#expect(message.contains("never injected"))
55+
#expect(message.contains("Target an app bundle instead."))
56+
}
57+
58+
@Test
59+
func notALaunchableAppIsASingleLine() throws {
60+
// It is rendered into a gRPC status message, where embedded newlines are unreadable.
61+
let message = try #require(
62+
FBSimulatorReplError
63+
.targetIsNotALaunchableApp(bundleID: "com.apple.springboard", processIdentifier: 1)
64+
.errorDescription)
65+
#expect(!message.contains("\n"))
66+
}
67+
}

0 commit comments

Comments
 (0)