Skip to content

Commit d96f2fb

Browse files
authored
Merge pull request #1467 from ahoppen/debounce-index-progress
Debounce the creation of work done progress for indexing and package reloading
2 parents c9e6348 + 3fc03e9 commit d96f2fb

8 files changed

+172
-197
lines changed

Sources/SourceKitLSP/CMakeLists.txt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ add_library(SourceKitLSP STATIC
1919
TestDiscovery.swift
2020
TextEdit+IsNoop.swift
2121
WorkDoneProgressManager.swift
22-
WorkDoneProgressState.swift
2322
Workspace.swift
2423
)
2524
target_sources(SourceKitLSP PRIVATE

Sources/SourceKitLSP/IndexProgressManager.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ actor IndexProgressManager {
7878

7979
private func indexProgressStatusDidChangeImpl() async {
8080
guard let sourceKitLSPServer else {
81+
await workDoneProgress?.end()
8182
workDoneProgress = nil
8283
return
8384
}
@@ -116,15 +117,17 @@ actor IndexProgressManager {
116117
case .upToDate:
117118
// Nothing left to index. Reset the target count and dismiss the work done progress.
118119
queuedIndexTasks = 0
120+
await workDoneProgress?.end()
119121
workDoneProgress = nil
120122
return
121123
}
122124

123125
if let workDoneProgress {
124-
workDoneProgress.update(message: message, percentage: percentage)
126+
await workDoneProgress.update(message: message, percentage: percentage)
125127
} else {
126128
workDoneProgress = await WorkDoneProgressManager(
127129
server: sourceKitLSPServer,
130+
initialDebounce: sourceKitLSPServer.options.workDoneProgressDebounceDuration,
128131
title: "Indexing",
129132
message: message,
130133
percentage: percentage

Sources/SourceKitLSP/SourceKitLSPServer+Options.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ extension SourceKitLSPServer {
4848
/// notification when running unit tests.
4949
public var swiftPublishDiagnosticsDebounceDuration: TimeInterval
5050

51+
/// When a task is started that should be displayed to the client as a work done progress, how many milliseconds to
52+
/// wait before actually starting the work done progress. This prevents flickering of the work done progress in the
53+
/// client for short-lived index tasks which end within this duration.
54+
public var workDoneProgressDebounceDuration: Duration
55+
5156
/// Experimental features that are enabled.
5257
public var experimentalFeatures: Set<ExperimentalFeature>
5358

@@ -61,6 +66,7 @@ extension SourceKitLSPServer {
6166
completionOptions: SKCompletionOptions = .init(),
6267
generatedInterfacesPath: AbsolutePath = defaultDirectoryForGeneratedInterfaces,
6368
swiftPublishDiagnosticsDebounceDuration: TimeInterval = 2, /* 2s */
69+
workDoneProgressDebounceDuration: Duration = .seconds(0),
6470
experimentalFeatures: Set<ExperimentalFeature> = [],
6571
indexTestHooks: IndexTestHooks = IndexTestHooks()
6672
) {
@@ -72,6 +78,7 @@ extension SourceKitLSPServer {
7278
self.generatedInterfacesPath = generatedInterfacesPath
7379
self.swiftPublishDiagnosticsDebounceDuration = swiftPublishDiagnosticsDebounceDuration
7480
self.experimentalFeatures = experimentalFeatures
81+
self.workDoneProgressDebounceDuration = workDoneProgressDebounceDuration
7582
self.indexTestHooks = indexTestHooks
7683
}
7784
}

Sources/SourceKitLSP/SourceKitLSPServer.swift

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -126,10 +126,10 @@ public actor SourceKitLSPServer {
126126
/// initializer.
127127
private nonisolated(unsafe) var indexProgressManager: IndexProgressManager!
128128

129-
private var packageLoadingWorkDoneProgress = WorkDoneProgressState(
130-
"SourceKitLSP.SourceKitLSPServer.reloadPackage",
131-
title: "SourceKit-LSP: Reloading Package"
132-
)
129+
/// Number of workspaces that are currently reloading swift package. When this is not 0, a
130+
/// `packageLoadingWorkDoneProgress` is created to show a work done progress indicator in the client.
131+
private var inProgressPackageLoadingOperations = 0
132+
private var packageLoadingWorkDoneProgress: WorkDoneProgressManager?
133133

134134
/// **Public for testing**
135135
public var _documentManager: DocumentManager {
@@ -896,6 +896,27 @@ extension SourceKitLSPServer {
896896
)
897897
}
898898

899+
private func reloadPackageStatusCallback(_ status: ReloadPackageStatus) async {
900+
switch status {
901+
case .start:
902+
inProgressPackageLoadingOperations += 1
903+
if let capabilityRegistry, packageLoadingWorkDoneProgress == nil {
904+
packageLoadingWorkDoneProgress = WorkDoneProgressManager(
905+
server: self,
906+
capabilityRegistry: capabilityRegistry,
907+
initialDebounce: options.workDoneProgressDebounceDuration,
908+
title: "SourceKit-LSP: Reloading Package"
909+
)
910+
}
911+
case .end:
912+
inProgressPackageLoadingOperations -= 1
913+
if inProgressPackageLoadingOperations == 0, let packageLoadingWorkDoneProgress {
914+
self.packageLoadingWorkDoneProgress = nil
915+
await packageLoadingWorkDoneProgress.end()
916+
}
917+
}
918+
}
919+
899920
/// Creates a workspace at the given `uri`.
900921
///
901922
/// If the build system that was determined for the workspace does not satisfy `condition`, `nil` is returned.
@@ -914,13 +935,7 @@ extension SourceKitLSPServer {
914935
options: options,
915936
toolchainRegistry: toolchainRegistry,
916937
reloadPackageStatusCallback: { [weak self] status in
917-
guard let self else { return }
918-
switch status {
919-
case .start:
920-
await self.packageLoadingWorkDoneProgress.startProgress(server: self)
921-
case .end:
922-
await self.packageLoadingWorkDoneProgress.endProgress(server: self)
923-
}
938+
await self?.reloadPackageStatusCallback(status)
924939
}
925940
)
926941
guard await condition(buildSystem) else {

Sources/SourceKitLSP/Swift/SwiftLanguageService.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,11 +142,17 @@ public actor SwiftLanguageService: LanguageService, Sendable {
142142
}
143143

144144
guard let sourceKitLSPServer else {
145+
Task {
146+
await sourcekitdCrashedWorkDoneProgress?.end()
147+
}
145148
sourcekitdCrashedWorkDoneProgress = nil
146149
return
147150
}
148151
switch state {
149152
case .connected:
153+
Task {
154+
await sourcekitdCrashedWorkDoneProgress?.end()
155+
}
150156
sourcekitdCrashedWorkDoneProgress = nil
151157
case .connectionInterrupted, .semanticFunctionalityDisabled:
152158
if sourcekitdCrashedWorkDoneProgress == nil {

Sources/SourceKitLSP/WorkDoneProgressManager.swift

Lines changed: 119 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
//===----------------------------------------------------------------------===//
1212

1313
import Foundation
14+
import LSPLogging
1415
import LanguageServerProtocol
1516
import SKSupport
1617
import SwiftExtensions
@@ -19,89 +20,155 @@ import SwiftExtensions
1920
///
2021
/// The work done progress is started when the object is created and ended when the object is destroyed.
2122
/// In between, updates can be sent to the client.
22-
final class WorkDoneProgressManager {
23-
private let token: ProgressToken
24-
private let queue = AsyncQueue<Serial>()
25-
private let server: SourceKitLSPServer
26-
/// `true` if the client returned without an error from the `CreateWorkDoneProgressRequest`.
23+
final actor WorkDoneProgressManager {
24+
private enum Status: Equatable {
25+
case inProgress(message: String?, percentage: Int?)
26+
case done
27+
}
28+
29+
/// The token with which the work done progress has been created. `nil` if no work done progress has been created yet,
30+
/// either because we didn't send the `WorkDoneProgress` request yet, because the work done progress creation failed,
31+
/// or because the work done progress has been ended.
32+
private var token: ProgressToken?
33+
34+
/// The queue on which progress updates are sent to the client.
35+
private let progressUpdateQueue = AsyncQueue<Serial>()
36+
37+
private weak var server: SourceKitLSPServer?
38+
39+
private let title: String
40+
41+
/// The next status that should be sent to the client by `sendProgressUpdateImpl`.
2742
///
28-
/// Since all work done progress reports are being sent on `queue`, we never access it in a state where the
29-
/// `CreateWorkDoneProgressRequest` is still in progress.
43+
/// While progress updates are being queued in `progressUpdateQueue` this status can evolve. The next
44+
/// `sendProgressUpdateImpl` call will pick up the latest status.
3045
///
31-
/// Must be a reference because `deinit` captures it and wants to observe changes to it from `init` eg. in the
32-
/// following:
33-
/// - `init` is called
34-
/// - `deinit` is called
35-
/// - The task from `init` gets executed
36-
/// - The task from `deinit` gets executed
37-
/// - This should have `workDoneProgressCreated == true` so that it can send the work progress end.
38-
private let workDoneProgressCreated: ThreadSafeBox<Bool> & AnyObject = ThreadSafeBox<Bool>(initialValue: false)
46+
/// For example, if we receive two update calls to 25% and 50% in quick succession the `sendProgressUpdateImpl`
47+
/// scheduled from the 25% update will already pick up the new 50% status. The `sendProgressUpdateImpl` call scheduled
48+
/// from the 50% update will then realize that the `lastStatus` is already up-to-date and be a no-op.
49+
private var pendingStatus: Status
3950

40-
/// The last message and percentage so we don't send a new report notification to the client if `update` is called
41-
/// without any actual change.
42-
private var lastStatus: (message: String?, percentage: Int?)
51+
/// The last status that was sent to the client. Used so we don't send no-op updates to the client.
52+
private var lastStatus: Status? = nil
4353

44-
convenience init?(server: SourceKitLSPServer, title: String, message: String? = nil, percentage: Int? = nil) async {
54+
init?(
55+
server: SourceKitLSPServer,
56+
initialDebounce: Duration? = nil,
57+
title: String,
58+
message: String? = nil,
59+
percentage: Int? = nil
60+
) async {
4561
guard let capabilityRegistry = await server.capabilityRegistry else {
4662
return nil
4763
}
48-
self.init(server: server, capabilityRegistry: capabilityRegistry, title: title, message: message)
64+
self.init(
65+
server: server,
66+
capabilityRegistry: capabilityRegistry,
67+
initialDebounce: initialDebounce,
68+
title: title,
69+
message: message,
70+
percentage: percentage
71+
)
4972
}
5073

5174
init?(
5275
server: SourceKitLSPServer,
5376
capabilityRegistry: CapabilityRegistry,
77+
initialDebounce: Duration? = nil,
5478
title: String,
5579
message: String? = nil,
5680
percentage: Int? = nil
5781
) {
5882
guard capabilityRegistry.clientCapabilities.window?.workDoneProgress ?? false else {
5983
return nil
6084
}
61-
self.token = .string("WorkDoneProgress-\(UUID())")
6285
self.server = server
63-
queue.async { [server, token, workDoneProgressCreated] in
64-
await server.waitUntilInitialized()
65-
do {
66-
_ = try await server.client.send(CreateWorkDoneProgressRequest(token: token))
67-
} catch {
68-
return
86+
self.title = title
87+
self.pendingStatus = .inProgress(message: message, percentage: percentage)
88+
progressUpdateQueue.async {
89+
if let initialDebounce {
90+
try? await Task.sleep(for: initialDebounce)
6991
}
70-
server.sendNotificationToClient(
71-
WorkDoneProgress(
72-
token: token,
73-
value: .begin(WorkDoneProgressBegin(title: title, message: message, percentage: percentage))
74-
)
75-
)
76-
workDoneProgressCreated.value = true
77-
self.lastStatus = (message, percentage)
92+
await self.sendProgressUpdateAssumingOnProgressUpdateQueue()
7893
}
7994
}
8095

81-
func update(message: String? = nil, percentage: Int? = nil) {
82-
queue.async { [server, token, workDoneProgressCreated] in
83-
guard workDoneProgressCreated.value else {
84-
return
96+
/// Send the necessary messages to the client to update the work done progress to `status`.
97+
///
98+
/// Must be called on `progressUpdateQueue`
99+
private func sendProgressUpdateAssumingOnProgressUpdateQueue() async {
100+
let statusToSend = pendingStatus
101+
guard statusToSend != lastStatus else {
102+
return
103+
}
104+
guard let server else {
105+
// SourceKitLSPServer has been destroyed, we don't have a way to send notifications to the client anymore.
106+
return
107+
}
108+
await server.waitUntilInitialized()
109+
switch statusToSend {
110+
case .inProgress(message: let message, percentage: let percentage):
111+
if let token {
112+
server.sendNotificationToClient(
113+
WorkDoneProgress(
114+
token: token,
115+
value: .report(WorkDoneProgressReport(cancellable: false, message: message, percentage: percentage))
116+
)
117+
)
118+
} else {
119+
let token = ProgressToken.string(UUID().uuidString)
120+
do {
121+
_ = try await server.client.send(CreateWorkDoneProgressRequest(token: token))
122+
} catch {
123+
return
124+
}
125+
server.sendNotificationToClient(
126+
WorkDoneProgress(
127+
token: token,
128+
value: .begin(WorkDoneProgressBegin(title: title, message: message, percentage: percentage))
129+
)
130+
)
131+
self.token = token
85132
}
86-
guard (message, percentage) != self.lastStatus else {
87-
return
133+
case .done:
134+
if let token {
135+
server.sendNotificationToClient(WorkDoneProgress(token: token, value: .end(WorkDoneProgressEnd())))
136+
self.token = nil
88137
}
89-
self.lastStatus = (message, percentage)
90-
server.sendNotificationToClient(
91-
WorkDoneProgress(
92-
token: token,
93-
value: .report(WorkDoneProgressReport(cancellable: false, message: message, percentage: percentage))
94-
)
95-
)
138+
}
139+
lastStatus = statusToSend
140+
}
141+
142+
func update(message: String? = nil, percentage: Int? = nil) {
143+
pendingStatus = .inProgress(message: message, percentage: percentage)
144+
progressUpdateQueue.async {
145+
await self.sendProgressUpdateAssumingOnProgressUpdateQueue()
146+
}
147+
}
148+
149+
/// Ends the work done progress. Any further update calls are no-ops.
150+
///
151+
/// `end` must be should be called before the `WorkDoneProgressManager` is deallocated.
152+
func end() {
153+
pendingStatus = .done
154+
progressUpdateQueue.async {
155+
await self.sendProgressUpdateAssumingOnProgressUpdateQueue()
96156
}
97157
}
98158

99159
deinit {
100-
queue.async { [server, token, workDoneProgressCreated] in
101-
guard workDoneProgressCreated.value else {
102-
return
160+
if pendingStatus != .done {
161+
// If there is still a pending work done progress, end it. We know that we don't have any pending updates on
162+
// `progressUpdateQueue` because they would capture `self` strongly and thus we wouldn't be deallocating this
163+
// object.
164+
// This is a fallback logic to ensure we don't leave pending work done progresses in the editor if the
165+
// `WorkDoneProgressManager` is destroyed without a call to `end` (eg. because its owning object is destroyed).
166+
// Calling `end()` is preferred because it ends the work done progress even if there are pending status updates
167+
// in `progressUpdateQueue`, which keep the `WorkDoneProgressManager` alive and thus prevent the work done
168+
// progress to be implicitly ended by the deinitializer.
169+
if let token {
170+
server?.sendNotificationToClient(WorkDoneProgress(token: token, value: .end(WorkDoneProgressEnd())))
103171
}
104-
server.sendNotificationToClient(WorkDoneProgress(token: token, value: .end(WorkDoneProgressEnd())))
105172
}
106173
}
107174
}

0 commit comments

Comments
 (0)