Skip to content

Commit 799d89b

Browse files
committed
Add a timeout for workspace/buildTargets and buildTarget/sources requests
This allows us to provide functionality based on fallback settings for unresponsive BSP servers. Fixes #2252
1 parent 1aae2a4 commit 799d89b

File tree

11 files changed

+250
-60
lines changed

11 files changed

+250
-60
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: 102 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -465,8 +465,7 @@ package actor BuildServerManager: QueueBasedMessageHandler {
465465
self.filesDependenciesUpdatedDebouncer = Debouncer(
466466
debounceDuration: .milliseconds(500),
467467
combineResults: { $0.union($1) },
468-
makeCall: {
469-
[weak self] (filesWithUpdatedDependencies) in
468+
makeCall: { [weak self] (filesWithUpdatedDependencies) in
470469
guard let self, let delegate = await self.delegate else {
471470
logger.fault("Not calling filesDependenciesUpdated because no delegate exists in SwiftPMBuildServer")
472471
return
@@ -485,8 +484,7 @@ package actor BuildServerManager: QueueBasedMessageHandler {
485484
self.filesBuildSettingsChangedDebouncer = Debouncer(
486485
debounceDuration: .milliseconds(20),
487486
combineResults: { $0.union($1) },
488-
makeCall: {
489-
[weak self] (filesWithChangedBuildSettings) in
487+
makeCall: { [weak self] (filesWithChangedBuildSettings) in
490488
guard let self, let delegate = await self.delegate else {
491489
logger.fault("Not calling fileBuildSettingsChanged because no delegate exists in SwiftPMBuildServer")
492490
return
@@ -653,30 +651,76 @@ package actor BuildServerManager: QueueBasedMessageHandler {
653651
}
654652

655653
private func didChangeBuildTarget(notification: OnBuildTargetDidChangeNotification) async {
656-
let updatedTargets: Set<BuildTargetIdentifier>? =
654+
let changedTargets: Set<BuildTargetIdentifier>? =
657655
if let changes = notification.changes {
658656
Set(changes.map(\.target))
659657
} else {
660658
nil
661659
}
662-
self.cachedAdjustedSourceKitOptions.clear(isolation: self) { cacheKey in
663-
guard let updatedTargets else {
664-
// All targets might have changed
665-
return true
660+
await self.buildTargetsDidChange(.didChangeBuildTargets(changedTargets: changedTargets))
661+
}
662+
663+
private enum BuildTargetsChange {
664+
case didChangeBuildTargets(changedTargets: Set<BuildTargetIdentifier>?)
665+
case buildTargetsReceivedResultAfterTimeout(
666+
request: WorkspaceBuildTargetsRequest,
667+
newResult: [BuildTargetIdentifier: BuildTargetInfo]
668+
)
669+
case sourceFilesReceivedResultAfterTimeout(
670+
request: BuildTargetSourcesRequest,
671+
newResult: BuildTargetSourcesResponse
672+
)
673+
}
674+
675+
/// Update the cached state in `BuildServerManager` because new data was received from the BSP server.
676+
///
677+
/// This handles a few seemingly unrelated reasons to ensure that we think about which caches to invalidate in the
678+
/// other scenarios as well, when making changes in here.
679+
private func buildTargetsDidChange(_ stateChange: BuildTargetsChange) async {
680+
let changedTargets: Set<BuildTargetIdentifier>?
681+
682+
switch stateChange {
683+
case .didChangeBuildTargets(let changedTargetsValue):
684+
changedTargets = changedTargetsValue
685+
self.cachedAdjustedSourceKitOptions.clear(isolation: self) { cacheKey in
686+
guard let changedTargets else {
687+
// All targets might have changed
688+
return true
689+
}
690+
return changedTargets.contains(cacheKey.target)
666691
}
667-
return updatedTargets.contains(cacheKey.target)
668-
}
669-
self.cachedBuildTargets.clearAll(isolation: self)
670-
self.cachedTargetSources.clear(isolation: self) { cacheKey in
671-
guard let updatedTargets else {
672-
// All targets might have changed
673-
return true
692+
self.cachedBuildTargets.clearAll(isolation: self)
693+
self.cachedTargetSources.clear(isolation: self) { cacheKey in
694+
guard let changedTargets else {
695+
// All targets might have changed
696+
return true
697+
}
698+
return !changedTargets.intersection(cacheKey.targets).isEmpty
674699
}
675-
return !updatedTargets.intersection(cacheKey.targets).isEmpty
676-
}
677-
self.cachedSourceFilesAndDirectories.clearAll(isolation: self)
678-
679-
await delegate?.buildTargetsChanged(notification.changes)
700+
self.cachedSourceFilesAndDirectories.clearAll(isolation: self)
701+
702+
case .buildTargetsReceivedResultAfterTimeout(let request, let newResult):
703+
changedTargets = nil
704+
705+
// Caches not invalidated:
706+
// - cachedAdjustedSourceKitOptions: We would not have requested SourceKit options for targets that we didn't
707+
// know about. Even if we did, the build server now telling us about the target should not change the options of
708+
// the file within the target
709+
// - cachedTargetSources: Similar to cachedAdjustedSourceKitOptions, we would not have requested sources for
710+
// targets that we didn't know about and if we did, they wouldn't be affected
711+
cachedSourceFilesAndDirectories.clearAll(isolation: self)
712+
self.cachedBuildTargets.set(request, to: newResult)
713+
case .sourceFilesReceivedResultAfterTimeout(let request, let newResult):
714+
changedTargets = Set(request.targets)
715+
716+
// Caches not invalidated:
717+
// - cachedAdjustedSourceKitOptions: Same as for buildTargetsReceivedResultAfterTimeout.
718+
// - cachedBuildTargets: Getting a result for the source files in a target doesn't change anything about the
719+
// target's existence.
720+
self.cachedTargetSources.set(request, to: newResult)
721+
cachedSourceFilesAndDirectories.clearAll(isolation: self)
722+
}
723+
await delegate?.buildTargetsChanged(changedTargets)
680724
await filesBuildSettingsChangedDebouncer.scheduleCall(Set(watchedFiles.keys))
681725
}
682726

@@ -967,7 +1011,7 @@ package actor BuildServerManager: QueueBasedMessageHandler {
9671011
if fallbackAfterTimeout {
9681012
try await withTimeout(options.buildSettingsTimeoutOrDefault) {
9691013
return try await self.buildSettingsFromBuildServer(for: document, in: target, language: language)
970-
} resultReceivedAfterTimeout: {
1014+
} resultReceivedAfterTimeout: { _ in
9711015
await self.filesBuildSettingsChangedDebouncer.scheduleCall([document])
9721016
}
9731017
} else {
@@ -1025,7 +1069,7 @@ package actor BuildServerManager: QueueBasedMessageHandler {
10251069
} else {
10261070
try await withTimeout(options.buildSettingsTimeoutOrDefault) {
10271071
await self.canonicalTarget(for: mainFile)
1028-
} resultReceivedAfterTimeout: {
1072+
} resultReceivedAfterTimeout: { _ in
10291073
await self.filesBuildSettingsChangedDebouncer.scheduleCall([document])
10301074
}
10311075
}
@@ -1179,28 +1223,39 @@ package actor BuildServerManager: QueueBasedMessageHandler {
11791223

11801224
let request = WorkspaceBuildTargetsRequest()
11811225
let result = try await cachedBuildTargets.get(request, isolation: self) { request in
1182-
let buildTargets = try await buildServerAdapter.send(request).targets
1183-
let (depths, dependents) = await self.targetDepthsAndDependents(for: buildTargets)
1184-
var result: [BuildTargetIdentifier: BuildTargetInfo] = [:]
1185-
result.reserveCapacity(buildTargets.count)
1186-
for buildTarget in buildTargets {
1187-
guard result[buildTarget.id] == nil else {
1188-
logger.error("Found two targets with the same ID \(buildTarget.id)")
1189-
continue
1190-
}
1191-
let depth: Int
1192-
if let d = depths[buildTarget.id] {
1193-
depth = d
1194-
} else {
1195-
logger.fault("Did not compute depth for target \(buildTarget.id)")
1196-
depth = 0
1226+
let result = try await withTimeout(self.options.buildServerWorkspaceRequestsTimeoutOrDefault) {
1227+
let buildTargets = try await buildServerAdapter.send(request).targets
1228+
let (depths, dependents) = await self.targetDepthsAndDependents(for: buildTargets)
1229+
var result: [BuildTargetIdentifier: BuildTargetInfo] = [:]
1230+
result.reserveCapacity(buildTargets.count)
1231+
for buildTarget in buildTargets {
1232+
guard result[buildTarget.id] == nil else {
1233+
logger.error("Found two targets with the same ID \(buildTarget.id)")
1234+
continue
1235+
}
1236+
let depth: Int
1237+
if let d = depths[buildTarget.id] {
1238+
depth = d
1239+
} else {
1240+
logger.fault("Did not compute depth for target \(buildTarget.id)")
1241+
depth = 0
1242+
}
1243+
result[buildTarget.id] = BuildTargetInfo(
1244+
target: buildTarget,
1245+
depth: depth,
1246+
dependents: dependents[buildTarget.id] ?? []
1247+
)
11971248
}
1198-
result[buildTarget.id] = BuildTargetInfo(
1199-
target: buildTarget,
1200-
depth: depth,
1201-
dependents: dependents[buildTarget.id] ?? []
1249+
return result
1250+
} resultReceivedAfterTimeout: { newResult in
1251+
await self.buildTargetsDidChange(
1252+
.buildTargetsReceivedResultAfterTimeout(request: request, newResult: newResult)
12021253
)
12031254
}
1255+
guard let result else {
1256+
logger.error("Failed to get targets of workspace within timeout")
1257+
return [:]
1258+
}
12041259
return result
12051260
}
12061261
return result
@@ -1233,7 +1288,11 @@ package actor BuildServerManager: QueueBasedMessageHandler {
12331288
}
12341289

12351290
let response = try await cachedTargetSources.get(request, isolation: self) { request in
1236-
try await buildServerAdapter.send(request)
1291+
try await withTimeout(self.options.buildServerWorkspaceRequestsTimeoutOrDefault) {
1292+
return try await buildServerAdapter.send(request)
1293+
} resultReceivedAfterTimeout: { newResult in
1294+
await self.buildTargetsDidChange(.sourceFilesReceivedResultAfterTimeout(request: request, newResult: newResult))
1295+
} ?? BuildTargetSourcesResponse(items: [])
12371296
}
12381297
return response.items
12391298
}

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 {
@@ -531,7 +549,9 @@ public struct SourceKitLSPOptions: Sendable, Codable, Equatable {
531549
workDoneProgressDebounceDuration: override?.workDoneProgressDebounceDuration
532550
?? base.workDoneProgressDebounceDuration,
533551
sourcekitdRequestTimeout: override?.sourcekitdRequestTimeout ?? base.sourcekitdRequestTimeout,
534-
semanticServiceRestartTimeout: override?.semanticServiceRestartTimeout ?? base.semanticServiceRestartTimeout
552+
semanticServiceRestartTimeout: override?.semanticServiceRestartTimeout ?? base.semanticServiceRestartTimeout,
553+
buildServerWorkspaceRequestsTimeout: override?.buildServerWorkspaceRequestsTimeout
554+
?? base.buildServerWorkspaceRequestsTimeout
535555
)
536556
}
537557

Sources/SemanticIndex/SemanticIndexManager.swift

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -416,14 +416,7 @@ package final actor SemanticIndexManager {
416416
}
417417
}
418418

419-
package func buildTargetsChanged(_ changes: [BuildTargetEvent]?) async {
420-
let targets: Set<BuildTargetIdentifier>? =
421-
if let changes = changes?.map(\.target) {
422-
Set(changes)
423-
} else {
424-
nil
425-
}
426-
419+
package func buildTargetsChanged(_ targets: Set<BuildTargetIdentifier>?) async {
427420
if let targets {
428421
var targetsAndDependencies = targets
429422
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
@@ -468,9 +468,9 @@ package final class Workspace: Sendable, BuildServerManagerDelegate {
468468
}
469469
}
470470

471-
package func buildTargetsChanged(_ changes: [BuildTargetEvent]?) async {
471+
package func buildTargetsChanged(_ changedTargets: Set<BuildTargetIdentifier>?) async {
472472
await sourceKitLSPServer?.fileHandlingCapabilityChanged()
473-
await semanticIndexManager?.buildTargetsChanged(changes)
473+
await semanticIndexManager?.buildTargetsChanged(changedTargets)
474474
await orLog("Scheduling syntactic test re-indexing") {
475475
let testFiles = try await buildServerManager.testFiles()
476476
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)