Skip to content

Commit c142b60

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 b885439 commit c142b60

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

@@ -896,6 +939,7 @@ package actor BuildServerManager: QueueBasedMessageHandler {
896939
previousUpdateTask?.cancel()
897940
await orLog("Re-computing copy file map") {
898941
let sourceFilesAndDirectories = try await self.sourceFilesAndDirectories()
942+
try Task.checkCancellation()
899943
var copiedFileMap: [DocumentURI: DocumentURI] = [:]
900944
for (file, fileInfo) in sourceFilesAndDirectories.files {
901945
for copyDestination in fileInfo.copyDestinations {
@@ -1022,7 +1066,7 @@ package actor BuildServerManager: QueueBasedMessageHandler {
10221066
if fallbackAfterTimeout {
10231067
try await withTimeout(options.buildSettingsTimeoutOrDefault) {
10241068
return try await self.buildSettingsFromBuildServer(for: document, in: target, language: language)
1025-
} resultReceivedAfterTimeout: {
1069+
} resultReceivedAfterTimeout: { _ in
10261070
await self.filesBuildSettingsChangedDebouncer.scheduleCall([document])
10271071
}
10281072
} else {
@@ -1080,7 +1124,7 @@ package actor BuildServerManager: QueueBasedMessageHandler {
10801124
} else {
10811125
try await withTimeout(options.buildSettingsTimeoutOrDefault) {
10821126
await self.canonicalTarget(for: mainFile)
1083-
} resultReceivedAfterTimeout: {
1127+
} resultReceivedAfterTimeout: { _ in
10841128
await self.filesBuildSettingsChangedDebouncer.scheduleCall([document])
10851129
}
10861130
}
@@ -1234,28 +1278,39 @@ package actor BuildServerManager: QueueBasedMessageHandler {
12341278

12351279
let request = WorkspaceBuildTargetsRequest()
12361280
let result = try await cachedBuildTargets.get(request, isolation: self) { request in
1237-
let buildTargets = try await buildServerAdapter.send(request).targets
1238-
let (depths, dependents) = await self.targetDepthsAndDependents(for: buildTargets)
1239-
var result: [BuildTargetIdentifier: BuildTargetInfo] = [:]
1240-
result.reserveCapacity(buildTargets.count)
1241-
for buildTarget in buildTargets {
1242-
guard result[buildTarget.id] == nil else {
1243-
logger.error("Found two targets with the same ID \(buildTarget.id)")
1244-
continue
1245-
}
1246-
let depth: Int
1247-
if let d = depths[buildTarget.id] {
1248-
depth = d
1249-
} else {
1250-
logger.fault("Did not compute depth for target \(buildTarget.id)")
1251-
depth = 0
1281+
let result = try await withTimeout(self.options.buildServerWorkspaceRequestsTimeoutOrDefault) {
1282+
let buildTargets = try await buildServerAdapter.send(request).targets
1283+
let (depths, dependents) = await self.targetDepthsAndDependents(for: buildTargets)
1284+
var result: [BuildTargetIdentifier: BuildTargetInfo] = [:]
1285+
result.reserveCapacity(buildTargets.count)
1286+
for buildTarget in buildTargets {
1287+
guard result[buildTarget.id] == nil else {
1288+
logger.error("Found two targets with the same ID \(buildTarget.id)")
1289+
continue
1290+
}
1291+
let depth: Int
1292+
if let d = depths[buildTarget.id] {
1293+
depth = d
1294+
} else {
1295+
logger.fault("Did not compute depth for target \(buildTarget.id)")
1296+
depth = 0
1297+
}
1298+
result[buildTarget.id] = BuildTargetInfo(
1299+
target: buildTarget,
1300+
depth: depth,
1301+
dependents: dependents[buildTarget.id] ?? []
1302+
)
12521303
}
1253-
result[buildTarget.id] = BuildTargetInfo(
1254-
target: buildTarget,
1255-
depth: depth,
1256-
dependents: dependents[buildTarget.id] ?? []
1304+
return result
1305+
} resultReceivedAfterTimeout: { newResult in
1306+
await self.buildTargetsDidChange(
1307+
.buildTargetsReceivedResultAfterTimeout(request: request, newResult: newResult)
12571308
)
12581309
}
1310+
guard let result else {
1311+
logger.error("Failed to get targets of workspace within timeout")
1312+
return [:]
1313+
}
12591314
return result
12601315
}
12611316
return result
@@ -1288,7 +1343,11 @@ package actor BuildServerManager: QueueBasedMessageHandler {
12881343
}
12891344

12901345
let response = try await cachedTargetSources.get(request, isolation: self) { request in
1291-
try await buildServerAdapter.send(request)
1346+
try await withTimeout(self.options.buildServerWorkspaceRequestsTimeoutOrDefault) {
1347+
return try await buildServerAdapter.send(request)
1348+
} resultReceivedAfterTimeout: { newResult in
1349+
await self.buildTargetsDidChange(.sourceFilesReceivedResultAfterTimeout(request: request, newResult: newResult))
1350+
} ?? BuildTargetSourcesResponse(items: [])
12921351
}
12931352
return response.items
12941353
}

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)