Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Documentation/Configuration File.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,4 @@ The structure of the file is currently not guaranteed to be stable. Options may
- `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.
- `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.
- `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.
- `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.
141 changes: 100 additions & 41 deletions Sources/BuildServerIntegration/BuildServerManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -482,8 +482,7 @@ package actor BuildServerManager: QueueBasedMessageHandler {
self.filesDependenciesUpdatedDebouncer = Debouncer(
debounceDuration: .milliseconds(500),
combineResults: { $0.union($1) },
makeCall: {
[weak self] (filesWithUpdatedDependencies) in
makeCall: { [weak self] (filesWithUpdatedDependencies) in
guard let self, let delegate = await self.delegate else {
logger.fault("Not calling filesDependenciesUpdated because no delegate exists in SwiftPMBuildServer")
return
Expand All @@ -502,8 +501,7 @@ package actor BuildServerManager: QueueBasedMessageHandler {
self.filesBuildSettingsChangedDebouncer = Debouncer(
debounceDuration: .milliseconds(20),
combineResults: { $0.union($1) },
makeCall: {
[weak self] (filesWithChangedBuildSettings) in
makeCall: { [weak self] (filesWithChangedBuildSettings) in
guard let self, let delegate = await self.delegate else {
logger.fault("Not calling fileBuildSettingsChanged because no delegate exists in SwiftPMBuildServer")
return
Expand Down Expand Up @@ -670,31 +668,76 @@ package actor BuildServerManager: QueueBasedMessageHandler {
}

private func didChangeBuildTarget(notification: OnBuildTargetDidChangeNotification) async {
let updatedTargets: Set<BuildTargetIdentifier>? =
let changedTargets: Set<BuildTargetIdentifier>? =
if let changes = notification.changes {
Set(changes.map(\.target))
} else {
nil
}
self.cachedAdjustedSourceKitOptions.clear(isolation: self) { cacheKey in
guard let updatedTargets else {
// All targets might have changed
return true
await self.buildTargetsDidChange(.didChangeBuildTargets(changedTargets: changedTargets))
}

private enum BuildTargetsChange {
case didChangeBuildTargets(changedTargets: Set<BuildTargetIdentifier>?)
case buildTargetsReceivedResultAfterTimeout(
request: WorkspaceBuildTargetsRequest,
newResult: [BuildTargetIdentifier: BuildTargetInfo]
)
case sourceFilesReceivedResultAfterTimeout(
request: BuildTargetSourcesRequest,
newResult: BuildTargetSourcesResponse
)
}

/// Update the cached state in `BuildServerManager` because new data was received from the BSP server.
///
/// This handles a few seemingly unrelated reasons to ensure that we think about which caches to invalidate in the
/// other scenarios as well, when making changes in here.
private func buildTargetsDidChange(_ stateChange: BuildTargetsChange) async {
let changedTargets: Set<BuildTargetIdentifier>?

switch stateChange {
case .didChangeBuildTargets(let changedTargetsValue):
changedTargets = changedTargetsValue
self.cachedAdjustedSourceKitOptions.clear(isolation: self) { cacheKey in
guard let changedTargets else {
// All targets might have changed
return true
}
return changedTargets.contains(cacheKey.target)
}
return updatedTargets.contains(cacheKey.target)
}
self.cachedBuildTargets.clearAll(isolation: self)
self.cachedTargetSources.clear(isolation: self) { cacheKey in
guard let updatedTargets else {
// All targets might have changed
return true
self.cachedBuildTargets.clearAll(isolation: self)
self.cachedTargetSources.clear(isolation: self) { cacheKey in
guard let changedTargets else {
// All targets might have changed
return true
}
return !changedTargets.intersection(cacheKey.targets).isEmpty
}
return !updatedTargets.intersection(cacheKey.targets).isEmpty
}
case .buildTargetsReceivedResultAfterTimeout(let request, let newResult):
changedTargets = nil

// Caches not invalidated:
// - cachedAdjustedSourceKitOptions: We would not have requested SourceKit options for targets that we didn't
// know about. Even if we did, the build server now telling us about the target should not change the options of
// the file within the target
// - cachedTargetSources: Similar to cachedAdjustedSourceKitOptions, we would not have requested sources for
// targets that we didn't know about and if we did, they wouldn't be affected
self.cachedBuildTargets.set(request, to: newResult)
case .sourceFilesReceivedResultAfterTimeout(let request, let newResult):
changedTargets = Set(request.targets)

// Caches not invalidated:
// - cachedAdjustedSourceKitOptions: Same as for buildTargetsReceivedResultAfterTimeout.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assuming that the files are different, we probably do need to clear options here? ie. there may now be added/removed files and thus we need to re-request options.

I also wonder if this is true for build targets - that they have changed could imply that new search paths are needed (or old removed). For SwiftPM this isn't relevant today (as it has the one folder that everything is built into), but for Bazel I imagine this is much more relevant?

And lastly, is it now possible that we have multiple target/source requests in flight in different orders?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assuming that the files are different, we probably do need to clear options here? ie. there may now be added/removed files and thus we need to re-request options.

I don’t think so. If we got built settings for a source files then that’s expected to be up-to-date until the build server sends us a buildTarget/didChange notification. In practice I don’t know how we would have gotten here since we won’t request the build settings for a file in a target unless we know that it’s part of the target but even if we did, and the build server was able to return build settings for the file while the buildTarget/sources was still in progress, we would expect that information to be correct until we receive a buildTarget/didChange notification. Does this make sense? It could be that I missed something here. Race conditions and cache invalidation are hard.

I also wonder if this is true for build targets - that they have changed could imply that new search paths are needed (or old removed). For SwiftPM this isn't relevant today (as it has the one folder that everything is built into), but for Bazel I imagine this is much more relevant?

Similar argument here. If we get any build settings for a file, we expect that to be correct until the build server notifies us that they have changed. Consider the workspace/buildTargets as a pure getter request that shouldn’t modify the build server’s state in an observable way. Receiving information about the build targets won’t change the build settings of the files within them.

And lastly, is it now possible that we have multiple target/source requests in flight in different orders?

No more than before. All the timeout handling happens within the closures of the Cache.get calls, so if there are multiple calls to the same target set, they will be served by the same Task, just like before this change. We could have concurrent request for two target sets that aren’t overlapping, eg. sources of targets A, B and another request for targets B, C but then we rely on the build server to give us consistent answers for target B in both requests.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

until the build server notifies us that they have changed

If the assumption is that we expect a buildTarget/didChange, then sounds good to me 👍

// - cachedBuildTargets: Getting a result for the source files in a target doesn't change anything about the
// target's existence.
self.cachedTargetSources.set(request, to: newResult)
}
// Clear caches that capture global state and are affected by all changes
self.cachedSourceFilesAndDirectories.clearAll(isolation: self)
self.scheduleRecomputeCopyFileMap()

await delegate?.buildTargetsChanged(notification.changes)
await delegate?.buildTargetsChanged(changedTargets)
await filesBuildSettingsChangedDebouncer.scheduleCall(Set(watchedFiles.keys))
}

Expand Down Expand Up @@ -896,6 +939,7 @@ package actor BuildServerManager: QueueBasedMessageHandler {
previousUpdateTask?.cancel()
await orLog("Re-computing copy file map") {
let sourceFilesAndDirectories = try await self.sourceFilesAndDirectories()
try Task.checkCancellation()
var copiedFileMap: [DocumentURI: DocumentURI] = [:]
for (file, fileInfo) in sourceFilesAndDirectories.files {
for copyDestination in fileInfo.copyDestinations {
Expand Down Expand Up @@ -1022,7 +1066,7 @@ package actor BuildServerManager: QueueBasedMessageHandler {
if fallbackAfterTimeout {
try await withTimeout(options.buildSettingsTimeoutOrDefault) {
return try await self.buildSettingsFromBuildServer(for: document, in: target, language: language)
} resultReceivedAfterTimeout: {
} resultReceivedAfterTimeout: { _ in
await self.filesBuildSettingsChangedDebouncer.scheduleCall([document])
}
} else {
Expand Down Expand Up @@ -1080,7 +1124,7 @@ package actor BuildServerManager: QueueBasedMessageHandler {
} else {
try await withTimeout(options.buildSettingsTimeoutOrDefault) {
await self.canonicalTarget(for: mainFile)
} resultReceivedAfterTimeout: {
} resultReceivedAfterTimeout: { _ in
await self.filesBuildSettingsChangedDebouncer.scheduleCall([document])
}
}
Expand Down Expand Up @@ -1234,28 +1278,39 @@ package actor BuildServerManager: QueueBasedMessageHandler {

let request = WorkspaceBuildTargetsRequest()
let result = try await cachedBuildTargets.get(request, isolation: self) { request in
let buildTargets = try await buildServerAdapter.send(request).targets
let (depths, dependents) = await self.targetDepthsAndDependents(for: buildTargets)
var result: [BuildTargetIdentifier: BuildTargetInfo] = [:]
result.reserveCapacity(buildTargets.count)
for buildTarget in buildTargets {
guard result[buildTarget.id] == nil else {
logger.error("Found two targets with the same ID \(buildTarget.id)")
continue
}
let depth: Int
if let d = depths[buildTarget.id] {
depth = d
} else {
logger.fault("Did not compute depth for target \(buildTarget.id)")
depth = 0
let result = try await withTimeout(self.options.buildServerWorkspaceRequestsTimeoutOrDefault) {
let buildTargets = try await buildServerAdapter.send(request).targets
let (depths, dependents) = await self.targetDepthsAndDependents(for: buildTargets)
var result: [BuildTargetIdentifier: BuildTargetInfo] = [:]
result.reserveCapacity(buildTargets.count)
for buildTarget in buildTargets {
guard result[buildTarget.id] == nil else {
logger.error("Found two targets with the same ID \(buildTarget.id)")
continue
}
let depth: Int
if let d = depths[buildTarget.id] {
depth = d
} else {
logger.fault("Did not compute depth for target \(buildTarget.id)")
depth = 0
}
result[buildTarget.id] = BuildTargetInfo(
target: buildTarget,
depth: depth,
dependents: dependents[buildTarget.id] ?? []
)
}
result[buildTarget.id] = BuildTargetInfo(
target: buildTarget,
depth: depth,
dependents: dependents[buildTarget.id] ?? []
return result
} resultReceivedAfterTimeout: { newResult in
await self.buildTargetsDidChange(
.buildTargetsReceivedResultAfterTimeout(request: request, newResult: newResult)
)
}
guard let result else {
logger.error("Failed to get targets of workspace within timeout")
return [:]
}
return result
}
return result
Expand Down Expand Up @@ -1288,7 +1343,11 @@ package actor BuildServerManager: QueueBasedMessageHandler {
}

let response = try await cachedTargetSources.get(request, isolation: self) { request in
try await buildServerAdapter.send(request)
try await withTimeout(self.options.buildServerWorkspaceRequestsTimeoutOrDefault) {
return try await buildServerAdapter.send(request)
} resultReceivedAfterTimeout: { newResult in
await self.buildTargetsDidChange(.sourceFilesReceivedResultAfterTimeout(request: request, newResult: newResult))
} ?? BuildTargetSourcesResponse(items: [])
}
return response.items
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ package protocol BuildServerManagerDelegate: AnyObject, Sendable {

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

/// Methods with which the `BuildServerManager` can send messages to the client (aka. editor).
Expand Down
24 changes: 22 additions & 2 deletions Sources/SKOptions/SourceKitLSPOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,22 @@ public struct SourceKitLSPOptions: Sendable, Codable, Equatable {
return .seconds(300)
}

/// Duration how long to wait for responses to `workspace/buildTargets` or `buildTarget/sources` request by the build
/// server before defaulting to an empty response.
public var buildServerWorkspaceRequestsTimeout: Double? = nil

public var buildServerWorkspaceRequestsTimeoutOrDefault: Duration {
if let buildServerWorkspaceRequestsTimeout {
return .seconds(buildServerWorkspaceRequestsTimeout)
}
// The default value needs to strike a balance: If the build server is slow to respond, we don't want to constantly
// run into this timeout, which causes somewhat expensive computations because we trigger the `buildTargetsChanged`
// chain.
// At the same time, we do want to provide functionality based on fallback settings after some time.
// 15s seems like it should strike a balance here but there is no data backing this value up.
return .seconds(15)
}

public init(
swiftPM: SwiftPMOptions? = .init(),
fallbackBuildSystem: FallbackBuildSystemOptions? = .init(),
Expand All @@ -451,7 +467,8 @@ public struct SourceKitLSPOptions: Sendable, Codable, Equatable {
swiftPublishDiagnosticsDebounceDuration: Double? = nil,
workDoneProgressDebounceDuration: Double? = nil,
sourcekitdRequestTimeout: Double? = nil,
semanticServiceRestartTimeout: Double? = nil
semanticServiceRestartTimeout: Double? = nil,
buildServerWorkspaceRequestsTimeout: Double? = nil
) {
self.swiftPM = swiftPM
self.fallbackBuildSystem = fallbackBuildSystem
Expand All @@ -471,6 +488,7 @@ public struct SourceKitLSPOptions: Sendable, Codable, Equatable {
self.workDoneProgressDebounceDuration = workDoneProgressDebounceDuration
self.sourcekitdRequestTimeout = sourcekitdRequestTimeout
self.semanticServiceRestartTimeout = semanticServiceRestartTimeout
self.buildServerWorkspaceRequestsTimeout = buildServerWorkspaceRequestsTimeout
}

public init?(fromLSPAny lspAny: LSPAny?) throws {
Expand Down Expand Up @@ -531,7 +549,9 @@ public struct SourceKitLSPOptions: Sendable, Codable, Equatable {
workDoneProgressDebounceDuration: override?.workDoneProgressDebounceDuration
?? base.workDoneProgressDebounceDuration,
sourcekitdRequestTimeout: override?.sourcekitdRequestTimeout ?? base.sourcekitdRequestTimeout,
semanticServiceRestartTimeout: override?.semanticServiceRestartTimeout ?? base.semanticServiceRestartTimeout
semanticServiceRestartTimeout: override?.semanticServiceRestartTimeout ?? base.semanticServiceRestartTimeout,
buildServerWorkspaceRequestsTimeout: override?.buildServerWorkspaceRequestsTimeout
?? base.buildServerWorkspaceRequestsTimeout
)
}

Expand Down
9 changes: 1 addition & 8 deletions Sources/SemanticIndex/SemanticIndexManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -416,14 +416,7 @@ package final actor SemanticIndexManager {
}
}

package func buildTargetsChanged(_ changes: [BuildTargetEvent]?) async {
let targets: Set<BuildTargetIdentifier>? =
if let changes = changes?.map(\.target) {
Set(changes)
} else {
nil
}

package func buildTargetsChanged(_ targets: Set<BuildTargetIdentifier>?) async {
if let targets {
var targetsAndDependencies = targets
targetsAndDependencies.formUnion(await buildServerManager.targets(dependingOn: targets))
Expand Down
4 changes: 2 additions & 2 deletions Sources/SourceKitLSP/Workspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -468,9 +468,9 @@ package final class Workspace: Sendable, BuildServerManagerDelegate {
}
}

package func buildTargetsChanged(_ changes: [BuildTargetEvent]?) async {
package func buildTargetsChanged(_ changedTargets: Set<BuildTargetIdentifier>?) async {
await sourceKitLSPServer?.fileHandlingCapabilityChanged()
await semanticIndexManager?.buildTargetsChanged(changes)
await semanticIndexManager?.buildTargetsChanged(changedTargets)
await orLog("Scheduling syntactic test re-indexing") {
let testFiles = try await buildServerManager.testFiles()
await syntacticTestIndex.listOfTestFilesDidChange(testFiles)
Expand Down
20 changes: 17 additions & 3 deletions Sources/SwiftExtensions/AsyncUtils.swift
Original file line number Diff line number Diff line change
Expand Up @@ -270,8 +270,8 @@ package func withTimeout<T: Sendable>(
/// `body` should be cancelled after `timeout`.
package func withTimeout<T: Sendable>(
_ timeout: Duration,
body: @escaping @Sendable () async throws -> T?,
resultReceivedAfterTimeout: @escaping @Sendable () async -> Void
body: @escaping @Sendable () async throws -> T,
resultReceivedAfterTimeout: @escaping @Sendable (_ result: T) async -> Void
) async throws -> T? {
let didHitTimeout = AtomicBool(initialValue: false)

Expand All @@ -286,7 +286,7 @@ package func withTimeout<T: Sendable>(
do {
let result = try await body()
if didHitTimeout.value {
await resultReceivedAfterTimeout()
await resultReceivedAfterTimeout(result)
}
continuation.yield(result)
} catch {
Expand All @@ -306,3 +306,17 @@ package func withTimeout<T: Sendable>(
preconditionFailure("Continuation never finishes")
}
}

/// Same as `withTimeout` above but allows `body` to return an optional value.
package func withTimeout<T: Sendable>(
_ timeout: Duration,
body: @escaping @Sendable () async throws -> T?,
resultReceivedAfterTimeout: @escaping @Sendable (_ result: T?) async -> Void
) async throws -> T? {
let result: T?? = try await withTimeout(timeout, body: body, resultReceivedAfterTimeout: resultReceivedAfterTimeout)
switch result {
case .none: return nil
case .some(.none): return nil
case .some(.some(let value)): return value
}
}
9 changes: 9 additions & 0 deletions Sources/SwiftExtensions/Cache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ package class Cache<Key: Sendable & Hashable, Result: Sendable> {
return try await task.value
}

/// Force the value for a specific key to a value.
///
/// This should only be used if a value for this key is received by means that aren't covered through the `compute`
/// function in `get`. An example of this is receiving the results of a BSP request after a timeout, in which case we
/// would have cached the timeout result through `get` but now we have an updated value.
package func set(_ key: Key, to value: Result) {
storage[key] = Task { value }
}

/// Get the value cached for `key`. If no value exists for `key`, try deriving the result from an existing cache entry
/// that satisfies `canReuseKey` by applying `transform` to that result.
package func getDerived(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,7 @@ private actor BSMDelegate: BuildServerManagerDelegate {

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

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

var clientSupportsWorkDoneProgress: Bool { false }

Expand Down
Loading