Skip to content

Commit bd6fb5d

Browse files
committed
Do not block initialization of the build server when build server is unresponsive in returning the list of test files
We were blocking the initialization response on `self.buildSystemManager.testFiles`, which requires the list of test files to be determined. Make that operation asynchronous so that a slow build server can’t take down all of SourceKit-LSP.
1 parent 58bce82 commit bd6fb5d

File tree

4 files changed

+107
-18
lines changed

4 files changed

+107
-18
lines changed

Sources/SKTestSupport/TestSourceKitLSPClient.swift

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -204,17 +204,20 @@ package final class TestSourceKitLSPClient: MessageHandler, Sendable {
204204
}
205205
preInitialization?(self)
206206
if initialize {
207-
_ = try await self.send(
208-
InitializeRequest(
209-
processId: nil,
210-
rootPath: nil,
211-
rootURI: nil,
212-
initializationOptions: initializationOptions,
213-
capabilities: capabilities,
214-
trace: .off,
215-
workspaceFolders: workspaceFolders
207+
let capabilities = capabilities
208+
try await withTimeout(.seconds(defaultTimeout)) {
209+
_ = try await self.send(
210+
InitializeRequest(
211+
processId: nil,
212+
rootPath: nil,
213+
rootURI: nil,
214+
initializationOptions: initializationOptions,
215+
capabilities: capabilities,
216+
trace: .off,
217+
workspaceFolders: workspaceFolders
218+
)
216219
)
217-
)
220+
}
218221
}
219222
}
220223

Sources/SourceKitLSP/Swift/SyntacticTestIndex.swift

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,28 @@ import SwiftExtensions
1818

1919
/// Task metadata for `SyntacticTestIndexer.indexingQueue`
2020
fileprivate enum TaskMetadata: DependencyTracker, Equatable {
21-
case read
21+
/// Determine the list of test files from the build system and scan them for tests. Only created when the
22+
/// `SyntacticTestIndex` is created
23+
case initialPopulation
24+
25+
/// Index the files in the given set for tests
2226
case index(Set<DocumentURI>)
2327

28+
/// Retrieve information about syntactically discovered tests from the index.
29+
case read
30+
2431
/// Reads can be concurrent and files can be indexed concurrently. But we need to wait for all files to finish
2532
/// indexing before reading the index.
2633
func isDependency(of other: TaskMetadata) -> Bool {
2734
switch (self, other) {
35+
case (.initialPopulation, _):
36+
// The initial population need to finish before we can do anything with the task.
37+
return true
38+
case (_, .initialPopulation):
39+
// Should never happen because the initial population should only be scheduled once before any other operations
40+
// on the test index. But be conservative in case we do get an `initialPopulation` somewhere in between and use it
41+
// as a full blocker on the queue.
42+
return true
2843
case (.read, .read):
2944
// We allow concurrent reads
3045
return false
@@ -109,7 +124,23 @@ actor SyntacticTestIndex {
109124
/// indexing tasks to finish.
110125
private let indexingQueue = AsyncQueue<TaskMetadata>()
111126

112-
init() {}
127+
init(determineTestFiles: @Sendable @escaping () async -> [DocumentURI]) {
128+
indexingQueue.async(priority: .low, metadata: .initialPopulation) {
129+
let testFiles = await determineTestFiles()
130+
131+
// Divide the files into multiple batches. This is more efficient than spawning a new task for every file, mostly
132+
// because it keeps the number of pending items in `indexingQueue` low and adding a new task to `indexingQueue` is
133+
// in O(number of pending tasks), since we need to scan for dependency edges to add, which would make scanning files
134+
// be O(number of files).
135+
// Over-subscribe the processor count in case one batch finishes more quickly than another.
136+
let batches = testFiles.partition(intoNumberOfBatches: ProcessInfo.processInfo.processorCount * 4)
137+
await batches.concurrentForEach { filesInBatch in
138+
for uri in filesInBatch {
139+
await self.rescanFileAssumingOnQueue(uri)
140+
}
141+
}
142+
}
143+
}
113144

114145
private func removeFilesFromIndex(_ removedFiles: Set<DocumentURI>) {
115146
self.removedFiles.formUnion(removedFiles)

Sources/SourceKitLSP/Workspace.swift

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ package final class Workspace: Sendable, BuildSystemManagerDelegate {
127127
}
128128

129129
/// The index that syntactically scans the workspace for tests.
130-
let syntacticTestIndex = SyntacticTestIndex()
130+
let syntacticTestIndex: SyntacticTestIndex
131131

132132
/// Language service for an open document, if available.
133133
private let documentService: ThreadSafeBox<[DocumentURI: LanguageService]> = ThreadSafeBox(initialValue: [:])
@@ -180,13 +180,15 @@ package final class Workspace: Sendable, BuildSystemManagerDelegate {
180180
} else {
181181
self.semanticIndexManager = nil
182182
}
183+
// Trigger an initial population of `syntacticTestIndex`.
184+
self.syntacticTestIndex = SyntacticTestIndex(determineTestFiles: {
185+
await orLog("Getting list of test files for initial syntactic index population") {
186+
try await buildSystemManager.testFiles()
187+
} ?? []
188+
})
183189
await indexDelegate?.addMainFileChangedCallback { [weak self] in
184190
await self?.buildSystemManager.mainFilesChanged()
185191
}
186-
// Trigger an initial population of `syntacticTestIndex`.
187-
if let testFiles = await orLog("Getting initial test files", { try await self.buildSystemManager.testFiles() }) {
188-
await syntacticTestIndex.listOfTestFilesDidChange(testFiles)
189-
}
190192
if let semanticIndexManager {
191193
await semanticIndexManager.scheduleBuildGraphGenerationAndBackgroundIndexAllFiles(
192194
filesToIndex: nil,
@@ -224,7 +226,7 @@ package final class Workspace: Sendable, BuildSystemManagerDelegate {
224226
// `SourceKitLSPServer` has been destructed. We are tearing down the
225227
// language server. Nothing left to do.
226228
logger.error(
227-
"Ignoring notificaiton \(type(of: notification).method) because connection to editor has been closed"
229+
"Ignoring notification \(type(of: notification).method) because connection to editor has been closed"
228230
)
229231
return
230232
}

Tests/BuildSystemIntegrationTests/BuildServerBuildSystemTests.swift

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,15 @@ import LanguageServerProtocol
1717
import LanguageServerProtocolExtensions
1818
import SKOptions
1919
import SKTestSupport
20+
import SourceKitLSP
21+
import SwiftExtensions
2022
import TSCBasic
2123
import XCTest
2224

25+
#if os(Windows)
26+
import WinSDK
27+
#endif
28+
2329
final class BuildServerBuildSystemTests: XCTestCase {
2430
func testBuildSettingsFromBuildServer() async throws {
2531
let project = try await BuildServerTestProject(
@@ -517,4 +523,51 @@ final class BuildServerBuildSystemTests: XCTestCase {
517523
// We currently pick the canonical target alphabetically, which means that `bsp://first` wins over `bsp://second`
518524
XCTAssert(try XCTUnwrap(optionsWithoutTarget).compilerArguments.contains("-DFIRST"))
519525
}
526+
527+
func testDontBlockBuildServerInitializationIfBuildSystemIsUnresponsive() async throws {
528+
// A build server that responds to the initialize request but not to any other requests.
529+
final class UnresponsiveBuildServer: MessageHandler {
530+
func handle(_ notification: some LanguageServerProtocol.NotificationType) {}
531+
532+
func handle<Request: RequestType>(
533+
_ request: Request,
534+
id: RequestID,
535+
reply: @escaping @Sendable (LSPResult<Request.Response>) -> Void
536+
) {
537+
switch request {
538+
case is InitializeBuildRequest:
539+
reply(
540+
.success(
541+
InitializeBuildResponse(
542+
displayName: "UnresponsiveBuildServer",
543+
version: "",
544+
bspVersion: "2.2.0",
545+
capabilities: BuildServerCapabilities()
546+
) as! Request.Response
547+
)
548+
)
549+
default:
550+
#if os(Windows)
551+
Sleep(60 * 60 * 1000 /*ms*/)
552+
#else
553+
sleep(60 * 60 /*s*/)
554+
#endif
555+
XCTFail("Build server should be terminated before finishing the timeout")
556+
}
557+
}
558+
}
559+
560+
// Creating the `MultiFileTestProject` waits for the initialize response and times out if it doesn't receive one.
561+
// Make sure that we get that response back.
562+
_ = try await MultiFileTestProject(
563+
files: ["Test.swift": ""],
564+
hooks: Hooks(
565+
buildSystemHooks: BuildSystemHooks(injectBuildServer: { _, _ in
566+
let connection = LocalConnection(receiverName: "Unresponsive build system")
567+
connection.start(handler: UnresponsiveBuildServer())
568+
return connection
569+
})
570+
)
571+
)
572+
}
520573
}

0 commit comments

Comments
 (0)