Skip to content

Do not block initialization of the build server when build server is unresponsive in returning the list of test files #1999

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 13 additions & 10 deletions Sources/SKTestSupport/TestSourceKitLSPClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -204,17 +204,20 @@ package final class TestSourceKitLSPClient: MessageHandler, Sendable {
}
preInitialization?(self)
if initialize {
_ = try await self.send(
InitializeRequest(
processId: nil,
rootPath: nil,
rootURI: nil,
initializationOptions: initializationOptions,
capabilities: capabilities,
trace: .off,
workspaceFolders: workspaceFolders
let capabilities = capabilities
try await withTimeout(.seconds(defaultTimeout)) {
_ = try await self.send(
InitializeRequest(
processId: nil,
rootPath: nil,
rootURI: nil,
initializationOptions: initializationOptions,
capabilities: capabilities,
trace: .off,
workspaceFolders: workspaceFolders
)
)
)
}
}
}

Expand Down
35 changes: 8 additions & 27 deletions Sources/SemanticIndex/SemanticIndexManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -339,13 +339,7 @@ package final actor SemanticIndexManager {
}

private func waitForBuildGraphGenerationTasks() async {
await withTaskGroup(of: Void.self) { taskGroup in
for generateBuildGraphTask in scheduleIndexingTasks.values {
taskGroup.addTask {
await generateBuildGraphTask.value
}
}
}
await scheduleIndexingTasks.values.concurrentForEach { await $0.value }
}

/// Wait for all in-progress index tasks to finish.
Expand All @@ -355,18 +349,13 @@ package final actor SemanticIndexManager {
// can await the index tasks below.
await waitForBuildGraphGenerationTasks()

await withTaskGroup(of: Void.self) { taskGroup in
for (_, status) in inProgressIndexTasks {
switch status {
case .waitingForPreparation(preparationTaskID: _, indexTask: let indexTask),
.preparing(preparationTaskID: _, indexTask: let indexTask),
.updatingIndexStore(updateIndexStoreTask: _, indexTask: let indexTask):
taskGroup.addTask {
await indexTask.value
}
}
await inProgressIndexTasks.concurrentForEach { (_, status) in
switch status {
case .waitingForPreparation(preparationTaskID: _, indexTask: let indexTask),
.preparing(preparationTaskID: _, indexTask: let indexTask),
.updatingIndexStore(updateIndexStoreTask: _, indexTask: let indexTask):
await indexTask.value
}
await taskGroup.waitForAll()
}
index.pollForUnitChangesAndWait()
logger.debug("Done waiting for up-to-date index")
Expand Down Expand Up @@ -786,17 +775,9 @@ package final actor SemanticIndexManager {
}
indexTasksWereScheduled(newIndexTasks)
}
let indexTasksImmutable = indexTasks

return Task(priority: priority) {
await withTaskGroup(of: Void.self) { taskGroup in
for indexTask in indexTasksImmutable {
taskGroup.addTask {
await indexTask.value
}
}
await taskGroup.waitForAll()
}
await indexTasks.concurrentForEach { await $0.value }
}
}
}
35 changes: 33 additions & 2 deletions Sources/SourceKitLSP/Swift/SyntacticTestIndex.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,28 @@ import SwiftExtensions

/// Task metadata for `SyntacticTestIndexer.indexingQueue`
fileprivate enum TaskMetadata: DependencyTracker, Equatable {
case read
/// Determine the list of test files from the build system and scan them for tests. Only created when the
/// `SyntacticTestIndex` is created
case initialPopulation

/// Index the files in the given set for tests
case index(Set<DocumentURI>)

/// Retrieve information about syntactically discovered tests from the index.
case read

/// Reads can be concurrent and files can be indexed concurrently. But we need to wait for all files to finish
/// indexing before reading the index.
func isDependency(of other: TaskMetadata) -> Bool {
switch (self, other) {
case (.initialPopulation, _):
// The initial population need to finish before we can do anything with the task.
return true
case (_, .initialPopulation):
// Should never happen because the initial population should only be scheduled once before any other operations
// on the test index. But be conservative in case we do get an `initialPopulation` somewhere in between and use it
// as a full blocker on the queue.
return true
case (.read, .read):
// We allow concurrent reads
return false
Expand Down Expand Up @@ -109,7 +124,23 @@ actor SyntacticTestIndex {
/// indexing tasks to finish.
private let indexingQueue = AsyncQueue<TaskMetadata>()

init() {}
init(determineTestFiles: @Sendable @escaping () async -> [DocumentURI]) {
indexingQueue.async(priority: .low, metadata: .initialPopulation) {
let testFiles = await determineTestFiles()

// Divide the files into multiple batches. This is more efficient than spawning a new task for every file, mostly
// because it keeps the number of pending items in `indexingQueue` low and adding a new task to `indexingQueue` is
// in O(number of pending tasks), since we need to scan for dependency edges to add, which would make scanning files
// be O(number of files).
// Over-subscribe the processor count in case one batch finishes more quickly than another.
let batches = testFiles.partition(intoNumberOfBatches: ProcessInfo.processInfo.processorCount * 4)
await batches.concurrentForEach { filesInBatch in
for uri in filesInBatch {
await self.rescanFileAssumingOnQueue(uri)
}
}
}
}

private func removeFilesFromIndex(_ removedFiles: Set<DocumentURI>) {
self.removedFiles.formUnion(removedFiles)
Expand Down
14 changes: 8 additions & 6 deletions Sources/SourceKitLSP/Workspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ package final class Workspace: Sendable, BuildSystemManagerDelegate {
}

/// The index that syntactically scans the workspace for tests.
let syntacticTestIndex = SyntacticTestIndex()
let syntacticTestIndex: SyntacticTestIndex

/// Language service for an open document, if available.
private let documentService: ThreadSafeBox<[DocumentURI: LanguageService]> = ThreadSafeBox(initialValue: [:])
Expand Down Expand Up @@ -180,13 +180,15 @@ package final class Workspace: Sendable, BuildSystemManagerDelegate {
} else {
self.semanticIndexManager = nil
}
// Trigger an initial population of `syntacticTestIndex`.
self.syntacticTestIndex = SyntacticTestIndex(determineTestFiles: {
await orLog("Getting list of test files for initial syntactic index population") {
try await buildSystemManager.testFiles()
} ?? []
})
await indexDelegate?.addMainFileChangedCallback { [weak self] in
await self?.buildSystemManager.mainFilesChanged()
}
// Trigger an initial population of `syntacticTestIndex`.
if let testFiles = await orLog("Getting initial test files", { try await self.buildSystemManager.testFiles() }) {
await syntacticTestIndex.listOfTestFilesDidChange(testFiles)
}
if let semanticIndexManager {
await semanticIndexManager.scheduleBuildGraphGenerationAndBackgroundIndexAllFiles(
filesToIndex: nil,
Expand Down Expand Up @@ -224,7 +226,7 @@ package final class Workspace: Sendable, BuildSystemManagerDelegate {
// `SourceKitLSPServer` has been destructed. We are tearing down the
// language server. Nothing left to do.
logger.error(
"Ignoring notificaiton \(type(of: notification).method) because connection to editor has been closed"
"Ignoring notification \(type(of: notification).method) because connection to editor has been closed"
)
return
}
Expand Down
11 changes: 11 additions & 0 deletions Sources/SwiftExtensions/AsyncUtils.swift
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,17 @@ extension Collection where Element: Sendable {
count = indexedResults.count
}
}

/// Invoke `body` for every element in the collection and wait for all calls of `body` to finish
package func concurrentForEach(_ body: @escaping @Sendable (Element) async -> Void) async {
await withTaskGroup(of: Void.self) { taskGroup in
for element in self {
taskGroup.addTask {
await body(element)
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't this need either a wait on each of the tasks or a taskGroup.waitForAll()?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, from the documentation of withTaskGroup. I only learned this now, which is why I had waitForAll in older implementations.

A group waits for all of its child tasks to complete or be canceled before it returns.

}
}

package struct TimeoutError: Error, CustomStringConvertible {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,15 @@ import LanguageServerProtocol
import LanguageServerProtocolExtensions
import SKOptions
import SKTestSupport
import SourceKitLSP
import SwiftExtensions
import TSCBasic
import XCTest

#if os(Windows)
import WinSDK
#endif

final class BuildServerBuildSystemTests: XCTestCase {
func testBuildSettingsFromBuildServer() async throws {
let project = try await BuildServerTestProject(
Expand Down Expand Up @@ -517,4 +523,51 @@ final class BuildServerBuildSystemTests: XCTestCase {
// We currently pick the canonical target alphabetically, which means that `bsp://first` wins over `bsp://second`
XCTAssert(try XCTUnwrap(optionsWithoutTarget).compilerArguments.contains("-DFIRST"))
}

func testDontBlockBuildServerInitializationIfBuildSystemIsUnresponsive() async throws {
// A build server that responds to the initialize request but not to any other requests.
final class UnresponsiveBuildServer: MessageHandler {
func handle(_ notification: some LanguageServerProtocol.NotificationType) {}

func handle<Request: RequestType>(
_ request: Request,
id: RequestID,
reply: @escaping @Sendable (LSPResult<Request.Response>) -> Void
) {
switch request {
case is InitializeBuildRequest:
reply(
.success(
InitializeBuildResponse(
displayName: "UnresponsiveBuildServer",
version: "",
bspVersion: "2.2.0",
capabilities: BuildServerCapabilities()
) as! Request.Response
)
)
default:
#if os(Windows)
Sleep(60 * 60 * 1000 /*ms*/)
#else
sleep(60 * 60 /*s*/)
#endif
XCTFail("Build server should be terminated before finishing the timeout")
}
}
}

// Creating the `MultiFileTestProject` waits for the initialize response and times out if it doesn't receive one.
// Make sure that we get that response back.
_ = try await MultiFileTestProject(
files: ["Test.swift": ""],
hooks: Hooks(
buildSystemHooks: BuildSystemHooks(injectBuildServer: { _, _ in
let connection = LocalConnection(receiverName: "Unresponsive build system")
connection.start(handler: UnresponsiveBuildServer())
return connection
})
)
)
}
}