Skip to content

Commit fa67c22

Browse files
authored
Merge pull request #2277 from ahoppen/bsp-timeouts
Add a timeout for `workspace/buildTargets` and `buildTarget/sources` requests
2 parents a793438 + c142b60 commit fa67c22

File tree

11 files changed

+248
-58
lines changed

11 files changed

+248
-58
lines changed

Documentation/Configuration File.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,4 @@ The structure of the file is currently not guaranteed to be stable. Options may
5959
- `workDoneProgressDebounceDuration: number`: When a task is started that should be displayed to the client as a work done progress, how many milliseconds to wait before actually starting the work done progress. This prevents flickering of the work done progress in the client for short-lived index tasks which end within this duration.
6060
- `sourcekitdRequestTimeout: number`: The maximum duration that a sourcekitd request should be allowed to execute before being declared as timed out. In general, editors should cancel requests that they are no longer interested in, but in case editors don't cancel requests, this ensures that a long-running non-cancelled request is not blocking sourcekitd and thus most semantic functionality. In particular, VS Code does not cancel the semantic tokens request, which can cause a long-running AST build that blocks sourcekitd.
6161
- `semanticServiceRestartTimeout: number`: If a request to sourcekitd or clangd exceeds this timeout, we assume that the semantic service provider is hanging for some reason and won't recover. To restore semantic functionality, we terminate and restart it.
62+
- `buildServerWorkspaceRequestsTimeout: number`: Duration how long to wait for responses to `workspace/buildTargets` or `buildTarget/sources` request by the build server before defaulting to an empty response.

Sources/BuildServerIntegration/BuildServerManager.swift

Lines changed: 100 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -482,8 +482,7 @@ package actor BuildServerManager: QueueBasedMessageHandler {
482482
self.filesDependenciesUpdatedDebouncer = Debouncer(
483483
debounceDuration: .milliseconds(500),
484484
combineResults: { $0.union($1) },
485-
makeCall: {
486-
[weak self] (filesWithUpdatedDependencies) in
485+
makeCall: { [weak self] (filesWithUpdatedDependencies) in
487486
guard let self, let delegate = await self.delegate else {
488487
logger.fault("Not calling filesDependenciesUpdated because no delegate exists in SwiftPMBuildServer")
489488
return
@@ -502,8 +501,7 @@ package actor BuildServerManager: QueueBasedMessageHandler {
502501
self.filesBuildSettingsChangedDebouncer = Debouncer(
503502
debounceDuration: .milliseconds(20),
504503
combineResults: { $0.union($1) },
505-
makeCall: {
506-
[weak self] (filesWithChangedBuildSettings) in
504+
makeCall: { [weak self] (filesWithChangedBuildSettings) in
507505
guard let self, let delegate = await self.delegate else {
508506
logger.fault("Not calling fileBuildSettingsChanged because no delegate exists in SwiftPMBuildServer")
509507
return
@@ -670,31 +668,76 @@ package actor BuildServerManager: QueueBasedMessageHandler {
670668
}
671669

672670
private func didChangeBuildTarget(notification: OnBuildTargetDidChangeNotification) async {
673-
let updatedTargets: Set<BuildTargetIdentifier>? =
671+
let changedTargets: Set<BuildTargetIdentifier>? =
674672
if let changes = notification.changes {
675673
Set(changes.map(\.target))
676674
} else {
677675
nil
678676
}
679-
self.cachedAdjustedSourceKitOptions.clear(isolation: self) { cacheKey in
680-
guard let updatedTargets else {
681-
// All targets might have changed
682-
return true
677+
await self.buildTargetsDidChange(.didChangeBuildTargets(changedTargets: changedTargets))
678+
}
679+
680+
private enum BuildTargetsChange {
681+
case didChangeBuildTargets(changedTargets: Set<BuildTargetIdentifier>?)
682+
case buildTargetsReceivedResultAfterTimeout(
683+
request: WorkspaceBuildTargetsRequest,
684+
newResult: [BuildTargetIdentifier: BuildTargetInfo]
685+
)
686+
case sourceFilesReceivedResultAfterTimeout(
687+
request: BuildTargetSourcesRequest,
688+
newResult: BuildTargetSourcesResponse
689+
)
690+
}
691+
692+
/// Update the cached state in `BuildServerManager` because new data was received from the BSP server.
693+
///
694+
/// This handles a few seemingly unrelated reasons to ensure that we think about which caches to invalidate in the
695+
/// other scenarios as well, when making changes in here.
696+
private func buildTargetsDidChange(_ stateChange: BuildTargetsChange) async {
697+
let changedTargets: Set<BuildTargetIdentifier>?
698+
699+
switch stateChange {
700+
case .didChangeBuildTargets(let changedTargetsValue):
701+
changedTargets = changedTargetsValue
702+
self.cachedAdjustedSourceKitOptions.clear(isolation: self) { cacheKey in
703+
guard let changedTargets else {
704+
// All targets might have changed
705+
return true
706+
}
707+
return changedTargets.contains(cacheKey.target)
683708
}
684-
return updatedTargets.contains(cacheKey.target)
685-
}
686-
self.cachedBuildTargets.clearAll(isolation: self)
687-
self.cachedTargetSources.clear(isolation: self) { cacheKey in
688-
guard let updatedTargets else {
689-
// All targets might have changed
690-
return true
709+
self.cachedBuildTargets.clearAll(isolation: self)
710+
self.cachedTargetSources.clear(isolation: self) { cacheKey in
711+
guard let changedTargets else {
712+
// All targets might have changed
713+
return true
714+
}
715+
return !changedTargets.intersection(cacheKey.targets).isEmpty
691716
}
692-
return !updatedTargets.intersection(cacheKey.targets).isEmpty
693-
}
717+
case .buildTargetsReceivedResultAfterTimeout(let request, let newResult):
718+
changedTargets = nil
719+
720+
// Caches not invalidated:
721+
// - cachedAdjustedSourceKitOptions: We would not have requested SourceKit options for targets that we didn't
722+
// know about. Even if we did, the build server now telling us about the target should not change the options of
723+
// the file within the target
724+
// - cachedTargetSources: Similar to cachedAdjustedSourceKitOptions, we would not have requested sources for
725+
// targets that we didn't know about and if we did, they wouldn't be affected
726+
self.cachedBuildTargets.set(request, to: newResult)
727+
case .sourceFilesReceivedResultAfterTimeout(let request, let newResult):
728+
changedTargets = Set(request.targets)
729+
730+
// Caches not invalidated:
731+
// - cachedAdjustedSourceKitOptions: Same as for buildTargetsReceivedResultAfterTimeout.
732+
// - cachedBuildTargets: Getting a result for the source files in a target doesn't change anything about the
733+
// target's existence.
734+
self.cachedTargetSources.set(request, to: newResult)
735+
}
736+
// Clear caches that capture global state and are affected by all changes
694737
self.cachedSourceFilesAndDirectories.clearAll(isolation: self)
695738
self.scheduleRecomputeCopyFileMap()
696739

697-
await delegate?.buildTargetsChanged(notification.changes)
740+
await delegate?.buildTargetsChanged(changedTargets)
698741
await filesBuildSettingsChangedDebouncer.scheduleCall(Set(watchedFiles.keys))
699742
}
700743

@@ -898,6 +941,7 @@ package actor BuildServerManager: QueueBasedMessageHandler {
898941
previousUpdateTask?.cancel()
899942
await orLog("Re-computing copy file map") {
900943
let sourceFilesAndDirectories = try await self.sourceFilesAndDirectories()
944+
try Task.checkCancellation()
901945
var copiedFileMap: [DocumentURI: DocumentURI] = [:]
902946
for (file, fileInfo) in sourceFilesAndDirectories.files {
903947
for copyDestination in fileInfo.copyDestinations {
@@ -1024,7 +1068,7 @@ package actor BuildServerManager: QueueBasedMessageHandler {
10241068
if fallbackAfterTimeout {
10251069
try await withTimeout(options.buildSettingsTimeoutOrDefault) {
10261070
return try await self.buildSettingsFromBuildServer(for: document, in: target, language: language)
1027-
} resultReceivedAfterTimeout: {
1071+
} resultReceivedAfterTimeout: { _ in
10281072
await self.filesBuildSettingsChangedDebouncer.scheduleCall([document])
10291073
}
10301074
} else {
@@ -1082,7 +1126,7 @@ package actor BuildServerManager: QueueBasedMessageHandler {
10821126
} else {
10831127
try await withTimeout(options.buildSettingsTimeoutOrDefault) {
10841128
await self.canonicalTarget(for: mainFile)
1085-
} resultReceivedAfterTimeout: {
1129+
} resultReceivedAfterTimeout: { _ in
10861130
await self.filesBuildSettingsChangedDebouncer.scheduleCall([document])
10871131
}
10881132
}
@@ -1236,28 +1280,39 @@ package actor BuildServerManager: QueueBasedMessageHandler {
12361280

12371281
let request = WorkspaceBuildTargetsRequest()
12381282
let result = try await cachedBuildTargets.get(request, isolation: self) { request in
1239-
let buildTargets = try await buildServerAdapter.send(request).targets
1240-
let (depths, dependents) = await self.targetDepthsAndDependents(for: buildTargets)
1241-
var result: [BuildTargetIdentifier: BuildTargetInfo] = [:]
1242-
result.reserveCapacity(buildTargets.count)
1243-
for buildTarget in buildTargets {
1244-
guard result[buildTarget.id] == nil else {
1245-
logger.error("Found two targets with the same ID \(buildTarget.id)")
1246-
continue
1247-
}
1248-
let depth: Int
1249-
if let d = depths[buildTarget.id] {
1250-
depth = d
1251-
} else {
1252-
logger.fault("Did not compute depth for target \(buildTarget.id)")
1253-
depth = 0
1283+
let result = try await withTimeout(self.options.buildServerWorkspaceRequestsTimeoutOrDefault) {
1284+
let buildTargets = try await buildServerAdapter.send(request).targets
1285+
let (depths, dependents) = await self.targetDepthsAndDependents(for: buildTargets)
1286+
var result: [BuildTargetIdentifier: BuildTargetInfo] = [:]
1287+
result.reserveCapacity(buildTargets.count)
1288+
for buildTarget in buildTargets {
1289+
guard result[buildTarget.id] == nil else {
1290+
logger.error("Found two targets with the same ID \(buildTarget.id)")
1291+
continue
1292+
}
1293+
let depth: Int
1294+
if let d = depths[buildTarget.id] {
1295+
depth = d
1296+
} else {
1297+
logger.fault("Did not compute depth for target \(buildTarget.id)")
1298+
depth = 0
1299+
}
1300+
result[buildTarget.id] = BuildTargetInfo(
1301+
target: buildTarget,
1302+
depth: depth,
1303+
dependents: dependents[buildTarget.id] ?? []
1304+
)
12541305
}
1255-
result[buildTarget.id] = BuildTargetInfo(
1256-
target: buildTarget,
1257-
depth: depth,
1258-
dependents: dependents[buildTarget.id] ?? []
1306+
return result
1307+
} resultReceivedAfterTimeout: { newResult in
1308+
await self.buildTargetsDidChange(
1309+
.buildTargetsReceivedResultAfterTimeout(request: request, newResult: newResult)
12591310
)
12601311
}
1312+
guard let result else {
1313+
logger.error("Failed to get targets of workspace within timeout")
1314+
return [:]
1315+
}
12611316
return result
12621317
}
12631318
return result
@@ -1290,7 +1345,11 @@ package actor BuildServerManager: QueueBasedMessageHandler {
12901345
}
12911346

12921347
let response = try await cachedTargetSources.get(request, isolation: self) { request in
1293-
try await buildServerAdapter.send(request)
1348+
try await withTimeout(self.options.buildServerWorkspaceRequestsTimeoutOrDefault) {
1349+
return try await buildServerAdapter.send(request)
1350+
} resultReceivedAfterTimeout: { newResult in
1351+
await self.buildTargetsDidChange(.sourceFilesReceivedResultAfterTimeout(request: request, newResult: newResult))
1352+
} ?? BuildTargetSourcesResponse(items: [])
12941353
}
12951354
return response.items
12961355
}

Sources/BuildServerIntegration/BuildServerManagerDelegate.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ package protocol BuildServerManagerDelegate: AnyObject, Sendable {
2929

3030
/// Notify the delegate that some information about the given build targets has changed and that it should recompute
3131
/// any information based on top of it.
32-
func buildTargetsChanged(_ changes: [BuildTargetEvent]?) async
32+
func buildTargetsChanged(_ changedTargets: Set<BuildTargetIdentifier>?) async
3333
}
3434

3535
/// Methods with which the `BuildServerManager` can send messages to the client (aka. editor).

Sources/SKOptions/SourceKitLSPOptions.swift

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,22 @@ public struct SourceKitLSPOptions: Sendable, Codable, Equatable {
433433
return .seconds(300)
434434
}
435435

436+
/// Duration how long to wait for responses to `workspace/buildTargets` or `buildTarget/sources` request by the build
437+
/// server before defaulting to an empty response.
438+
public var buildServerWorkspaceRequestsTimeout: Double? = nil
439+
440+
public var buildServerWorkspaceRequestsTimeoutOrDefault: Duration {
441+
if let buildServerWorkspaceRequestsTimeout {
442+
return .seconds(buildServerWorkspaceRequestsTimeout)
443+
}
444+
// The default value needs to strike a balance: If the build server is slow to respond, we don't want to constantly
445+
// run into this timeout, which causes somewhat expensive computations because we trigger the `buildTargetsChanged`
446+
// chain.
447+
// At the same time, we do want to provide functionality based on fallback settings after some time.
448+
// 15s seems like it should strike a balance here but there is no data backing this value up.
449+
return .seconds(15)
450+
}
451+
436452
public init(
437453
swiftPM: SwiftPMOptions? = .init(),
438454
fallbackBuildSystem: FallbackBuildSystemOptions? = .init(),
@@ -451,7 +467,8 @@ public struct SourceKitLSPOptions: Sendable, Codable, Equatable {
451467
swiftPublishDiagnosticsDebounceDuration: Double? = nil,
452468
workDoneProgressDebounceDuration: Double? = nil,
453469
sourcekitdRequestTimeout: Double? = nil,
454-
semanticServiceRestartTimeout: Double? = nil
470+
semanticServiceRestartTimeout: Double? = nil,
471+
buildServerWorkspaceRequestsTimeout: Double? = nil
455472
) {
456473
self.swiftPM = swiftPM
457474
self.fallbackBuildSystem = fallbackBuildSystem
@@ -471,6 +488,7 @@ public struct SourceKitLSPOptions: Sendable, Codable, Equatable {
471488
self.workDoneProgressDebounceDuration = workDoneProgressDebounceDuration
472489
self.sourcekitdRequestTimeout = sourcekitdRequestTimeout
473490
self.semanticServiceRestartTimeout = semanticServiceRestartTimeout
491+
self.buildServerWorkspaceRequestsTimeout = buildServerWorkspaceRequestsTimeout
474492
}
475493

476494
public init?(fromLSPAny lspAny: LSPAny?) throws {
@@ -535,7 +553,9 @@ public struct SourceKitLSPOptions: Sendable, Codable, Equatable {
535553
workDoneProgressDebounceDuration: override?.workDoneProgressDebounceDuration
536554
?? base.workDoneProgressDebounceDuration,
537555
sourcekitdRequestTimeout: override?.sourcekitdRequestTimeout ?? base.sourcekitdRequestTimeout,
538-
semanticServiceRestartTimeout: override?.semanticServiceRestartTimeout ?? base.semanticServiceRestartTimeout
556+
semanticServiceRestartTimeout: override?.semanticServiceRestartTimeout ?? base.semanticServiceRestartTimeout,
557+
buildServerWorkspaceRequestsTimeout: override?.buildServerWorkspaceRequestsTimeout
558+
?? base.buildServerWorkspaceRequestsTimeout
539559
)
540560
}
541561

Sources/SemanticIndex/SemanticIndexManager.swift

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -426,14 +426,7 @@ package final actor SemanticIndexManager {
426426
}
427427
}
428428

429-
package func buildTargetsChanged(_ changes: [BuildTargetEvent]?) async {
430-
let targets: Set<BuildTargetIdentifier>? =
431-
if let changes = changes?.map(\.target) {
432-
Set(changes)
433-
} else {
434-
nil
435-
}
436-
429+
package func buildTargetsChanged(_ targets: Set<BuildTargetIdentifier>?) async {
437430
if let targets {
438431
var targetsAndDependencies = targets
439432
targetsAndDependencies.formUnion(await buildServerManager.targets(dependingOn: targets))

Sources/SourceKitLSP/Workspace.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -452,9 +452,9 @@ package final class Workspace: Sendable, BuildServerManagerDelegate {
452452
}
453453
}
454454

455-
package func buildTargetsChanged(_ changes: [BuildTargetEvent]?) async {
455+
package func buildTargetsChanged(_ changedTargets: Set<BuildTargetIdentifier>?) async {
456456
await sourceKitLSPServer?.fileHandlingCapabilityChanged()
457-
await semanticIndexManager?.buildTargetsChanged(changes)
457+
await semanticIndexManager?.buildTargetsChanged(changedTargets)
458458
await orLog("Scheduling syntactic test re-indexing") {
459459
let testFiles = try await buildServerManager.testFiles()
460460
await syntacticTestIndex.listOfTestFilesDidChange(testFiles)

Sources/SwiftExtensions/AsyncUtils.swift

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -270,8 +270,8 @@ package func withTimeout<T: Sendable>(
270270
/// `body` should be cancelled after `timeout`.
271271
package func withTimeout<T: Sendable>(
272272
_ timeout: Duration,
273-
body: @escaping @Sendable () async throws -> T?,
274-
resultReceivedAfterTimeout: @escaping @Sendable () async -> Void
273+
body: @escaping @Sendable () async throws -> T,
274+
resultReceivedAfterTimeout: @escaping @Sendable (_ result: T) async -> Void
275275
) async throws -> T? {
276276
let didHitTimeout = AtomicBool(initialValue: false)
277277

@@ -286,7 +286,7 @@ package func withTimeout<T: Sendable>(
286286
do {
287287
let result = try await body()
288288
if didHitTimeout.value {
289-
await resultReceivedAfterTimeout()
289+
await resultReceivedAfterTimeout(result)
290290
}
291291
continuation.yield(result)
292292
} catch {
@@ -306,3 +306,17 @@ package func withTimeout<T: Sendable>(
306306
preconditionFailure("Continuation never finishes")
307307
}
308308
}
309+
310+
/// Same as `withTimeout` above but allows `body` to return an optional value.
311+
package func withTimeout<T: Sendable>(
312+
_ timeout: Duration,
313+
body: @escaping @Sendable () async throws -> T?,
314+
resultReceivedAfterTimeout: @escaping @Sendable (_ result: T?) async -> Void
315+
) async throws -> T? {
316+
let result: T?? = try await withTimeout(timeout, body: body, resultReceivedAfterTimeout: resultReceivedAfterTimeout)
317+
switch result {
318+
case .none: return nil
319+
case .some(.none): return nil
320+
case .some(.some(let value)): return value
321+
}
322+
}

Sources/SwiftExtensions/Cache.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,15 @@ package class Cache<Key: Sendable & Hashable, Result: Sendable> {
3333
return try await task.value
3434
}
3535

36+
/// Force the value for a specific key to a value.
37+
///
38+
/// This should only be used if a value for this key is received by means that aren't covered through the `compute`
39+
/// function in `get`. An example of this is receiving the results of a BSP request after a timeout, in which case we
40+
/// would have cached the timeout result through `get` but now we have an updated value.
41+
package func set(_ key: Key, to value: Result) {
42+
storage[key] = Task { value }
43+
}
44+
3645
/// Get the value cached for `key`. If no value exists for `key`, try deriving the result from an existing cache entry
3746
/// that satisfies `canReuseKey` by applying `transform` to that result.
3847
package func getDerived(

Tests/BuildServerIntegrationTests/BuildSystemManagerTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -428,7 +428,7 @@ private actor BSMDelegate: BuildServerManagerDelegate {
428428

429429
func filesDependenciesUpdated(_ changedFiles: Set<DocumentURI>) {}
430430

431-
func buildTargetsChanged(_ changes: [BuildTargetEvent]?) async {}
431+
func buildTargetsChanged(_ changedTargets: Set<BuildTargetIdentifier>?) async {}
432432

433433
var clientSupportsWorkDoneProgress: Bool { false }
434434

0 commit comments

Comments
 (0)