Skip to content

Commit 5bf6233

Browse files
committed
Debounce the creation of work done progress for indexing
This avoids us from showing a work done progress for short-lived index tasks that take less than a second. Fixes #1298 rdar://128071328
1 parent e1f80e4 commit 5bf6233

File tree

5 files changed

+137
-58
lines changed

5 files changed

+137
-58
lines changed

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.indexProgressDebounceDuration,
128131
title: "Indexing",
129132
message: message,
130133
percentage: percentage

Sources/SourceKitLSP/SourceKitLSPServer+Options.swift

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

51+
/// When an index task is started, how many milliseconds to wait before sending a work done progress to the client.
52+
// This prevents flickering of the work done progress in the client for short-lived index tasks
53+
public var indexProgressDebounceDuration: Duration
54+
5155
/// Experimental features that are enabled.
5256
public var experimentalFeatures: Set<ExperimentalFeature>
5357

@@ -61,6 +65,7 @@ extension SourceKitLSPServer {
6165
completionOptions: SKCompletionOptions = .init(),
6266
generatedInterfacesPath: AbsolutePath = defaultDirectoryForGeneratedInterfaces,
6367
swiftPublishDiagnosticsDebounceDuration: TimeInterval = 2, /* 2s */
68+
indexProgressDebounceDuration: Duration = .seconds(0),
6469
experimentalFeatures: Set<ExperimentalFeature> = [],
6570
indexTestHooks: IndexTestHooks = IndexTestHooks()
6671
) {
@@ -72,6 +77,7 @@ extension SourceKitLSPServer {
7277
self.generatedInterfacesPath = generatedInterfacesPath
7378
self.swiftPublishDiagnosticsDebounceDuration = swiftPublishDiagnosticsDebounceDuration
7479
self.experimentalFeatures = experimentalFeatures
80+
self.indexProgressDebounceDuration = indexProgressDebounceDuration
7581
self.indexTestHooks = indexTestHooks
7682
}
7783
}

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: 111 additions & 51 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,29 +20,44 @@ 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
}
@@ -51,57 +67,101 @@ final class WorkDoneProgressManager {
5167
init?(
5268
server: SourceKitLSPServer,
5369
capabilityRegistry: CapabilityRegistry,
70+
initialDebounce: Duration? = nil,
5471
title: String,
5572
message: String? = nil,
5673
percentage: Int? = nil
5774
) {
5875
guard capabilityRegistry.clientCapabilities.window?.workDoneProgress ?? false else {
5976
return nil
6077
}
61-
self.token = .string("WorkDoneProgress-\(UUID())")
6278
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
79+
self.title = title
80+
self.pendingStatus = .inProgress(message: message, percentage: percentage)
81+
progressUpdateQueue.async {
82+
if let initialDebounce {
83+
try? await Task.sleep(for: initialDebounce)
6984
}
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)
85+
await self.sendProgressUpdateAssumingOnProgressUpdateQueue()
7886
}
7987
}
8088

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

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

Sources/sourcekit-lsp/SourceKitLSP.swift

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -193,11 +193,6 @@ struct SourceKitLSP: AsyncParsableCommand {
193193
)
194194
var generatedInterfacesPath = defaultDirectoryForGeneratedInterfaces
195195

196-
@Option(
197-
help: "When server-side filtering is enabled, the maximum number of results to return"
198-
)
199-
var completionMaxResults = 200
200-
201196
@Option(
202197
name: .customLong("experimental-feature"),
203198
help: """
@@ -207,6 +202,14 @@ struct SourceKitLSP: AsyncParsableCommand {
207202
)
208203
var experimentalFeatures: [ExperimentalFeature] = []
209204

205+
// MARK: Configuration options intended for SourceKit-LSP developers.
206+
207+
@Option(help: .hidden)
208+
var completionMaxResults = 200
209+
210+
@Option(help: .hidden)
211+
var indexProgressDebounceDuration: Int = 1_000
212+
210213
func mapOptions() -> SourceKitLSPServer.Options {
211214
var serverOptions = SourceKitLSPServer.Options()
212215

@@ -222,9 +225,10 @@ struct SourceKitLSP: AsyncParsableCommand {
222225
serverOptions.indexOptions.indexStorePath = indexStorePath
223226
serverOptions.indexOptions.indexDatabasePath = indexDatabasePath
224227
serverOptions.indexOptions.indexPrefixMappings = indexPrefixMappings
225-
serverOptions.completionOptions.maxResults = completionMaxResults
226228
serverOptions.generatedInterfacesPath = generatedInterfacesPath
227229
serverOptions.experimentalFeatures = Set(experimentalFeatures)
230+
serverOptions.completionOptions.maxResults = completionMaxResults
231+
serverOptions.indexProgressDebounceDuration = .milliseconds(indexProgressDebounceDuration)
228232

229233
return serverOptions
230234
}

0 commit comments

Comments
 (0)