Skip to content

Commit d546f45

Browse files
committed
Warm-up build and (hopefully) better updates handling
1 parent d91283f commit d546f45

20 files changed

Lines changed: 959 additions & 230 deletions
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import Foundation
2+
import Logging
3+
4+
protocol RefreshTrigger: Sendable {
5+
func requestRefresh(reason: String) async
6+
}
7+
8+
actor BackgroundRefreshCoordinator: RefreshTrigger {
9+
typealias RefreshAction = @Sendable (_ reason: String) async -> Void
10+
11+
private let logger: Logger
12+
private let refreshAction: RefreshAction
13+
14+
private var isRefreshing: Bool
15+
16+
init(logger: Logger, refreshAction: @escaping RefreshAction) {
17+
self.logger = logger
18+
self.refreshAction = refreshAction
19+
isRefreshing = false
20+
}
21+
22+
func requestRefresh(reason: String) async {
23+
guard isRefreshing == false else {
24+
logger.trace("background refresh request coalesced; already in flight (reason=\(reason))")
25+
return
26+
}
27+
28+
isRefreshing = true
29+
logger.trace("background refresh scheduled (reason=\(reason))")
30+
31+
Task { [refreshAction, logger] in
32+
logger.trace("background refresh started (reason=\(reason))")
33+
await refreshAction(reason)
34+
logger.trace("background refresh finished (reason=\(reason))")
35+
self.markRefreshFinished()
36+
}
37+
}
38+
39+
private func markRefreshFinished() {
40+
isRefreshing = false
41+
}
42+
}

Sources/XcodeBSP/BuildGraphService.swift

Lines changed: 106 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ actor BuildGraphService {
3434

3535
private let projectName: String
3636
private var snapshotCache: BuildGraphSnapshot?
37+
private var warmupTasksByScheme: [String: Task<Void, Error>]
3738

3839
init(
3940
xcodebuild: any XcodeBuildClient,
@@ -44,6 +45,7 @@ actor BuildGraphService {
4445
self.logger = logger
4546
self.configProvider = configProvider
4647
projectName = URL(filePath: FileManager.default.currentDirectoryPath).lastPathComponent
48+
warmupTasksByScheme = [:]
4749
}
4850

4951
func snapshot(decoder: JSONDecoder) async throws -> BuildGraphSnapshot {
@@ -57,8 +59,34 @@ actor BuildGraphService {
5759
}
5860

5961
func refresh(decoder: JSONDecoder, checkCache: Bool) async throws -> BuildGraphRefreshResult {
62+
let refreshStart = Date()
63+
var checkpointStart = refreshStart
64+
var refreshSummary = "failed"
65+
66+
func durationMs(since start: Date) -> Int {
67+
Int(Date().timeIntervalSince(start) * 1_000)
68+
}
69+
70+
func logCheckpoint(_ name: String) {
71+
let now = Date()
72+
let stepMs = Int(now.timeIntervalSince(checkpointStart) * 1_000)
73+
let totalMs = Int(now.timeIntervalSince(refreshStart) * 1_000)
74+
logger.debug(
75+
"build graph refresh checkpoint \(name) (stepMs: \(stepMs), totalMs: \(totalMs))"
76+
)
77+
checkpointStart = now
78+
}
79+
80+
logger.debug("build graph refresh started (checkCache: \(checkCache))")
81+
defer {
82+
logger.debug(
83+
"build graph refresh finished (status: \(refreshSummary), totalMs: \(durationMs(since: refreshStart)))"
84+
)
85+
}
86+
6087
let config = try configProvider.load(decoder: decoder)
6188
let schemes = try resolveSchemes(config: config, checkCache: checkCache)
89+
logCheckpoint("config+schemes (schemes: \(schemes.count))")
6290

6391
var targets: [BuildGraphTarget] = []
6492
var filesByTargetURI: [String: [String]] = [:]
@@ -125,6 +153,7 @@ actor BuildGraphService {
125153
}
126154
}
127155
}
156+
logCheckpoint("build targets+options (targets: \(targets.count), filesByTarget: \(filesByTargetURI.count))")
128157

129158
let targetsByFilePath = targetsByFile.mapValues { Array($0).sorted() }
130159

@@ -136,12 +165,14 @@ actor BuildGraphService {
136165
}
137166
}
138167
}
168+
logCheckpoint("flatten options by file (files: \(optionsByFilePath.count))")
139169

140170
let indexStorePath = preferredIndexStorePath(
141171
candidates: indexStoreCandidates,
142172
schemes: schemes,
143173
checkCache: checkCache
144174
)
175+
logCheckpoint("resolve index store path")
145176

146177
let snapshot = BuildGraphSnapshot(
147178
targets: targets.sorted(by: { $0.uri < $1.uri }),
@@ -157,6 +188,7 @@ actor BuildGraphService {
157188

158189
let changedTargetURIs = changedTargetURIs(previous: previous, current: snapshot)
159190
let changedOptionsByFilePath = changedOptionsByFilePath(previous: previous, current: snapshot)
191+
logCheckpoint("compute snapshot diff (changedTargets: \(changedTargetURIs.count), changedFiles: \(changedOptionsByFilePath.count))")
160192

161193
logger.trace(
162194
"""
@@ -166,6 +198,13 @@ actor BuildGraphService {
166198
"""
167199
)
168200

201+
refreshSummary =
202+
"""
203+
success, checkCache: \(checkCache), schemes: \(schemes.count), targets: \(snapshot.targets.count), \
204+
filesByTarget: \(snapshot.filesByTargetURI.count), optionsByFile: \(snapshot.optionsByFilePath.count), \
205+
changedTargets: \(changedTargetURIs.count), changedFiles: \(changedOptionsByFilePath.count)
206+
"""
207+
169208
return BuildGraphRefreshResult(
170209
snapshot: snapshot,
171210
changedTargetURIs: changedTargetURIs,
@@ -176,6 +215,27 @@ actor BuildGraphService {
176215
func invalidate() {
177216
snapshotCache = nil
178217
}
218+
219+
func warmupBuild(forScheme scheme: String) async throws {
220+
if let task = warmupTasksByScheme[scheme] {
221+
try await task.value
222+
return
223+
}
224+
225+
let task = Task<Void, Error> { [xcodebuild] in
226+
try xcodebuild.warmupBuild(forScheme: scheme)
227+
}
228+
warmupTasksByScheme[scheme] = task
229+
230+
do {
231+
try await task.value
232+
} catch {
233+
warmupTasksByScheme[scheme] = nil
234+
throw error
235+
}
236+
237+
warmupTasksByScheme[scheme] = nil
238+
}
179239
}
180240

181241
extension BuildGraphService {
@@ -198,22 +258,8 @@ extension BuildGraphService {
198258
}
199259

200260
private func settingsForIndex(forScheme scheme: String, checkCache: Bool) throws -> XcodeBuild.SettingsForIndex {
201-
do {
202-
logger.trace("invoking xcodebuild.settingsForIndex(forScheme: \(scheme), checkCache: \(checkCache))")
203-
let settings = try xcodebuild.settingsForIndex(forScheme: scheme, checkCache: checkCache)
204-
if settings.isEmpty, checkCache {
205-
logger.trace("invoking xcodebuild.settingsForIndex(forScheme: \(scheme), checkCache: false) because cached settings are empty")
206-
return try xcodebuild.settingsForIndex(forScheme: scheme, checkCache: false)
207-
}
208-
return settings
209-
} catch {
210-
guard checkCache else {
211-
throw error
212-
}
213-
214-
logger.trace("invoking xcodebuild.settingsForIndex(forScheme: \(scheme), checkCache: false) after failure with cached result")
215-
return try xcodebuild.settingsForIndex(forScheme: scheme, checkCache: false)
216-
}
261+
logger.trace("invoking xcodebuild.settingsForIndex(forScheme: \(scheme), checkCache: \(checkCache))")
262+
return try xcodebuild.settingsForIndex(forScheme: scheme, checkCache: checkCache)
217263
}
218264

219265
private func makeTargetURI(scheme: String, target: String?) -> String {
@@ -283,9 +329,22 @@ extension BuildGraphService {
283329
}
284330

285331
private func sanitizedCompilerArguments(from settings: XcodeBuild.FileSettings) -> [String] {
286-
var arguments = settings.swiftASTCommandArguments ?? []
287-
arguments.append(contentsOf: settings.clangASTCommandArguments ?? [])
288-
arguments.append(contentsOf: settings.clangPCHCommandArguments ?? [])
332+
var arguments = stripCompilerExecutable(
333+
from: settings.swiftASTCommandArguments ?? [],
334+
expectedCompilers: ["swiftc", "swift-frontend"]
335+
)
336+
arguments.append(
337+
contentsOf: stripCompilerExecutable(
338+
from: settings.clangASTCommandArguments ?? [],
339+
expectedCompilers: ["clang", "clang++", "cc", "c++"]
340+
)
341+
)
342+
arguments.append(
343+
contentsOf: stripCompilerExecutable(
344+
from: settings.clangPCHCommandArguments ?? [],
345+
expectedCompilers: ["clang", "clang++", "cc", "c++"]
346+
)
347+
)
289348

290349
arguments = arguments.filter { $0 != "-use-frontend-parseable-output" }
291350
for (index, argument) in arguments.enumerated().reversed() {
@@ -300,6 +359,33 @@ extension BuildGraphService {
300359
return removeMissingSDK(arguments: arguments)
301360
}
302361

362+
private func stripCompilerExecutable(from arguments: [String], expectedCompilers: [String]) -> [String] {
363+
let commands = Set(expectedCompilers.map { $0.lowercased() })
364+
guard arguments.isEmpty == false else {
365+
return arguments
366+
}
367+
368+
if arguments.count > 1, isCommand(arguments[0], oneOf: ["xcrun"]), isCommand(arguments[1], oneOf: commands) {
369+
return Array(arguments.dropFirst(2))
370+
}
371+
372+
if isCommand(arguments[0], oneOf: commands) {
373+
return Array(arguments.dropFirst())
374+
}
375+
376+
return arguments
377+
}
378+
379+
private func isCommand(_ argument: String, oneOf commands: Set<String>) -> Bool {
380+
let lowercased = argument.lowercased()
381+
if commands.contains(lowercased) {
382+
return true
383+
}
384+
385+
let basename = URL(filePath: argument).lastPathComponent.lowercased()
386+
return commands.contains(basename)
387+
}
388+
303389
private func removeMissingSDK(arguments: [String]) -> [String] {
304390
var sanitized = arguments
305391
for (index, argument) in arguments.enumerated().reversed() {
@@ -317,6 +403,7 @@ extension BuildGraphService {
317403
return sanitized
318404
}
319405

406+
320407
private func workingDirectory(arguments: [String]) -> String? {
321408
for (index, argument) in arguments.enumerated() {
322409
if argument == "-working-directory", index + 1 < arguments.count {

Sources/XcodeBSP/BuildServerProtocol/Handlers/BuildTargetPrepare.swift

Lines changed: 90 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,43 @@ extension BuildTargetPrepare: MethodHandler {
3333
func handle(request: Request<Params>, decoder: JSONDecoder) async throws -> Result {
3434
await state.beginUpdate()
3535
do {
36-
let refresh = try await graph.refresh(decoder: decoder, checkCache: true)
37-
await state.recordRefreshChanges(refresh)
36+
let initialRefresh = try await graph.refresh(decoder: decoder, checkCache: true)
37+
await state.recordRefreshChanges(initialRefresh)
3838

39-
for target in request.params.targets {
39+
let preparedTargets = request.params.targets.compactMap { target -> (TargetID, PreparedTarget)? in
4040
guard let parsedTarget = parseTarget(fromURI: target.uri) else {
4141
logger.error("failed to extract scheme from target uri: \(target.uri)")
42-
continue
42+
return nil
43+
}
44+
return (target, parsedTarget)
45+
}
46+
47+
let warmupSchemes = schemesRequiringWarmup(
48+
preparedTargets: preparedTargets,
49+
snapshot: initialRefresh.snapshot
50+
)
51+
52+
var snapshot = initialRefresh.snapshot
53+
if warmupSchemes.isEmpty == false {
54+
for scheme in warmupSchemes {
55+
do {
56+
logger.debug("buildTarget/prepare warmup build started for scheme \(scheme)")
57+
try await graph.warmupBuild(forScheme: scheme)
58+
logger.debug("buildTarget/prepare warmup build completed for scheme \(scheme)")
59+
} catch {
60+
logger.error("buildTarget/prepare warmup build failed for scheme \(scheme): \(error)")
61+
}
4362
}
4463

64+
let refreshed = try await graph.refresh(decoder: decoder, checkCache: true)
65+
await state.recordRefreshChanges(refreshed)
66+
snapshot = refreshed.snapshot
67+
}
68+
69+
for (target, parsedTarget) in preparedTargets {
70+
4571
let lookupURI = canonicalTargetURI(scheme: parsedTarget.scheme, target: parsedTarget.target)
46-
let optionsByFile = refresh.snapshot.optionsByTargetURI[target.uri] ?? refresh.snapshot.optionsByTargetURI[lookupURI] ?? [:]
72+
let optionsByFile = snapshot.optionsByTargetURI[target.uri] ?? snapshot.optionsByTargetURI[lookupURI] ?? [:]
4773
if optionsByFile.isEmpty {
4874
logger.debug("buildTarget/prepare produced no compiler arguments for \(target.uri)")
4975
continue
@@ -111,6 +137,65 @@ extension BuildTargetPrepare {
111137
let fallbackTarget = target.map { "&target=\($0)" } ?? ""
112138
return components.string ?? "xcode://\(projectName)?scheme=\(scheme)\(fallbackTarget)"
113139
}
140+
141+
private func schemesRequiringWarmup(
142+
preparedTargets: [(TargetID, PreparedTarget)],
143+
snapshot: BuildGraphSnapshot
144+
) -> [String] {
145+
var schemes: Set<String> = []
146+
147+
for (target, parsedTarget) in preparedTargets {
148+
let lookupURI = canonicalTargetURI(scheme: parsedTarget.scheme, target: parsedTarget.target)
149+
let optionsByFile = snapshot.optionsByTargetURI[target.uri] ?? snapshot.optionsByTargetURI[lookupURI] ?? [:]
150+
guard optionsByFile.isEmpty == false else {
151+
continue
152+
}
153+
154+
/*
155+
if optionsByFile.values.contains(where: { hasMissingCriticalPaths(arguments: $0.options) }) {
156+
*/
157+
schemes.insert(parsedTarget.scheme)
158+
/*
159+
}
160+
*/
161+
}
162+
163+
return schemes.sorted()
164+
}
165+
166+
private func hasMissingCriticalPaths(arguments: [String]) -> Bool {
167+
var index = 0
168+
while index < arguments.count {
169+
let argument = arguments[index]
170+
171+
if argument == "-fmodule-map-file", index + 1 < arguments.count {
172+
if FileManager.default.fileExists(atPath: arguments[index + 1]) == false {
173+
return true
174+
}
175+
} else if argument.hasPrefix("-fmodule-map-file=") {
176+
let path = String(argument.dropFirst("-fmodule-map-file=".count))
177+
if FileManager.default.fileExists(atPath: path) == false {
178+
return true
179+
}
180+
} else if argument == "-Xcc", index + 1 < arguments.count {
181+
let wrapped = arguments[index + 1]
182+
if wrapped == "-fmodule-map-file", index + 3 < arguments.count, arguments[index + 2] == "-Xcc" {
183+
if FileManager.default.fileExists(atPath: arguments[index + 3]) == false {
184+
return true
185+
}
186+
} else if wrapped.hasPrefix("-fmodule-map-file=") {
187+
let path = String(wrapped.dropFirst("-fmodule-map-file=".count))
188+
if FileManager.default.fileExists(atPath: path) == false {
189+
return true
190+
}
191+
}
192+
}
193+
194+
index += 1
195+
}
196+
197+
return false
198+
}
114199
}
115200

116201
extension BuildTargetPrepare {

0 commit comments

Comments
 (0)