Skip to content

Commit bb1529c

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 0e58ab1 commit bb1529c

File tree

9 files changed

+250
-43
lines changed

9 files changed

+250
-43
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: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,17 @@ 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 currently running 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+
///
96+
/// `id` is a unique ID that identifies the preparation task and is used to set `inProgressPrepareForEditorTask` to
97+
/// `nil` when the current in progress task finishes.
98+
private var inProgressPrepareForEditorTask: (id: UUID, document: DocumentURI, task: Task<Void, Never>)? = nil
99+
89100
/// The `TaskScheduler` that manages the scheduling of index tasks. This is shared among all `SemanticIndexManager`s
90101
/// in the process, to ensure that we don't schedule more index operations than processor cores from multiple
91102
/// workspaces.
@@ -287,20 +298,39 @@ public final actor SemanticIndexManager {
287298
/// Schedule preparation of the target that contains the given URI, building all modules that the file depends on.
288299
///
289300
/// 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) {
301+
public func schedulePreparationForEditorFunctionality(
302+
of uri: DocumentURI,
303+
priority: TaskPriority? = nil
304+
) {
305+
if inProgressPrepareForEditorTask?.document == uri {
306+
// We are already preparing this document, so nothing to do. This is necessary to avoid the following scenario:
307+
// Determining the canonical configured target for a document takes 1s and we get a new document request for the
308+
// document ever 0.5s, which would cancel the previous in-progress preparation task, cancelling the canonical
309+
// configured target configuration, never actually getting to the actual preparation.
310+
return
311+
}
312+
let id = UUID()
313+
let task = Task(priority: priority) {
292314
await withLoggingScope("preparation") {
293315
guard let target = await buildSystemManager.canonicalConfiguredTarget(for: uri) else {
294316
return
295317
}
318+
if Task.isCancelled {
319+
return
320+
}
296321
await self.prepare(targets: [target], priority: priority)
322+
if inProgressPrepareForEditorTask?.id == id {
323+
inProgressPrepareForEditorTask = nil
324+
}
297325
}
298326
}
327+
inProgressPrepareForEditorTask?.task.cancel()
328+
inProgressPrepareForEditorTask = (id, uri, task)
299329
}
300330

301331
// MARK: - Helper functions
302332

303-
/// Prepare the given targets for indexing
333+
/// Prepare the given targets for indexing.
304334
private func prepare(targets: [ConfiguredTarget], priority: TaskPriority?) async {
305335
// Perform a quick initial check whether the target is up-to-date, in which case we don't need to schedule a
306336
// preparation operation at all.
@@ -323,6 +353,9 @@ public final actor SemanticIndexManager {
323353
testHooks: testHooks
324354
)
325355
)
356+
if Task.isCancelled {
357+
return
358+
}
326359
let preparationTask = await indexTaskScheduler.schedule(priority: priority, taskDescription) { task, newState in
327360
guard case .finished = newState else {
328361
return
@@ -337,7 +370,17 @@ public final actor SemanticIndexManager {
337370
for target in targetsToPrepare {
338371
inProgressPreparationTasks[target] = OpaqueQueuedIndexTask(preparationTask)
339372
}
340-
return await preparationTask.waitToFinishPropagatingCancellation()
373+
await withTaskCancellationHandler {
374+
return await preparationTask.waitToFinish()
375+
} onCancel: {
376+
// Only cancel the preparation task if it hasn't started executing yet. This ensures that we always make progress
377+
// during preparation and can't get into the following scenario: The user has two target A and B that both take
378+
// 10s to prepare. The user is now switching between the files every 5 seconds, which would always cause
379+
// preparation for one target to get cancelled, never resulting in an up-to-date preparation status.
380+
if !preparationTask.isExecuting {
381+
preparationTask.cancel()
382+
}
383+
}
341384
}
342385

343386
/// 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)