Skip to content

Commit b3ac424

Browse files
authored
Merge pull request #1986 from ahoppen/pause-background-indexing
Add an experimental request to pause background indexing
2 parents 82bafea + f1aeb6c commit b3ac424

File tree

13 files changed

+391
-18
lines changed

13 files changed

+391
-18
lines changed

Contributor Documentation/LSP Extensions.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,13 +451,38 @@ logName?: string;
451451
452452
New request to wait until the index is up-to-date.
453453
454+
> [!IMPORTANT]
455+
> This request is experimental and may be modified or removed in future versions of SourceKit-LSP without notice. Do not rely on it.
456+
454457
- params: `PollIndexParams`
455458
- result: `void`
456459
457460
```ts
458461
export interface PollIndexParams {}
459462
```
460463
464+
## `workspace/_setOptions`
465+
466+
New request to modify runtime options of SourceKit-LSP.
467+
468+
Any options not specified in this request will be left as-is.
469+
470+
> [!IMPORTANT]
471+
> This request is experimental, guarded behind the `set-options-request` experimental feature, and may be modified or removed in future versions of SourceKit-LSP without notice. Do not rely on it.
472+
473+
- params: `SetOptionsParams`
474+
- result: `void`
475+
476+
```ts
477+
export interface SetOptionsParams {
478+
/**
479+
* `true` to pause background indexing or `false` to resume background indexing.
480+
*/
481+
backgroundIndexingPaused?: bool;
482+
}
483+
```
484+
485+
461486
## `workspace/getReferenceDocument`
462487
463488
Request from the client to the server asking for contents of a URI having a custom scheme.

Documentation/Configuration File.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ The structure of the file is currently not guaranteed to be stable. Options may
5555
- `noLazy`: Prepare a target without generating object files but do not do lazy type checking and function body skipping. This uses SwiftPM's `--experimental-prepare-for-indexing-no-lazy` flag.
5656
- `enabled`: Prepare a target without generating object files.
5757
- `cancelTextDocumentRequestsOnEditAndClose: boolean`: Whether sending a `textDocument/didChange` or `textDocument/didClose` notification for a document should cancel all pending requests for that document.
58-
- `experimentalFeatures: ("on-type-formatting")[]`: Experimental features that are enabled.
58+
- `experimentalFeatures: ("on-type-formatting"|"set-options-request")[]`: Experimental features that are enabled.
5959
- `swiftPublishDiagnosticsDebounceDuration: number`: The time that `SwiftLanguageService` should wait after an edit before starting to compute diagnostics and sending a `PublishDiagnosticsNotification`.
6060
- `workDoneProgressDebounceDuration: number`: When a task is started that should be displayed to the client as a work done progress, how many milliseconds to wait before actually starting the work done progress. This prevents flickering of the work done progress in the client for short-lived index tasks which end within this duration.
6161
- `sourcekitdRequestTimeout: number`: The maximum duration that a sourcekitd request should be allowed to execute before being declared as timed out. In general, editors should cancel requests that they are no longer interested in, but in case editors don't cancel requests, this ensures that a long-running non-cancelled request is not blocking sourcekitd and thus most semantic functionality. In particular, VS Code does not cancel the semantic tokens request, which can cause a long-running AST build that blocks sourcekitd.

Sources/LanguageServerProtocol/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ add_library(LanguageServerProtocol STATIC
7575
Requests/RegisterCapabilityRequest.swift
7676
Requests/RenameRequest.swift
7777
Requests/SelectionRangeRequest.swift
78+
Requests/SetOptionsRequest.swift
7879
Requests/ShowDocumentRequest.swift
7980
Requests/ShowMessageRequest.swift
8081
Requests/ShutdownRequest.swift

Sources/LanguageServerProtocol/Messages.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ public let builtinRequests: [_RequestType.Type] = [
6767
RegisterCapabilityRequest.self,
6868
RenameRequest.self,
6969
SelectionRangeRequest.self,
70+
SetOptionsRequest.self,
7071
ShowDocumentRequest.self,
7172
ShowMessageRequest.self,
7273
ShutdownRequest.self,
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
/// New request to modify runtime options of SourceKit-LSP.
14+
//
15+
/// Any options not specified in this request will be left as-is.
16+
public struct SetOptionsRequest: RequestType {
17+
public static let method: String = "workspace/_setOptions"
18+
public typealias Response = VoidResponse
19+
20+
/// `true` to pause background indexing or `false` to resume background indexing.
21+
public var backgroundIndexingPaused: Bool?
22+
23+
public init(backgroundIndexingPaused: Bool?) {
24+
self.backgroundIndexingPaused = backgroundIndexingPaused
25+
}
26+
}

Sources/SKOptions/ExperimentalFeatures.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,9 @@
1414
/// to `sourcekit-lsp` on the command line or through the configuration file.
1515
/// The raw value of this feature is how it is named on the command line and in the configuration file.
1616
public enum ExperimentalFeature: String, Codable, Sendable, CaseIterable {
17+
/// Enable support for the `textDocument/onTypeFormatting` request.
1718
case onTypeFormatting = "on-type-formatting"
19+
20+
/// Enable support for the `workspace/_setOptions` request.
21+
case setOptionsRequest = "set-options-request"
1822
}

Sources/SKTestSupport/Assertions.swift

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -134,13 +134,13 @@ package func unwrap<T>(
134134
return try XCTUnwrap(expression, file: file, line: line)
135135
}
136136

137-
private struct ExpectationNotFulfilledError: Error, CustomStringConvertible {
138-
var expecatations: [XCTestExpectation]
137+
package struct ExpectationNotFulfilledError: Error, CustomStringConvertible {
138+
var expectations: [XCTestExpectation]
139139

140-
var description: String {
140+
package var description: String {
141141
return """
142142
One of the expectation was not fulfilled within timeout: \
143-
\(expecatations.map(\.description).joined(separator: ", "))
143+
\(expectations.map(\.description).joined(separator: ", "))
144144
"""
145145
}
146146
}
@@ -162,6 +162,6 @@ package nonisolated func fulfillmentOfOrThrow(
162162
enforceOrder: enforceOrderOfFulfillment
163163
)
164164
if started != .completed {
165-
throw ExpectationNotFulfilledError(expecatations: expectations)
165+
throw ExpectationNotFulfilledError(expectations: expectations)
166166
}
167167
}

Sources/SemanticIndex/TaskScheduler.swift

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188,9 +188,10 @@ package actor QueuedTask<TaskDescription: TaskDescriptionProtocol> {
188188
/// execution.
189189
private let executionStateChangedCallback: (@Sendable (QueuedTask, TaskExecutionState) async -> Void)?
190190

191-
init(
191+
fileprivate init(
192192
priority: TaskPriority,
193193
description: TaskDescription,
194+
taskPriorityChangedCallback: @escaping @Sendable (_ newPriority: TaskPriority) -> Void,
194195
executionStateChangedCallback: (@Sendable (QueuedTask, TaskExecutionState) async -> Void)?
195196
) async {
196197
self._priority = AtomicUInt8(initialValue: priority.rawValue)
@@ -223,6 +224,7 @@ package actor QueuedTask<TaskDescription: TaskDescriptionProtocol> {
223224
)
224225
}
225226
self.priority = Task.currentPriority
227+
taskPriorityChangedCallback(self.priority)
226228
}
227229
} onCancel: {
228230
self.resultTaskCancelled.value = true
@@ -349,13 +351,66 @@ package actor TaskScheduler<TaskDescription: TaskDescriptionProtocol> {
349351
/// - `.medium`: 4
350352
/// - `.low`: 2
351353
/// - `.background`: 2
352-
private let maxConcurrentTasksByPriority: [(priority: TaskPriority, maxConcurrentTasks: Int)]
354+
private var maxConcurrentTasksByPriority: [(priority: TaskPriority, maxConcurrentTasks: Int)] {
355+
didSet {
356+
// These preconditions need to match the ones in `init`
357+
maxConcurrentTasksByPriority = maxConcurrentTasksByPriority.sorted(by: { $0.priority > $1.priority })
358+
precondition(maxConcurrentTasksByPriority.allSatisfy { $0.maxConcurrentTasks >= 0 })
359+
precondition(maxConcurrentTasksByPriority.map(\.maxConcurrentTasks).isSorted(descending: true))
360+
precondition(!maxConcurrentTasksByPriority.isEmpty)
361+
362+
// Check we are over-subscribed in currently executing tasks. If we are, cancel currently executing task to be
363+
// rescheduled until we are within the new limit.
364+
var tasksToReschedule: [QueuedTask<TaskDescription>] = []
365+
for (priority, maxConcurrentTasks) in maxConcurrentTasksByPriority {
366+
var tasksInPrioritySlot = currentlyExecutingTasks.filter { $0.priority <= priority }
367+
if tasksInPrioritySlot.count <= maxConcurrentTasks {
368+
// We have enough available slots. Nothing to do.
369+
continue
370+
}
371+
tasksInPrioritySlot = tasksInPrioritySlot.sorted { $0.priority > $1.priority }
372+
while tasksInPrioritySlot.count > maxConcurrentTasks {
373+
// Cancel the task with the lowest priority (because it is least important and also takes a slot in the lower
374+
// priority execution buckets) and the among those the most recent one because it has probably made the least
375+
// progress.
376+
guard let mostRecentTaskInSlot = tasksInPrioritySlot.popLast() else {
377+
// Should never happen because `tasksInPrioritySlot.count > maxConcurrentTasks >= 0`
378+
logger.fault("Unexpectedly unable to pop last task from tasksInPrioritySlot")
379+
break
380+
}
381+
tasksToReschedule.append(mostRecentTaskInSlot)
382+
}
383+
}
384+
385+
// Poke the scheduler to schedule new jobs if new execution slots became available.
386+
poke()
387+
388+
// Cancel any tasks that didn't fit into the new execution slots anymore. Do this on a separate task is fine
389+
// because even if we extend the number of execution slots before the task gets executed (which is unlikely), we
390+
// would cancel the tasks and then immediately reschedule it – while that's doing unnecessary work, it's still
391+
// correct.
392+
Task.detached(priority: .high) {
393+
for tasksToReschedule in tasksToReschedule {
394+
await tasksToReschedule.cancelToBeRescheduled()
395+
}
396+
}
397+
}
398+
}
399+
400+
/// Modify the number of tasks that are allowed to run concurrently at each priority level.
401+
///
402+
/// If there are more tasks executing currently that fit within the new execution limits, tasks will be cancelled and
403+
/// rescheduled again when execution slots become available.
404+
package func setMaxConcurrentTasksByPriority(_ newValue: [(priority: TaskPriority, maxConcurrentTasks: Int)]) {
405+
self.maxConcurrentTasksByPriority = newValue
406+
}
353407

354408
package init(maxConcurrentTasksByPriority: [(priority: TaskPriority, maxConcurrentTasks: Int)]) {
409+
// These preconditions need to match the ones in `maxConcurrentTasksByPriority:didSet`
355410
self.maxConcurrentTasksByPriority = maxConcurrentTasksByPriority.sorted(by: { $0.priority > $1.priority })
411+
precondition(maxConcurrentTasksByPriority.allSatisfy { $0.maxConcurrentTasks >= 0 })
356412
precondition(maxConcurrentTasksByPriority.map(\.maxConcurrentTasks).isSorted(descending: true))
357413
precondition(!maxConcurrentTasksByPriority.isEmpty)
358-
precondition(maxConcurrentTasksByPriority.last!.maxConcurrentTasks >= 1)
359414
}
360415

361416
/// Enqueue a new task to be executed.
@@ -374,6 +429,13 @@ package actor TaskScheduler<TaskDescription: TaskDescriptionProtocol> {
374429
let queuedTask = await QueuedTask(
375430
priority: priority ?? Task.currentPriority,
376431
description: taskDescription,
432+
taskPriorityChangedCallback: { [weak self] (newPriority) in
433+
Task.detached(priority: newPriority) {
434+
// If the task's priority got elevated, there might be an execution slot for it now. Poke the scheduler
435+
// to run the task if possible.
436+
await self?.poke()
437+
}
438+
},
377439
executionStateChangedCallback: executionStateChangedCallback
378440
)
379441
pendingTasks.append(queuedTask)

Sources/SourceKitLSP/MessageHandlingDependencyTracker.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,10 @@ package enum MessageHandlingDependencyTracker: QueueBasedMessageHandlerDependenc
211211
self = .globalConfigurationChange
212212
case is RegisterCapabilityRequest:
213213
self = .globalConfigurationChange
214+
case is SetOptionsRequest:
215+
// The request does not modify any global state in an observable way, so we can treat it as a freestanding
216+
// request.
217+
self = .freestanding
214218
case is ShowMessageRequest:
215219
self = .freestanding
216220
case is ShutdownRequest:

Sources/SourceKitLSP/SourceKitLSPServer.swift

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,26 @@ package actor SourceKitLSPServer {
159159
/// The files that we asked the client to watch.
160160
private var watchers: Set<FileSystemWatcher> = []
161161

162+
private static func maxConcurrentIndexingTasksByPriority(
163+
isIndexingPaused: Bool,
164+
options: SourceKitLSPOptions
165+
) -> [(priority: TaskPriority, maxConcurrentTasks: Int)] {
166+
let processorCount = ProcessInfo.processInfo.processorCount
167+
let lowPriorityCores =
168+
if isIndexingPaused {
169+
0
170+
} else {
171+
max(
172+
Int(options.indexOrDefault.maxCoresPercentageToUseForBackgroundIndexingOrDefault * Double(processorCount)),
173+
1
174+
)
175+
}
176+
return [
177+
(TaskPriority.medium, processorCount),
178+
(TaskPriority.low, lowPriorityCores),
179+
]
180+
}
181+
162182
/// Creates a language server for the given client.
163183
package init(
164184
client: Connection,
@@ -173,13 +193,9 @@ package actor SourceKitLSPServer {
173193
self.onExit = onExit
174194

175195
self.client = client
176-
let processorCount = ProcessInfo.processInfo.processorCount
177-
let lowPriorityCores =
178-
options.indexOrDefault.maxCoresPercentageToUseForBackgroundIndexingOrDefault * Double(processorCount)
179-
self.indexTaskScheduler = TaskScheduler(maxConcurrentTasksByPriority: [
180-
(TaskPriority.medium, processorCount),
181-
(TaskPriority.low, max(Int(lowPriorityCores), 1)),
182-
])
196+
self.indexTaskScheduler = TaskScheduler(
197+
maxConcurrentTasksByPriority: Self.maxConcurrentIndexingTasksByPriority(isIndexingPaused: false, options: options)
198+
)
183199
self.indexProgressManager = nil
184200
#if canImport(SwiftDocC)
185201
self.documentationManager = nil
@@ -822,6 +838,8 @@ extension SourceKitLSPServer: QueueBasedMessageHandler {
822838
await self.handleRequest(for: request, requestHandler: self.references)
823839
case let request as RequestAndReply<RenameRequest>:
824840
await request.reply { try await rename(request.params) }
841+
case let request as RequestAndReply<SetOptionsRequest>:
842+
await request.reply { try await self.setBackgroundIndexingPaused(request.params) }
825843
case let request as RequestAndReply<ShutdownRequest>:
826844
await request.reply { try await shutdown(request.params) }
827845
case let request as RequestAndReply<SymbolInfoRequest>:
@@ -1434,6 +1452,19 @@ extension SourceKitLSPServer {
14341452
}
14351453
}
14361454

1455+
func setBackgroundIndexingPaused(_ request: SetOptionsRequest) async throws -> VoidResponse {
1456+
guard self.options.hasExperimentalFeature(.setOptionsRequest) else {
1457+
throw ResponseError.unknown("Pausing background indexing is an experimental feature")
1458+
}
1459+
if let backgroundIndexingPaused = request.backgroundIndexingPaused {
1460+
await self.indexTaskScheduler.setMaxConcurrentTasksByPriority(
1461+
Self.maxConcurrentIndexingTasksByPriority(isIndexingPaused: backgroundIndexingPaused, options: self.options)
1462+
)
1463+
}
1464+
1465+
return VoidResponse()
1466+
}
1467+
14371468
// MARK: - Language features
14381469

14391470
func completion(

0 commit comments

Comments
 (0)