Files
sourcekit-lsp/Sources/SourceKitLSP/WorkDoneProgressManager.swift
Alex Hoppen 68bff7f216 Make background indexing a proper option in SourceKitLSPOptions
This allows us to flip the default in the future more easily. It also allows users to disable background indexing when it’s enabled by default.

rdar://130280855

# Conflicts:
#	Tests/SourceKitLSPTests/BackgroundIndexingTests.swift
2024-06-28 22:36:11 +02:00

258 lines
9.6 KiB
Swift

//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2014 - 2020 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
//
//===----------------------------------------------------------------------===//
import Foundation
import LSPLogging
import LanguageServerProtocol
import SKSupport
import SwiftExtensions
/// Represents a single `WorkDoneProgress` task that gets communicated with the client.
///
/// The work done progress is started when the object is created and ended when the object is destroyed.
/// In between, updates can be sent to the client.
actor WorkDoneProgressManager {
private enum Status: Equatable {
case inProgress(message: String?, percentage: Int?)
case done
}
/// The token with which the work done progress has been created. `nil` if no work done progress has been created yet,
/// either because we didn't send the `WorkDoneProgress` request yet, because the work done progress creation failed,
/// or because the work done progress has been ended.
private var token: ProgressToken?
/// The queue on which progress updates are sent to the client.
private let progressUpdateQueue = AsyncQueue<Serial>()
private weak var server: SourceKitLSPServer?
/// A string with which the `token` of the generated `WorkDoneProgress` sent to the client starts.
///
/// A UUID will be appended to this prefix to make the token unique. The token prefix can be used to classify the work
/// done progress into a category, which makes debugging easier because the tokens have semantic meaning and also
/// allows clients to interpret what the `WorkDoneProgress` represents (for example Swift for VS Code explicitly
/// recognizes work done progress that indicates that sourcekitd has crashed to offer a diagnostic bundle to be
/// generated).
private let tokenPrefix: String
private let title: String
/// The next status that should be sent to the client by `sendProgressUpdateImpl`.
///
/// While progress updates are being queued in `progressUpdateQueue` this status can evolve. The next
/// `sendProgressUpdateImpl` call will pick up the latest status.
///
/// For example, if we receive two update calls to 25% and 50% in quick succession the `sendProgressUpdateImpl`
/// scheduled from the 25% update will already pick up the new 50% status. The `sendProgressUpdateImpl` call scheduled
/// from the 50% update will then realize that the `lastStatus` is already up-to-date and be a no-op.
private var pendingStatus: Status
/// The last status that was sent to the client. Used so we don't send no-op updates to the client.
private var lastStatus: Status? = nil
/// Needed to work around rdar://116221716
private static func getServerCapabilityRegistry(_ server: SourceKitLSPServer) async -> CapabilityRegistry? {
return await server.capabilityRegistry
}
init?(
server: SourceKitLSPServer,
tokenPrefix: String,
initialDebounce: Duration? = nil,
title: String,
message: String? = nil,
percentage: Int? = nil
) async {
guard let capabilityRegistry = await Self.getServerCapabilityRegistry(server) else {
return nil
}
self.init(
server: server,
capabilityRegistry: capabilityRegistry,
tokenPrefix: tokenPrefix,
initialDebounce: initialDebounce,
title: title,
message: message,
percentage: percentage
)
}
init?(
server: SourceKitLSPServer,
capabilityRegistry: CapabilityRegistry,
tokenPrefix: String,
initialDebounce: Duration? = nil,
title: String,
message: String? = nil,
percentage: Int? = nil
) {
guard capabilityRegistry.clientCapabilities.window?.workDoneProgress ?? false else {
return nil
}
self.tokenPrefix = tokenPrefix
self.server = server
self.title = title
self.pendingStatus = .inProgress(message: message, percentage: percentage)
progressUpdateQueue.async {
if let initialDebounce {
try? await Task.sleep(for: initialDebounce)
}
await self.sendProgressUpdateAssumingOnProgressUpdateQueue()
}
}
/// Send the necessary messages to the client to update the work done progress to `status`.
///
/// Must be called on `progressUpdateQueue`
private func sendProgressUpdateAssumingOnProgressUpdateQueue() async {
let statusToSend = pendingStatus
guard statusToSend != lastStatus else {
return
}
guard let server else {
// SourceKitLSPServer has been destroyed, we don't have a way to send notifications to the client anymore.
return
}
await server.waitUntilInitialized()
switch statusToSend {
case .inProgress(message: let message, percentage: let percentage):
if let token {
server.sendNotificationToClient(
WorkDoneProgress(
token: token,
value: .report(WorkDoneProgressReport(cancellable: false, message: message, percentage: percentage))
)
)
} else {
let token = ProgressToken.string("\(tokenPrefix).\(UUID().uuidString)")
do {
_ = try await server.client.send(CreateWorkDoneProgressRequest(token: token))
} catch {
return
}
server.sendNotificationToClient(
WorkDoneProgress(
token: token,
value: .begin(WorkDoneProgressBegin(title: title, message: message, percentage: percentage))
)
)
self.token = token
}
case .done:
if let token {
server.sendNotificationToClient(WorkDoneProgress(token: token, value: .end(WorkDoneProgressEnd())))
self.token = nil
}
}
lastStatus = statusToSend
}
func update(message: String? = nil, percentage: Int? = nil) {
pendingStatus = .inProgress(message: message, percentage: percentage)
progressUpdateQueue.async {
await self.sendProgressUpdateAssumingOnProgressUpdateQueue()
}
}
/// Ends the work done progress. Any further update calls are no-ops.
///
/// `end` must be should be called before the `WorkDoneProgressManager` is deallocated.
func end() {
pendingStatus = .done
progressUpdateQueue.async {
await self.sendProgressUpdateAssumingOnProgressUpdateQueue()
}
}
deinit {
if pendingStatus != .done {
// If there is still a pending work done progress, end it. We know that we don't have any pending updates on
// `progressUpdateQueue` because they would capture `self` strongly and thus we wouldn't be deallocating this
// object.
// This is a fallback logic to ensure we don't leave pending work done progresses in the editor if the
// `WorkDoneProgressManager` is destroyed without a call to `end` (eg. because its owning object is destroyed).
// Calling `end()` is preferred because it ends the work done progress even if there are pending status updates
// in `progressUpdateQueue`, which keep the `WorkDoneProgressManager` alive and thus prevent the work done
// progress to be implicitly ended by the deinitializer.
if let token {
server?.sendNotificationToClient(WorkDoneProgress(token: token, value: .end(WorkDoneProgressEnd())))
}
}
}
}
/// A `WorkDoneProgressManager` that essentially has two states. If any operation tracked by this type is currently
/// running, it displays a work done progress in the client. If multiple operations are running at the same time, it
/// doesn't show multiple work done progress in the client. For example, we only want to show one progress indicator
/// when sourcekitd has crashed, not one per `SwiftLanguageService`.
actor SharedWorkDoneProgressManager {
private weak var sourceKitLSPServer: SourceKitLSPServer?
/// The number of in-progress operations. When greater than 0 `workDoneProgress` non-nil and a work done progress is
/// displayed to the user.
private var inProgressOperations = 0
private var workDoneProgress: WorkDoneProgressManager?
private let tokenPrefix: String
private let title: String
private let message: String?
public init(
sourceKitLSPServer: SourceKitLSPServer,
tokenPrefix: String,
title: String,
message: String? = nil
) {
self.sourceKitLSPServer = sourceKitLSPServer
self.tokenPrefix = tokenPrefix
self.title = title
self.message = message
}
func start() async {
guard let sourceKitLSPServer else {
return
}
// Do all asynchronous operations up-front so that incrementing `inProgressOperations` and setting `workDoneProgress`
// cannot be interrupted by an `await` call
let initialDebounceDuration = await sourceKitLSPServer.options.workDoneProgressDebounceDurationOrDefault
let capabilityRegistry = await sourceKitLSPServer.capabilityRegistry
inProgressOperations += 1
if let capabilityRegistry, workDoneProgress == nil {
workDoneProgress = WorkDoneProgressManager(
server: sourceKitLSPServer,
capabilityRegistry: capabilityRegistry,
tokenPrefix: tokenPrefix,
initialDebounce: initialDebounceDuration,
title: title,
message: message
)
}
}
func end() async {
if inProgressOperations > 0 {
inProgressOperations -= 1
} else {
logger.fault(
"Unbalanced calls to SharedWorkDoneProgressManager.start and end for \(self.tokenPrefix, privacy: .public)"
)
}
if inProgressOperations == 0, let workDoneProgress {
self.workDoneProgress = nil
await workDoneProgress.end()
}
}
}