Skip to content

Commit dd9c0af

Browse files
authored
Merge pull request #1629 from ahoppen/implicit-request-cancellation
Implicitly cancel text document requests when the document is edited or closed
2 parents 32b413e + 1e409c9 commit dd9c0af

File tree

6 files changed

+148
-13
lines changed

6 files changed

+148
-13
lines changed

Documentation/Configuration File.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,5 +44,6 @@ The structure of the file is currently not guaranteed to be stable. Options may
4444
- `generatedFilesPath: string`: Directory in which generated interfaces and macro expansions should be stored.
4545
- `backgroundIndexing: bool`: Explicitly enable or disable background indexing.
4646
- `backgroundPreparationMode: "build"|"noLazy"|"enabled"`: Determines how background indexing should prepare a target. Possible values are: `build`: Build a target to prepare it, `noLazy`: Prepare a target without generating object files but do not do lazy type checking and function body skipping, `enabled`: Prepare a target without generating object files and the like
47+
- `cancelTextDocumentRequestsOnEditAndClose: bool`: Whether sending a `textDocument/didChange` or `textDocument/didClose` notification for a document should cancel all pending requests for that document.
4748
- `experimentalFeatures: string[]`: Experimental features to enable
4849
- `swiftPublishDiagnosticsDebounce`: The time that `SwiftLanguageService` should wait after an edit before starting to compute diagnostics and sending a `PublishDiagnosticsNotification`.

Sources/SKOptions/SourceKitLSPOptions.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,14 @@ public struct SourceKitLSPOptions: Sendable, Codable, CustomLogStringConvertible
286286
return .build
287287
}
288288

289+
/// Whether sending a `textDocument/didChange` or `textDocument/didClose` notification for a document should cancel
290+
/// all pending requests for that document.
291+
public var cancelTextDocumentRequestsOnEditAndClose: Bool? = nil
292+
293+
public var cancelTextDocumentRequestsOnEditAndCloseOrDefault: Bool {
294+
return cancelTextDocumentRequestsOnEditAndClose ?? true
295+
}
296+
289297
/// Experimental features that are enabled.
290298
public var experimentalFeatures: Set<ExperimentalFeature>? = nil
291299

@@ -343,6 +351,7 @@ public struct SourceKitLSPOptions: Sendable, Codable, CustomLogStringConvertible
343351
generatedFilesPath: String? = nil,
344352
backgroundIndexing: Bool? = nil,
345353
backgroundPreparationMode: String? = nil,
354+
cancelTextDocumentRequestsOnEditAndClose: Bool? = nil,
346355
experimentalFeatures: Set<ExperimentalFeature>? = nil,
347356
swiftPublishDiagnosticsDebounceDuration: Double? = nil,
348357
workDoneProgressDebounceDuration: Double? = nil,
@@ -358,6 +367,7 @@ public struct SourceKitLSPOptions: Sendable, Codable, CustomLogStringConvertible
358367
self.defaultWorkspaceType = defaultWorkspaceType
359368
self.backgroundIndexing = backgroundIndexing
360369
self.backgroundPreparationMode = backgroundPreparationMode
370+
self.cancelTextDocumentRequestsOnEditAndClose = cancelTextDocumentRequestsOnEditAndClose
361371
self.experimentalFeatures = experimentalFeatures
362372
self.swiftPublishDiagnosticsDebounceDuration = swiftPublishDiagnosticsDebounceDuration
363373
self.workDoneProgressDebounceDuration = workDoneProgressDebounceDuration
@@ -412,6 +422,8 @@ public struct SourceKitLSPOptions: Sendable, Codable, CustomLogStringConvertible
412422
generatedFilesPath: override?.generatedFilesPath ?? base.generatedFilesPath,
413423
backgroundIndexing: override?.backgroundIndexing ?? base.backgroundIndexing,
414424
backgroundPreparationMode: override?.backgroundPreparationMode ?? base.backgroundPreparationMode,
425+
cancelTextDocumentRequestsOnEditAndClose: override?.cancelTextDocumentRequestsOnEditAndClose
426+
?? base.cancelTextDocumentRequestsOnEditAndClose,
415427
experimentalFeatures: override?.experimentalFeatures ?? base.experimentalFeatures,
416428
swiftPublishDiagnosticsDebounceDuration: override?.swiftPublishDiagnosticsDebounceDuration
417429
?? base.swiftPublishDiagnosticsDebounceDuration,

Sources/SourceKitLSP/SourceKitLSPServer.swift

Lines changed: 62 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,10 @@ package actor SourceKitLSPServer {
177177
/// The requests that we are currently handling.
178178
///
179179
/// Used to cancel the tasks if the client requests cancellation.
180-
private var inProgressRequests: [RequestID: Task<(), Never>] = [:]
180+
private var inProgressRequestsByID: [RequestID: Task<(), Never>] = [:]
181+
182+
/// For all currently handled text document requests a mapping from the document to the corresponding request ID.
183+
private var inProgressTextDocumentRequests: [DocumentURI: Set<RequestID>] = [:]
181184

182185
/// Up to 10 request IDs that have recently finished.
183186
///
@@ -187,14 +190,22 @@ package actor SourceKitLSPServer {
187190

188191
/// - Note: Needed so we can set an in-progress request from a different
189192
/// isolation context.
190-
private func setInProgressRequest(for id: RequestID, task: Task<(), Never>?) {
191-
self.inProgressRequests[id] = task
193+
private func setInProgressRequest(for id: RequestID, _ request: some RequestType, task: Task<(), Never>?) {
194+
self.inProgressRequestsByID[id] = task
192195
if task == nil {
193196
recentlyFinishedRequests.append(id)
194197
while recentlyFinishedRequests.count > 10 {
195198
recentlyFinishedRequests.removeFirst()
196199
}
197200
}
201+
202+
if let request = request as? any TextDocumentRequest {
203+
if task == nil {
204+
inProgressTextDocumentRequests[request.textDocument.uri, default: []].remove(id)
205+
} else {
206+
inProgressTextDocumentRequests[request.textDocument.uri, default: []].insert(id)
207+
}
208+
}
198209
}
199210

200211
var onExit: () -> Void
@@ -547,12 +558,19 @@ extension SourceKitLSPServer: MessageHandler {
547558
package nonisolated func handle(_ params: some NotificationType) {
548559
let notificationID = notificationIDForLogging.fetchAndIncrement()
549560
withLoggingScope("notification-\(notificationID % 100)") {
550-
if let params = params as? CancelRequestNotification {
551-
// Request cancellation needs to be able to overtake any other message we
552-
// are currently handling. Ordering is not important here. We thus don't
553-
// need to execute it on `messageHandlingQueue`.
561+
// Request cancellation needs to be able to overtake any other message we
562+
// are currently handling. Ordering is not important here. We thus don't
563+
// need to execute it on `messageHandlingQueue`.
564+
switch params {
565+
case let params as CancelRequestNotification:
554566
self.cancelRequest(params)
555567
return
568+
case let params as DidChangeTextDocumentNotification:
569+
self.cancelTextDocumentRequests(for: params.textDocument.uri)
570+
case let params as DidCloseTextDocumentNotification:
571+
self.cancelTextDocumentRequests(for: params.textDocument.uri)
572+
default:
573+
break
556574
}
557575

558576
let signposter = Logger(subsystem: LoggingScope.subsystem, category: "message-handling")
@@ -617,6 +635,7 @@ extension SourceKitLSPServer: MessageHandler {
617635
// The last 2 digits should be sufficient to differentiate between multiple concurrently running requests.
618636
await withLoggingScope("request-\(id.numericValue % 100)") {
619637
await withTaskCancellationHandler {
638+
await self.testHooks.handleRequest?(params)
620639
await self.handleImpl(params, id: id, reply: reply)
621640
signposter.endInterval("Request", state, "Done")
622641
} onCancel: {
@@ -626,14 +645,14 @@ extension SourceKitLSPServer: MessageHandler {
626645
// We have handled the request and can't cancel it anymore.
627646
// Stop keeping track of it to free the memory.
628647
self.cancellationMessageHandlingQueue.async(priority: .background) {
629-
await self.setInProgressRequest(for: id, task: nil)
648+
await self.setInProgressRequest(for: id, params, task: nil)
630649
}
631650
}
632651
// Keep track of the ID -> Task management with low priority. Once we cancel
633652
// a request, the cancellation task runs with a high priority and depends on
634653
// this task, which will elevate this task's priority.
635654
cancellationMessageHandlingQueue.async(priority: .background) {
636-
await self.setInProgressRequest(for: id, task: task)
655+
await self.setInProgressRequest(for: id, params, task: task)
637656
}
638657
}
639658

@@ -1222,11 +1241,11 @@ extension SourceKitLSPServer {
12221241
// Nothing to do.
12231242
}
12241243

1225-
nonisolated func cancelRequest(_ notification: CancelRequestNotification) {
1244+
private nonisolated func cancelRequest(_ notification: CancelRequestNotification) {
12261245
// Since the request is very cheap to execute and stops other requests
12271246
// from performing more work, we execute it with a high priority.
12281247
cancellationMessageHandlingQueue.async(priority: .high) {
1229-
if let task = await self.inProgressRequests[notification.id] {
1248+
if let task = await self.inProgressRequestsByID[notification.id] {
12301249
task.cancel()
12311250
return
12321251
}
@@ -1238,6 +1257,38 @@ extension SourceKitLSPServer {
12381257
}
12391258
}
12401259

1260+
/// Cancel all in-progress text document requests for the given document.
1261+
///
1262+
/// As a user makes an edit to a file, these requests are most likely no longer relevant. It also makes sure that a
1263+
/// long-running sourcekitd request can't block the entire language server if the client does not cancel all requests.
1264+
/// For example, consider the following sequence of requests:
1265+
/// - `textDocument/semanticTokens/full` for document A
1266+
/// - `textDocument/didChange` for document A
1267+
/// - `textDocument/formatting` for document A
1268+
///
1269+
/// If the editor is not cancelling the semantic tokens request on edit (like VS Code does), then the `didChange`
1270+
/// notification is blocked on the semantic tokens request finishing. Hence, we also can't run the
1271+
/// `textDocument/formatting` request. Cancelling the semantic tokens on the edit fixes the issue.
1272+
///
1273+
/// This method is a no-op if `cancelTextDocumentRequestsOnEditAndClose` is disabled.
1274+
private nonisolated func cancelTextDocumentRequests(for uri: DocumentURI) {
1275+
// Since the request is very cheap to execute and stops other requests
1276+
// from performing more work, we execute it with a high priority.
1277+
cancellationMessageHandlingQueue.async(priority: .high) {
1278+
await self.cancelTextDocumentRequestsImpl(for: uri)
1279+
}
1280+
}
1281+
1282+
private func cancelTextDocumentRequestsImpl(for uri: DocumentURI) {
1283+
guard self.options.cancelTextDocumentRequestsOnEditAndCloseOrDefault else {
1284+
return
1285+
}
1286+
for requestID in self.inProgressTextDocumentRequests[uri, default: []] {
1287+
logger.info("Implicitly cancelling request \(requestID)")
1288+
self.inProgressRequestsByID[requestID]?.cancel()
1289+
}
1290+
}
1291+
12411292
/// The server is about to exit, and the server should flush any buffered state.
12421293
///
12431294
/// The server shall not be used to handle more requests (other than possibly

Sources/SourceKitLSP/Swift/SemanticTokens.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ extension SwiftLanguageService {
3636
return nil
3737
}
3838

39+
try Task.checkCancellation()
40+
3941
return SyntaxHighlightingTokenParser(sourcekitd: sourcekitd).parseTokens(skTokens, in: snapshot)
4042
}
4143

@@ -51,6 +53,8 @@ extension SwiftLanguageService {
5153
for snapshot: DocumentSnapshot,
5254
in range: Range<Position>? = nil
5355
) async throws -> SyntaxHighlightingTokens {
56+
try Task.checkCancellation()
57+
5458
async let tree = syntaxTreeManager.syntaxTree(for: snapshot)
5559
let semanticTokens = await orLog("Loading semantic tokens") { try await semanticHighlightingTokens(for: snapshot) }
5660

@@ -61,12 +65,16 @@ extension SwiftLanguageService {
6165
await tree.range
6266
}
6367

68+
try Task.checkCancellation()
69+
6470
let tokens =
6571
await tree
6672
.classifications(in: range)
6773
.map { $0.highlightingTokens(in: snapshot) }
6874
.reduce(into: SyntaxHighlightingTokens(tokens: [])) { $0.tokens += $1.tokens }
6975

76+
try Task.checkCancellation()
77+
7078
return
7179
tokens
7280
.mergingTokens(with: semanticTokens ?? SyntaxHighlightingTokens(tokens: []))

Sources/SourceKitLSP/TestHooks.swift

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,22 @@ public struct TestHooks: Sendable {
2525

2626
package var swiftpmTestHooks: SwiftPMTestHooks
2727

28+
/// A hook that will be executed before a request is handled.
29+
///
30+
/// This allows requests to be artificially delayed.
31+
package var handleRequest: (@Sendable (any RequestType) async -> Void)?
32+
2833
public init() {
29-
self.init(indexTestHooks: IndexTestHooks(), swiftpmTestHooks: SwiftPMTestHooks())
34+
self.init(indexTestHooks: IndexTestHooks(), swiftpmTestHooks: SwiftPMTestHooks(), handleRequest: nil)
3035
}
3136

3237
package init(
3338
indexTestHooks: IndexTestHooks = IndexTestHooks(),
34-
swiftpmTestHooks: SwiftPMTestHooks = SwiftPMTestHooks()
39+
swiftpmTestHooks: SwiftPMTestHooks = SwiftPMTestHooks(),
40+
handleRequest: (@Sendable (any RequestType) async -> Void)? = nil
3541
) {
3642
self.indexTestHooks = indexTestHooks
3743
self.swiftpmTestHooks = swiftpmTestHooks
44+
self.handleRequest = handleRequest
3845
}
3946
}

Tests/SourceKitLSPTests/SemanticTokensTests.swift

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

1313
import LanguageServerProtocol
14+
import SKOptions
1415
import SKSupport
1516
import SKTestSupport
1617
import SourceKitD
@@ -943,6 +944,61 @@ final class SemanticTokensTests: XCTestCase {
943944
]
944945
)
945946
}
947+
948+
func testImplicitCancellationOnEdit() async throws {
949+
let testClient = try await TestSourceKitLSPClient(
950+
testHooks: TestHooks(handleRequest: { request in
951+
if request is DocumentSemanticTokensRequest {
952+
// Sleep long enough for the edit to be handled
953+
try? await Task.sleep(for: .seconds(10))
954+
}
955+
})
956+
)
957+
let uri = DocumentURI(for: .swift)
958+
let positions = testClient.openDocument("1️⃣", uri: uri)
959+
960+
let receivedSemanticTokensResponse = self.expectation(description: "Received semantic tokens response")
961+
testClient.send(DocumentSemanticTokensRequest(textDocument: TextDocumentIdentifier(uri))) { result in
962+
XCTAssertEqual(result, .failure(ResponseError.cancelled))
963+
receivedSemanticTokensResponse.fulfill()
964+
}
965+
testClient.send(
966+
DidChangeTextDocumentNotification(
967+
textDocument: VersionedTextDocumentIdentifier(uri, version: 2),
968+
contentChanges: [TextDocumentContentChangeEvent(range: Range(positions["1️⃣"]), text: "let x = 1")]
969+
)
970+
)
971+
try await fulfillmentOfOrThrow([receivedSemanticTokensResponse])
972+
}
973+
974+
func testNoImplicitCancellationOnEditIfImplicitCancellationIsDisabled() async throws {
975+
try SkipUnless.longTestsEnabled()
976+
977+
let testClient = try await TestSourceKitLSPClient(
978+
options: SourceKitLSPOptions(cancelTextDocumentRequestsOnEditAndClose: false),
979+
testHooks: TestHooks(handleRequest: { request in
980+
if request is DocumentSemanticTokensRequest {
981+
// Sleep long enough for the edit to be handled
982+
try? await Task.sleep(for: .seconds(2))
983+
}
984+
})
985+
)
986+
let uri = DocumentURI(for: .swift)
987+
let positions = testClient.openDocument("1️⃣", uri: uri)
988+
989+
let receivedSemanticTokensResponse = self.expectation(description: "Received semantic tokens response")
990+
testClient.send(DocumentSemanticTokensRequest(textDocument: TextDocumentIdentifier(uri))) { result in
991+
XCTAssertEqual(result, .success(DocumentSemanticTokensResponse(data: [])))
992+
receivedSemanticTokensResponse.fulfill()
993+
}
994+
testClient.send(
995+
DidChangeTextDocumentNotification(
996+
textDocument: VersionedTextDocumentIdentifier(uri, version: 2),
997+
contentChanges: [TextDocumentContentChangeEvent(range: Range(positions["1️⃣"]), text: "let x = 1")]
998+
)
999+
)
1000+
try await fulfillmentOfOrThrow([receivedSemanticTokensResponse])
1001+
}
9461002
}
9471003

9481004
fileprivate struct TokenSpec {

0 commit comments

Comments
 (0)