Skip to content

Commit 4d166bd

Browse files
authored
Generate XOJIT preview thunks for source files reached through a symlink (#1465)
CONTEXT When SwiftUI Previews run in XOJIT mode, the previews client asks Swift Build — per source file — for the compile command that builds that file's "preview thunk", via generatePreviewInfo(.thunkInfo), identifying the file by path. In SwiftCompilerSpec.generatePreviewInfo, Swift Build locates that path among the target's compile inputs and hands it to LibSwiftDriver.frontendCommandLine as the primary input, which produces the swift-frontend invocation for the thunk. That lookup compares paths by exact string. If the source file was recorded in the build description under a different — but equivalent — spelling than the one in the request, nothing matches: frontendCommandLine returns no command line, generatePreviewInfo returns an empty result, and the client reports noPreviewInfos, so the preview never renders. This happens for standalone Swift packages whose sources live under a symlinked directory. On macOS, temporary directories such as /tmp are symlinks to /private/tmp, and SwiftPM's new package PIF builder symlink-resolves source paths (to match what the index store records). The compile command then holds /private/tmp/.../View.swift while the preview request still carries the unresolved /tmp/.../View.swift — the same file, a different spelling, no match. FIX In the XOJIT branch of SwiftCompilerSpec.generatePreviewInfo, match the requested source file against the command's inputs by resolved path (ie, FSProxy.realpath), and use the spelling that actually appears in the command as the primary input. The output-file-map and the VFS overlay are keyed on that same spelling so they line up with what the driver resolves. When the requested and recorded paths already agree — e.g. the legacy package PIF builder, which doesn't resolve symlinks — this is a no-op. TESTING Adds previewXOJITThunkInfoResolvesSymlinkedSourcePath to PreviewsBuildOperationTests. Rather than standing up a real package, it reproduces the spelling mismatch with a single symlink: it builds a one-file app under a real directory and then requests the preview thunk through a symlink to that directory, so the requested path and the build's input path resolve to the same file but differ as strings. It asserts that exactly one preview info comes back and that the returned compile command references the real (build) spelling, never the symlinked request path. Without the fix the test returns zero preview infos (ie, the noPreviewInfos condition); with it one, as expected. Fixes rdar://176386125 (and related to rdar://179887248).
1 parent b6e1c1c commit 4d166bd

3 files changed

Lines changed: 144 additions & 4 deletions

File tree

Sources/SWBCore/SpecImplementations/Tools/SwiftCompiler.swift

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3226,21 +3226,37 @@ public final class SwiftCompilerSpec : CompilerSpec, SpecIdentifierType, SwiftDi
32263226
if payload.previewStyle == .xojit {
32273227
// Also pass the auxiliary Swift files.
32283228
commandLine.append(contentsOf: originalInputs.map(\.str))
3229-
selectedInputPath = sourceFile
3229+
3230+
// Fixes rdar://176386125. The package PIF builder may symlink-resolve source paths in
3231+
// the build description (eg, /tmp -> /private/tmp) so they match what the index service stores.
3232+
// The previews client, however, asks us to thunk the *unresolved* path, so a plain `sourceFile` won't
3233+
// match the command's inputs and libSwiftDriver returns no command line -> `noPreviewInfos`.
3234+
//
3235+
// Use the spelling that actually appears in the command's inputs, matched by realpath,
3236+
// and key the output-file-map / VFS overlay on it. No-op when the paths already agree
3237+
// (eg, the legacy package PIF builder available in Xcode).
3238+
//
3239+
// See the related `previewXOJITThunkInfoResolvesSymlinkedSourcePath` test.
3240+
func resolvedPath(_ path: Path) -> Path { (try? fs.realpath(path)) ?? path }
3241+
let realSourceFile = resolvedPath(sourceFile)
3242+
selectedInputPath = originalInputs.only { resolvedPath($0) == realSourceFile } ?? sourceFile
32303243

32313244
if let driverPayload = payload.driverPayload {
32323245
do {
32333246
// Inject the thunk source into the output file map
32343247
let pchPath = driverPayload.tempDirPath.join(driverPayload.outputPrefix + "-primary-Bridging-header.pch")
3235-
let map = SwiftOutputFileMap(files: [sourceFile.str: .init(object: outputPath.str), "": .init(pch: pchPath.str)])
3248+
let map = SwiftOutputFileMap(files: [
3249+
selectedInputPath.str: SwiftOutputFileMap.Entry(object: outputPath.str),
3250+
"": SwiftOutputFileMap.Entry(pch: pchPath.str)
3251+
])
32363252
let newOutputFileMap = driverPayload.tempDirPath.join(UUID().uuidString)
32373253
try fs.createDirectory(newOutputFileMap.dirname, recursive: true)
32383254
try fs.write(newOutputFileMap, contents: ByteString(JSONEncoder(outputFormatting: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]).encode(map)))
32393255
commandLine.append(contentsOf: ["-output-file-map", newOutputFileMap.str])
32403256

32413257
// rdar://127735418 ([JIT] Emit a vfsoverlay for JIT preview thunk compiler arguments so clients can specify the original file path when substituting contents)
32423258
let vfs = VFS()
3243-
vfs.addMapping(sourceFile, externalContents: inputPath)
3259+
vfs.addMapping(selectedInputPath, externalContents: inputPath)
32443260
newVFSOverlayPath = driverPayload.tempDirPath.join("vfsoverlay-\(inputPath.basename).json")
32453261
try fs.createDirectory(newOutputFileMap.dirname, recursive: true)
32463262
let overlay = try vfs.toVFSOverlay().propertyListItem.asJSONFragment().asString

Sources/SWBUtil/Collection.swift

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,28 @@ public extension Collection {
1717
}
1818

1919
/// Return the only element in the collection, if it has exactly one element.
20-
/// - complexity: O(1).
20+
///
21+
/// **Complexity**. O(1).
2122
var only: Iterator.Element? {
2223
return !isEmpty && index(after: startIndex) == endIndex ? self.first : nil
2324
}
2425

26+
/// Returns the single element of the sequence satisfying `predicate`,
27+
/// or `nil`if no element — or more than one element — satisfies it.
28+
///
29+
/// Unlike `first(where:)`, multiple matches yield `nil`;
30+
/// iteration stops as soon as a second match is found.
31+
///
32+
/// **Complexity**. O(n), where n is the length of the sequence.
33+
func only(where predicate: (Element) throws -> Bool) rethrows -> Element? {
34+
var onlyMatch: Element?
35+
for candidate in self where try predicate(candidate) {
36+
guard onlyMatch == nil else { return nil }
37+
onlyMatch = candidate
38+
}
39+
return onlyMatch
40+
}
41+
2542
/// Returns the elements of the sequence, sorted using the given key path as the comparison between elements.
2643
@inlinable func sorted<Value: Comparable>(by predicate: (Element) -> Value) -> [Element] {
2744
return sorted(<, by: predicate)

Tests/SWBBuildSystemTests/PreviewsBuildOperationTests.swift

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1122,6 +1122,113 @@ fileprivate struct PreviewsBuildOperationTests: CoreBasedTests {
11221122
}
11231123
}
11241124

1125+
/// Regression test for rdar://176386125: `generatePreviewInfo(.thunkInfo)` must match the
1126+
/// requested source file against the compile command's inputs by *resolved* path, not by unresolved path.
1127+
///
1128+
/// For standalone Swift packages, the OSS (ie, new) *package PIF builder* bakes the
1129+
/// symlink-resolved source path (eg, `/private/tmp/foo/main.swift`) into the compile
1130+
/// command so it matches what the *index service* stores, while the previews client requests a thunk
1131+
/// for the unresolved path (eg, `/tmp/foo/main.swift`). The two paths resolve to the same file but differ,
1132+
/// so the broken code — which compared them literally — found no matching input, libSwiftDriver
1133+
/// produced no command line, and the request came back empty, which clients report as `noPreviewInfos`.
1134+
///
1135+
/// We reproduce that path mismatch without a real package by introducing a single symlink `srcRootSymlink`.
1136+
/// Before the fix this returns zero preview infos; after the fix it returns one, and the resulting
1137+
/// compile command line refers to the build's spelling (`srcRoot`), never the request's symlink.
1138+
@Test(.requireSDKs(.iOS))
1139+
func previewXOJITThunkInfoResolvesSymlinkedSourcePath() async throws {
1140+
try await withTemporaryDirectory { (tmpDirPath: Path) in
1141+
// The real source directory. The build references `main.swift` through it, so
1142+
// this is the spelling that ends up baked into the compile command's inputs.
1143+
let srcRoot = tmpDirPath.join("srcroot")
1144+
1145+
// A symlink that points at `srcRoot`.
1146+
// The preview request references `main.swift` through it.
1147+
let srcRootSymlink = tmpDirPath.join("srcroot-symlink")
1148+
1149+
let sourceFile = TestFile("main.swift")
1150+
let testProject = TestProject(
1151+
"ProjectName",
1152+
sourceRoot: srcRoot,
1153+
groupTree: TestGroup(
1154+
"Sources", path: "Sources",
1155+
children: [sourceFile]
1156+
),
1157+
buildConfigurations: [
1158+
TestBuildConfiguration("Debug", buildSettings: [
1159+
"GENERATE_INFOPLIST_FILE": "YES",
1160+
"PRODUCT_NAME": "$(TARGET_NAME)",
1161+
"SDKROOT": "iphoneos",
1162+
"SWIFT_VERSION": "5.0",
1163+
"SWIFT_OPTIMIZATION_LEVEL": "-Onone"
1164+
])
1165+
],
1166+
targets: [
1167+
TestStandardTarget(
1168+
"AppTarget",
1169+
type: .application,
1170+
buildPhases: [
1171+
TestSourcesBuildPhase([TestBuildFile(sourceFile.name)])
1172+
]
1173+
)
1174+
]
1175+
)
1176+
1177+
let core = try await getCore()
1178+
let tester = try await BuildOperationTester(core, testProject, simulated: false)
1179+
1180+
try tester.fs.createDirectory(srcRoot.join("Sources"), recursive: true)
1181+
try tester.fs.write(srcRoot.join("Sources").join(sourceFile.name), contents: "")
1182+
1183+
// The build references the source through `srcRoot`; the preview request references it
1184+
// through `srcRootSymlink`, a symlink to the same directory. Same file, different spelling.
1185+
try tester.fs.symlink(srcRootSymlink, target: srcRoot)
1186+
let buildSourceFile = srcRoot.join("Sources").join(sourceFile.name)
1187+
let requestedSourceFile = srcRootSymlink.join("Sources").join(sourceFile.name)
1188+
1189+
// Sanity check the setup: the two spellings differ as strings but resolve to the same file.
1190+
try #require(requestedSourceFile != buildSourceFile)
1191+
try #require(try tester.fs.realpath(requestedSourceFile) == tester.fs.realpath(buildSourceFile))
1192+
1193+
let buildParameters = BuildParameters(
1194+
configuration: "Debug",
1195+
overrides: ["ENABLE_XOJIT_PREVIEWS": "YES"]
1196+
)
1197+
1198+
try await tester.checkBuild(
1199+
parameters: buildParameters,
1200+
runDestination: .iOSSimulator,
1201+
buildCommand: .build(style: .buildOnly, skipDependencies: false)
1202+
) { results in
1203+
results.checkNoErrors()
1204+
1205+
let buildDescription = results.buildDescription
1206+
let appTarget = try #require(buildDescription.allConfiguredTargets.first { $0.target.name == "AppTarget" })
1207+
1208+
let previewInfoInput = TaskGeneratePreviewInfoInput.thunkInfo(
1209+
sourceFile: requestedSourceFile,
1210+
thunkVariantSuffix: "selection"
1211+
)
1212+
let previewInfos = buildDescription.generatePreviewInfoForTesting(
1213+
for: [appTarget],
1214+
workspaceContext: tester.workspaceContext,
1215+
buildRequestContext: results.buildRequestContext,
1216+
input: previewInfoInput
1217+
)
1218+
1219+
// The crux: before the fix this was empty (-> `noPreviewInfos`), because the requested
1220+
// symlinked path didn't match the resolved path baked into the compile command.
1221+
#expect(previewInfos.count == 1)
1222+
let compileCommandLine: [String] = try #require(previewInfos.only?.thunkInfo?.compileCommandLine)
1223+
1224+
// And the command we hand back references the spelling that actually appears in the
1225+
// build (`srcRoot`), not the symlinked path the client happened to ask with.
1226+
#expect(compileCommandLine.contains(buildSourceFile.str))
1227+
#expect(!compileCommandLine.contains(requestedSourceFile.str))
1228+
}
1229+
}
1230+
}
1231+
11251232
private enum LinkStyle {
11261233
case dylib
11271234
case bundleLoader

0 commit comments

Comments
 (0)