Skip to content

Commit 10df160

Browse files
committed
Cancel preparation tasks for editor functionality if the preparation task hasn't been started yet and the document is no longer active
When the user opens documents from three targets A, B, and C in quick succession, then we don’t want to schedule preparation of wait until A *and* B are finished preparing before preparing C. Instead, we want to - Finish for preparation of A to finish if it has already started by the time the file in C is opened. This is done so we always make progress during preparation and don’t get into a scenario where preparation is always cancelled if a user switches between two targets more quickly than it takes to prepare those targets. - Not prepare B because it is no longer relevant and we haven’t started any progress here. Essentially, we pretend that the hop to B never happened.
1 parent e295a4e commit 10df160

File tree

9 files changed

+242
-42
lines changed

9 files changed

+242
-42
lines changed

Sources/SKCore/TaskScheduler.swift

Lines changed: 35 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -147,13 +147,20 @@ public actor QueuedTask<TaskDescription: TaskDescriptionProtocol> {
147147
/// Gets reset every time `executionTask` finishes.
148148
nonisolated(unsafe) private var cancelledToBeRescheduled: AtomicBool = .init(initialValue: false)
149149

150+
/// Whether `resultTask` has been cancelled.
151+
private nonisolated(unsafe) var resultTaskCancelled: AtomicBool = .init(initialValue: false)
152+
150153
private nonisolated(unsafe) var _isExecuting: AtomicBool = .init(initialValue: false)
151154

152155
/// Whether the task is currently executing or still queued to be executed later.
153156
public nonisolated var isExecuting: Bool {
154157
return _isExecuting.value
155158
}
156159

160+
public nonisolated func cancel() {
161+
resultTask.cancel()
162+
}
163+
157164
/// Wait for the task to finish.
158165
///
159166
/// If the tasks that waits for this queued task to finished is cancelled, the QueuedTask will still continue
@@ -197,31 +204,35 @@ public actor QueuedTask<TaskDescription: TaskDescriptionProtocol> {
197204
self.executionTaskCreatedContinuation = executionTaskCreatedContinuation
198205

199206
self.resultTask = Task.detached(priority: priority) {
200-
await withTaskGroup(of: Void.self) { taskGroup in
201-
taskGroup.addTask {
202-
for await _ in updatePriorityStream {
203-
self.priority = Task.currentPriority
207+
await withTaskCancellationHandler {
208+
await withTaskGroup(of: Void.self) { taskGroup in
209+
taskGroup.addTask {
210+
for await _ in updatePriorityStream {
211+
self.priority = Task.currentPriority
212+
}
204213
}
205-
}
206-
taskGroup.addTask {
207-
for await task in executionTaskCreatedStream {
208-
switch await task.value {
209-
case .cancelledToBeRescheduled:
210-
// Break the switch and wait for a new `executionTask` to be placed into `executionTaskCreatedStream`.
211-
break
212-
case .terminated:
213-
// The task finished. We are done with this `QueuedTask`
214-
return
214+
taskGroup.addTask {
215+
for await task in executionTaskCreatedStream {
216+
switch await task.valuePropagatingCancellation {
217+
case .cancelledToBeRescheduled:
218+
// Break the switch and wait for a new `executionTask` to be placed into `executionTaskCreatedStream`.
219+
break
220+
case .terminated:
221+
// The task finished. We are done with this `QueuedTask`
222+
return
223+
}
215224
}
216225
}
226+
// The first (update priority) task never finishes, so this waits for the second (wait for execution) task
227+
// to terminate.
228+
// Afterwards we also cancel the update priority task.
229+
for await _ in taskGroup {
230+
taskGroup.cancelAll()
231+
return
232+
}
217233
}
218-
// The first (update priority) task never finishes, so this waits for the second (wait for execution) task
219-
// to terminate.
220-
// Afterwards we also cancel the update priority task.
221-
for await _ in taskGroup {
222-
taskGroup.cancelAll()
223-
return
224-
}
234+
} onCancel: {
235+
self.resultTaskCancelled.value = true
225236
}
226237
}
227238
}
@@ -233,7 +244,9 @@ public actor QueuedTask<TaskDescription: TaskDescriptionProtocol> {
233244
func execute() async -> ExecutionTaskFinishStatus {
234245
precondition(executionTask == nil, "Task started twice")
235246
let task = Task.detached(priority: self.priority) {
236-
await self.description.execute()
247+
if !Task.isCancelled && !self.resultTaskCancelled.value {
248+
await self.description.execute()
249+
}
237250
return await self.finalizeExecution()
238251
}
239252
executionTask = task

Sources/SKSupport/LineTable.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
//===----------------------------------------------------------------------===//
1212

1313
import LSPLogging
14+
1415
#if canImport(os)
1516
import os
1617
#endif

Sources/SKSwiftPMWorkspace/SwiftPMBuildSystem.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,9 @@ extension SwiftPMBuildSystem: SKCore.BuildSystem {
489489
"--disable-index-store",
490490
"--target", target.targetID,
491491
]
492+
if Task.isCancelled {
493+
return
494+
}
492495
let process = try Process.launch(arguments: arguments, workingDirectory: nil)
493496
let result = try await process.waitUntilExitSendingSigIntOnTaskCancellation()
494497
switch result.exitStatus.exhaustivelySwitchable {

Sources/SemanticIndex/PreparationTaskDescription.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ public struct PreparationTaskDescription: IndexTaskDescription {
7070
// See comment in `withLoggingScope`.
7171
// The last 2 digits should be sufficient to differentiate between multiple concurrently running preparation operations
7272
await withLoggingScope("preparation-\(id % 100)") {
73+
await testHooks.preparationTaskDidStart?(self)
7374
let targetsToPrepare = await targetsToPrepare.asyncFilter {
7475
await !preparationUpToDateStatus.isUpToDate($0)
7576
}.sorted(by: {

Sources/SemanticIndex/SemanticIndexManager.swift

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,14 @@ public final actor SemanticIndexManager {
8686
/// After the file is indexed, it is removed from this dictionary.
8787
private var inProgressIndexTasks: [DocumentURI: InProgressIndexStore] = [:]
8888

89+
/// The last task that prepares a document for editor functionality.
90+
///
91+
/// This is used so we can cancel preparation tasks for documents that the user is no longer interacting with and
92+
/// avoid the following scenario: The user browses through documents from targets A, B, and C in quick succession. We
93+
/// don't want stack preparation of A, B, and C. Instead we want to only prepare target C - and also finish
94+
/// preparation of A if it has already started when the user opens C.
95+
private var lastPrepareForEditorTask: (document: DocumentURI, task: Task<Void, Never>)? = nil
96+
8997
/// The `TaskScheduler` that manages the scheduling of index tasks. This is shared among all `SemanticIndexManager`s
9098
/// in the process, to ensure that we don't schedule more index operations than processor cores from multiple
9199
/// workspaces.
@@ -287,20 +295,35 @@ public final actor SemanticIndexManager {
287295
/// Schedule preparation of the target that contains the given URI, building all modules that the file depends on.
288296
///
289297
/// This is intended to be called when the user is interacting with the document at the given URI.
290-
public func schedulePreparation(of uri: DocumentURI, priority: TaskPriority? = nil) {
291-
Task(priority: priority) {
298+
public func schedulePreparationForEditorFunctionality(
299+
of uri: DocumentURI,
300+
priority: TaskPriority? = nil
301+
) {
302+
if lastPrepareForEditorTask?.document == uri {
303+
// We are already preparing this document, so nothing to do. This is necessary to avoid the following scenario:
304+
// Determining the canonical configured target for a document takes 1s and we get a new document request for the
305+
// document ever 0.5s, which would cancel the previous in-progress preparation task, cancelling the canonical
306+
// configured target configuration, never actually getting to the actual preparation.
307+
return
308+
}
309+
let task = Task(priority: priority) {
292310
await withLoggingScope("preparation") {
293311
guard let target = await buildSystemManager.canonicalConfiguredTarget(for: uri) else {
294312
return
295313
}
314+
if Task.isCancelled {
315+
return
316+
}
296317
await self.prepare(targets: [target], priority: priority)
297318
}
298319
}
320+
lastPrepareForEditorTask?.task.cancel()
321+
lastPrepareForEditorTask = (uri, task)
299322
}
300323

301324
// MARK: - Helper functions
302325

303-
/// Prepare the given targets for indexing
326+
/// Prepare the given targets for indexing.
304327
private func prepare(targets: [ConfiguredTarget], priority: TaskPriority?) async {
305328
// Perform a quick initial check whether the target is up-to-date, in which case we don't need to schedule a
306329
// preparation operation at all.
@@ -323,6 +346,9 @@ public final actor SemanticIndexManager {
323346
testHooks: testHooks
324347
)
325348
)
349+
if Task.isCancelled {
350+
return
351+
}
326352
let preparationTask = await indexTaskScheduler.schedule(priority: priority, taskDescription) { task, newState in
327353
guard case .finished = newState else {
328354
return
@@ -337,7 +363,17 @@ public final actor SemanticIndexManager {
337363
for target in targetsToPrepare {
338364
inProgressPreparationTasks[target] = OpaqueQueuedIndexTask(preparationTask)
339365
}
340-
return await preparationTask.waitToFinishPropagatingCancellation()
366+
await withTaskCancellationHandler {
367+
return await preparationTask.waitToFinish()
368+
} onCancel: {
369+
// Only cancel the preparation task if it hasn't started executing yet. This ensures that we always make progress
370+
// during preparation and can't get into the following scenario: The user has two target A and B that both take
371+
// 10s to prepare. The user is now switching between the files every 5 seconds, which would always cause
372+
// preparation for one target to get cancelled, never resulting in an up-to-date preparation status.
373+
if !preparationTask.isExecuting {
374+
preparationTask.cancel()
375+
}
376+
}
341377
}
342378

343379
/// Update the index store for the given files, assuming that their targets have already been prepared.

Sources/SemanticIndex/TestHooks.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,19 @@
1212

1313
/// Callbacks that allow inspection of internal state modifications during testing.
1414
public struct IndexTestHooks: Sendable {
15+
public var preparationTaskDidStart: (@Sendable (PreparationTaskDescription) async -> Void)?
16+
1517
public var preparationTaskDidFinish: (@Sendable (PreparationTaskDescription) async -> Void)?
1618

1719
/// A callback that is called when an index task finishes.
1820
public var updateIndexStoreTaskDidFinish: (@Sendable (UpdateIndexStoreTaskDescription) async -> Void)?
1921

2022
public init(
23+
preparationTaskDidStart: (@Sendable (PreparationTaskDescription) async -> Void)? = nil,
2124
preparationTaskDidFinish: (@Sendable (PreparationTaskDescription) async -> Void)? = nil,
2225
updateIndexStoreTaskDidFinish: (@Sendable (UpdateIndexStoreTaskDescription) async -> Void)? = nil
2326
) {
27+
self.preparationTaskDidStart = preparationTaskDidStart
2428
self.preparationTaskDidFinish = preparationTaskDidFinish
2529
self.updateIndexStoreTaskDidFinish = updateIndexStoreTaskDidFinish
2630
}

Sources/SemanticIndex/UpdateIndexStoreTaskDescription.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,9 @@ public struct UpdateIndexStoreTaskDescription: IndexTaskDescription {
301301
processArguments: [String],
302302
workingDirectory: AbsolutePath?
303303
) async throws {
304+
if Task.isCancelled {
305+
return
306+
}
304307
let process = try Process.launch(
305308
arguments: processArguments,
306309
workingDirectory: workingDirectory

Sources/SourceKitLSP/SourceKitLSPServer.swift

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1000,7 +1000,9 @@ extension SourceKitLSPServer: MessageHandler {
10001000
// which prepares the files. For files that are open but aren't being worked on (eg. a different tab), we don't
10011001
// get requests, ensuring that we don't unnecessarily prepare them.
10021002
let workspace = await self.workspaceForDocument(uri: textDocumentRequest.textDocument.uri)
1003-
await workspace?.semanticIndexManager?.schedulePreparation(of: textDocumentRequest.textDocument.uri)
1003+
await workspace?.semanticIndexManager?.schedulePreparationForEditorFunctionality(
1004+
of: textDocumentRequest.textDocument.uri
1005+
)
10041006
}
10051007

10061008
switch request {
@@ -1543,7 +1545,7 @@ extension SourceKitLSPServer {
15431545
)
15441546
return
15451547
}
1546-
await workspace.semanticIndexManager?.schedulePreparation(of: uri)
1548+
await workspace.semanticIndexManager?.schedulePreparationForEditorFunctionality(of: uri)
15471549
await openDocument(notification, workspace: workspace)
15481550
}
15491551

@@ -1599,7 +1601,7 @@ extension SourceKitLSPServer {
15991601
)
16001602
return
16011603
}
1602-
await workspace.semanticIndexManager?.schedulePreparation(of: uri)
1604+
await workspace.semanticIndexManager?.schedulePreparationForEditorFunctionality(of: uri)
16031605

16041606
// If the document is ready, we can handle the change right now.
16051607
documentManager.edit(notification)

0 commit comments

Comments
 (0)