Skip to content

Commit a6f74fc

Browse files
committed
Fix a race condition during the computation of uriToWorkspaceCache
See comment in `workspaceForDocument` that explains the race.
1 parent 0de726d commit a6f74fc

File tree

2 files changed

+124
-94
lines changed

2 files changed

+124
-94
lines changed

Sources/SKSupport/AsyncUtils.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,21 @@ public extension Task {
7676
}
7777
}
7878

79+
public extension Task where Failure == Never {
80+
/// Awaits the value of the result.
81+
///
82+
/// If the current task is cancelled, this will cancel the subtask as well.
83+
var valuePropagatingCancellation: Success {
84+
get async {
85+
await withTaskCancellationHandler {
86+
return await self.value
87+
} onCancel: {
88+
self.cancel()
89+
}
90+
}
91+
}
92+
}
93+
7994
/// Allows the execution of a cancellable operation that returns the results
8095
/// via a completion handler.
8196
///

Sources/SourceKitLSP/SourceKitLSPServer.swift

Lines changed: 109 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,10 @@ public actor SourceKitLSPServer {
408408
/// request's task before handling any cancellation request for it.
409409
private let cancellationMessageHandlingQueue = AsyncQueue<Serial>()
410410

411+
/// The queue on which all modifications of `uriToWorkspaceCache` happen. This means that the value of
412+
/// `workspacesAndIsImplicit` and `uriToWorkspaceCache` can't change while executing a closure on `workspaceQueue`.
413+
private let workspaceQueue = AsyncQueue<Serial>()
414+
411415
/// The connection to the editor.
412416
public let client: Connection
413417

@@ -432,14 +436,19 @@ public actor SourceKitLSPServer {
432436
}
433437

434438
/// Caches which workspace a document with the given URI should be opened in.
435-
/// Must only be accessed from `queue`.
439+
///
440+
/// - Important: Must only be modified from `workspaceQueue`. This means that the value of `uriToWorkspaceCache`
441+
/// can't change while executing an operation on `workspaceQueue`.
436442
private var uriToWorkspaceCache: [DocumentURI: WeakWorkspace] = [:]
437443

438444
/// The open workspaces.
439445
///
440446
/// Implicit workspaces are workspaces that weren't actually specified by the client during initialization or by a
441447
/// `didChangeWorkspaceFolders` request. Instead, they were opened by sourcekit-lsp because a file could not be
442448
/// handled by any of the open workspaces but one of the file's parent directories had handling capabilities for it.
449+
///
450+
/// - Important: Must only be modified from `workspaceQueue`. This means that the value of `workspacesAndIsImplicit`
451+
/// can't change while executing an operation on `workspaceQueue`.
443452
private var workspacesAndIsImplicit: [(workspace: Workspace, isImplicit: Bool)] = [] {
444453
didSet {
445454
uriToWorkspaceCache = [:]
@@ -452,7 +461,9 @@ public actor SourceKitLSPServer {
452461

453462
@_spi(Testing)
454463
public func setWorkspaces(_ newValue: [(workspace: Workspace, isImplicit: Bool)]) {
455-
self.workspacesAndIsImplicit = newValue
464+
workspaceQueue.async {
465+
self.workspacesAndIsImplicit = newValue
466+
}
456467
}
457468

458469
/// The requests that we are currently handling.
@@ -517,54 +528,58 @@ public actor SourceKitLSPServer {
517528
}
518529

519530
public func workspaceForDocument(uri: DocumentURI) async -> Workspace? {
520-
if let cachedWorkspace = uriToWorkspaceCache[uri]?.value {
531+
if let cachedWorkspace = self.uriToWorkspaceCache[uri]?.value {
521532
return cachedWorkspace
522533
}
523534

524-
// Pick the workspace with the best FileHandlingCapability for this file.
525-
// If there is a tie, use the workspace that occurred first in the list.
526-
var bestWorkspace: (workspace: Workspace?, fileHandlingCapability: FileHandlingCapability) = (nil, .unhandled)
527-
for workspace in workspaces {
528-
let fileHandlingCapability = await workspace.buildSystemManager.fileHandlingCapability(for: uri)
529-
if fileHandlingCapability > bestWorkspace.fileHandlingCapability {
530-
bestWorkspace = (workspace, fileHandlingCapability)
535+
// Execute the computation of the workspace on `workspaceQueue` to ensure that the file handling capabilities of the
536+
// workspaces don't change during the computation. Otherwise, we could run into a race condition like the following:
537+
// 1. We don't have an entry for file `a.swift` in `uriToWorkspaceCache` and start the computation
538+
// 2. We find that the first workspace in `self.workspaces` can handle this file.
539+
// 3. During the `await ... .fileHandlingCapability` for a second workspace the file handling capabilities for the
540+
// first workspace change, meaning it can no longer handle the document. This resets `uriToWorkspaceCache`
541+
// assuming that the URI to workspace relation will get re-computed.
542+
// 4. But we then set `uriToWorkspaceCache[uri]` to the workspace found in step (2), caching an out-of-date result.
543+
//
544+
// Furthermore, the computation of the workspace for a URI can create a new implicit workspace, which modifies
545+
// `workspacesAndIsImplicit` and which must only be modified on `workspaceQueue`.
546+
return await self.workspaceQueue.async {
547+
// Pick the workspace with the best FileHandlingCapability for this file.
548+
// If there is a tie, use the workspace that occurred first in the list.
549+
var bestWorkspace: (workspace: Workspace?, fileHandlingCapability: FileHandlingCapability) = (nil, .unhandled)
550+
for workspace in self.workspaces {
551+
let fileHandlingCapability = await workspace.buildSystemManager.fileHandlingCapability(for: uri)
552+
if fileHandlingCapability > bestWorkspace.fileHandlingCapability {
553+
bestWorkspace = (workspace, fileHandlingCapability)
554+
}
531555
}
532-
}
533-
if bestWorkspace.fileHandlingCapability < .handled {
534-
// We weren't able to handle the document with any of the known workspaces. See if any of the document's parent
535-
// directories contain a workspace that can handle the document.
536-
let rootUris = workspaces.map(\.rootUri)
537-
if let workspace = await findWorkspaceCapableOfHandlingDocument(at: uri) {
538-
if workspaces.map(\.rootUri) != rootUris {
539-
// Workspaces was modified while running `findWorkspaceCapableOfHandlingDocument`, so we raced.
540-
// This is unlikely to happen unless the user opens many files that in sub-workspaces simultaneously.
541-
// Try again based on the new data. Very likely the workspace that can handle this document is now in
542-
// `workspaces` and we will be able to return it without having to search again.
543-
logger.debug("findWorkspaceCapableOfHandlingDocument raced with another workspace creation. Trying again.")
544-
return await workspaceForDocument(uri: uri)
556+
if bestWorkspace.fileHandlingCapability < .handled {
557+
// We weren't able to handle the document with any of the known workspaces. See if any of the document's parent
558+
// directories contain a workspace that can handle the document.
559+
if let workspace = await self.findWorkspaceCapableOfHandlingDocument(at: uri) {
560+
// Appending a workspace is fine and doesn't require checking if we need to re-open any documents because:
561+
// - Any currently open documents that have FileHandlingCapability `.handled` will continue to be opened in
562+
// their current workspace because it occurs further in front inside the workspace list
563+
// - Any currently open documents that have FileHandlingCapability < `.handled` also went through this check
564+
// and didn't find any parent workspace that was able to handle them. We assume that a workspace can only
565+
// properly handle files within its root directory, so those files now also can't be handled by the new
566+
// workspace.
567+
logger.log("Opening implicit workspace at \(workspace.rootUri.forLogging) to handle \(uri.forLogging)")
568+
self.workspacesAndIsImplicit.append((workspace: workspace, isImplicit: true))
569+
bestWorkspace = (workspace, .handled)
545570
}
546-
// Appending a workspace is fine and doesn't require checking if we need to re-open any documents because:
547-
// - Any currently open documents that have FileHandlingCapability `.handled` will continue to be opened in
548-
// their current workspace because it occurs further in front inside the workspace list
549-
// - Any currently open documents that have FileHandlingCapability < `.handled` also went through this check
550-
// and didn't find any parent workspace that was able to handle them. We assume that a workspace can only
551-
// properly handle files within its root directory, so those files now also can't be handled by the new
552-
// workspace.
553-
logger.log("Opening implicit workspace at \(workspace.rootUri.forLogging) to handle \(uri.forLogging)")
554-
workspacesAndIsImplicit.append((workspace: workspace, isImplicit: true))
555-
bestWorkspace = (workspace, .handled)
556571
}
557-
}
558-
uriToWorkspaceCache[uri] = WeakWorkspace(bestWorkspace.workspace)
559-
if let workspace = bestWorkspace.workspace {
560-
return workspace
561-
}
562-
if let workspace = workspaces.only {
563-
// Special handling: If there is only one workspace, open all files in it, even it it cannot handle the document.
564-
// This retains the behavior of SourceKit-LSP before it supported multiple workspaces.
565-
return workspace
566-
}
567-
return nil
572+
self.uriToWorkspaceCache[uri] = WeakWorkspace(bestWorkspace.workspace)
573+
if let workspace = bestWorkspace.workspace {
574+
return workspace
575+
}
576+
if let workspace = self.workspaces.only {
577+
// Special handling: If there is only one workspace, open all files in it, even it it cannot handle the document.
578+
// This retains the behavior of SourceKit-LSP before it supported multiple workspaces.
579+
return workspace
580+
}
581+
return nil
582+
}.valuePropagatingCancellation
568583
}
569584

570585
/// Execute `notificationHandler` with the request as well as the workspace
@@ -1077,8 +1092,10 @@ extension SourceKitLSPServer: BuildSystemDelegate {
10771092
}
10781093

10791094
public func fileHandlingCapabilityChanged() {
1080-
logger.log("Resetting URI to workspace cache because file handling capability of a workspace changed")
1081-
self.uriToWorkspaceCache = [:]
1095+
workspaceQueue.async {
1096+
logger.log("Resetting URI to workspace cache because file handling capability of a workspace changed")
1097+
self.uriToWorkspaceCache = [:]
1098+
}
10821099
}
10831100
}
10841101

@@ -1184,47 +1201,43 @@ extension SourceKitLSPServer {
11841201

11851202
capabilityRegistry = CapabilityRegistry(clientCapabilities: req.capabilities)
11861203

1187-
if let workspaceFolders = req.workspaceFolders {
1188-
self.workspacesAndIsImplicit += await workspaceFolders.asyncCompactMap {
1189-
guard let workspace = await self.createWorkspace($0) else {
1190-
return nil
1204+
await workspaceQueue.async {
1205+
if let workspaceFolders = req.workspaceFolders {
1206+
self.workspacesAndIsImplicit += await workspaceFolders.asyncCompactMap {
1207+
guard let workspace = await self.createWorkspace($0) else {
1208+
return nil
1209+
}
1210+
return (workspace: workspace, isImplicit: false)
1211+
}
1212+
} else if let uri = req.rootURI {
1213+
let workspaceFolder = WorkspaceFolder(uri: uri)
1214+
if let workspace = await self.createWorkspace(workspaceFolder) {
1215+
self.workspacesAndIsImplicit.append((workspace: workspace, isImplicit: false))
1216+
}
1217+
} else if let path = req.rootPath {
1218+
let workspaceFolder = WorkspaceFolder(uri: DocumentURI(URL(fileURLWithPath: path)))
1219+
if let workspace = await self.createWorkspace(workspaceFolder) {
1220+
self.workspacesAndIsImplicit.append((workspace: workspace, isImplicit: false))
11911221
}
1192-
return (workspace: workspace, isImplicit: false)
1193-
}
1194-
} else if let uri = req.rootURI {
1195-
let workspaceFolder = WorkspaceFolder(uri: uri)
1196-
if let workspace = await self.createWorkspace(workspaceFolder) {
1197-
self.workspacesAndIsImplicit.append((workspace: workspace, isImplicit: false))
1198-
}
1199-
} else if let path = req.rootPath {
1200-
let workspaceFolder = WorkspaceFolder(uri: DocumentURI(URL(fileURLWithPath: path)))
1201-
if let workspace = await self.createWorkspace(workspaceFolder) {
1202-
self.workspacesAndIsImplicit.append((workspace: workspace, isImplicit: false))
12031222
}
1204-
}
12051223

1206-
if self.workspaces.isEmpty {
1207-
logger.error("no workspace found")
1208-
1209-
let workspace = await Workspace(
1210-
documentManager: self.documentManager,
1211-
rootUri: req.rootURI,
1212-
capabilityRegistry: self.capabilityRegistry!,
1213-
toolchainRegistry: self.toolchainRegistry,
1214-
buildSetup: self.options.buildSetup,
1215-
underlyingBuildSystem: nil,
1216-
index: nil,
1217-
indexDelegate: nil
1218-
)
1219-
1220-
// Another workspace might have been added while we awaited the
1221-
// construction of the workspace above. If that race happened, just
1222-
// discard the workspace we created here since `workspaces` now isn't
1223-
// empty anymore.
12241224
if self.workspaces.isEmpty {
1225+
logger.error("no workspace found")
1226+
1227+
let workspace = await Workspace(
1228+
documentManager: self.documentManager,
1229+
rootUri: req.rootURI,
1230+
capabilityRegistry: self.capabilityRegistry!,
1231+
toolchainRegistry: self.toolchainRegistry,
1232+
buildSetup: self.options.buildSetup,
1233+
underlyingBuildSystem: nil,
1234+
index: nil,
1235+
indexDelegate: nil
1236+
)
1237+
12251238
self.workspacesAndIsImplicit.append((workspace: workspace, isImplicit: false))
12261239
}
1227-
}
1240+
}.value
12281241

12291242
assert(!self.workspaces.isEmpty)
12301243
for workspace in self.workspaces {
@@ -1552,21 +1565,23 @@ extension SourceKitLSPServer {
15521565
for docUri in self.documentManager.openDocuments {
15531566
preChangeWorkspaces[docUri] = await self.workspaceForDocument(uri: docUri)
15541567
}
1555-
if let removed = notification.event.removed {
1556-
self.workspacesAndIsImplicit.removeAll { workspace in
1557-
// Close all implicit workspaces as well because we could have opened a new explicit workspace that now contains
1558-
// files from a previous implicit workspace.
1559-
return workspace.isImplicit
1560-
|| removed.contains(where: { workspaceFolder in workspace.workspace.rootUri == workspaceFolder.uri })
1568+
await workspaceQueue.async {
1569+
if let removed = notification.event.removed {
1570+
self.workspacesAndIsImplicit.removeAll { workspace in
1571+
// Close all implicit workspaces as well because we could have opened a new explicit workspace that now contains
1572+
// files from a previous implicit workspace.
1573+
return workspace.isImplicit
1574+
|| removed.contains(where: { workspaceFolder in workspace.workspace.rootUri == workspaceFolder.uri })
1575+
}
15611576
}
1562-
}
1563-
if let added = notification.event.added {
1564-
let newWorkspaces = await added.asyncCompactMap { await self.createWorkspace($0) }
1565-
for workspace in newWorkspaces {
1566-
await workspace.buildSystemManager.setDelegate(self)
1577+
if let added = notification.event.added {
1578+
let newWorkspaces = await added.asyncCompactMap { await self.createWorkspace($0) }
1579+
for workspace in newWorkspaces {
1580+
await workspace.buildSystemManager.setDelegate(self)
1581+
}
1582+
self.workspacesAndIsImplicit += newWorkspaces.map { (workspace: $0, isImplicit: false) }
15671583
}
1568-
self.workspacesAndIsImplicit += newWorkspaces.map { (workspace: $0, isImplicit: false) }
1569-
}
1584+
}.value
15701585

15711586
// For each document that has moved to a different workspace, close it in
15721587
// the old workspace and open it in the new workspace.

0 commit comments

Comments
 (0)