Skip to content

Commit 4e44553

Browse files
authored
Merge pull request #2020 from ahoppen/debounce-buildsettingschanged
Debounce `filesBuildSettingsChanged` calls
2 parents 3fc3616 + 18b18c2 commit 4e44553

File tree

2 files changed

+54
-7
lines changed

2 files changed

+54
-7
lines changed

Sources/BuildSystemIntegration/BuildSystemManager.swift

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,22 @@ package actor BuildSystemManager: QueueBasedMessageHandler {
357357
}
358358
}
359359

360+
/// Debounces calls to `delegate.fileBuildSettingsChanged`.
361+
///
362+
/// This helps in the following situation: A build system takes 5s to return build settings for a file and we have 10
363+
/// requests for those build settings coming in that time period. Once we get build settings, we get 10 calls to
364+
/// `resultReceivedAfterTimeout` in `buildSettings(for:in:language:fallbackAfterTimeout:)`, all for the same document.
365+
/// But calling `fileBuildSettingsChanged` once is totally sufficient.
366+
///
367+
/// Force-unwrapped optional because initializing it requires access to `self`.
368+
private var filesBuildSettingsChangedDebouncer: Debouncer<Set<DocumentURI>>! = nil {
369+
didSet {
370+
// Must only be set once
371+
precondition(oldValue == nil)
372+
precondition(filesBuildSettingsChangedDebouncer != nil)
373+
}
374+
}
375+
360376
private var cachedAdjustedSourceKitOptions = RequestCache<TextDocumentSourceKitOptionsRequest>()
361377

362378
private var cachedBuildTargets = Cache<WorkspaceBuildTargetsRequest, [BuildTargetIdentifier: BuildTargetInfo]>()
@@ -421,6 +437,24 @@ package actor BuildSystemManager: QueueBasedMessageHandler {
421437
}
422438
}
423439

440+
// We don't need a large debounce duration here. It just needs to be big enough to accumulate
441+
// `resultReceivedAfterTimeout` calls for the same document (see comment on `filesBuildSettingsChangedDebouncer`).
442+
// Since they should all come in at the same time, a couple of milliseconds should be sufficient here, an 20ms be
443+
// plenty while still not causing a noticeable delay to the user.
444+
self.filesBuildSettingsChangedDebouncer = Debouncer(
445+
debounceDuration: .milliseconds(20),
446+
combineResults: { $0.union($1) }
447+
) {
448+
[weak self] (filesWithChangedBuildSettings) in
449+
guard let self, let delegate = await self.delegate else {
450+
logger.fault("Not calling fileBuildSettingsChanged because no delegate exists in SwiftPMBuildSystem")
451+
return
452+
}
453+
if !filesWithChangedBuildSettings.isEmpty {
454+
await delegate.fileBuildSettingsChanged(filesWithChangedBuildSettings)
455+
}
456+
}
457+
424458
// TODO: Forward file watch patterns from this initialize request to the client
425459
// (https://github.com/swiftlang/sourcekit-lsp/issues/1671)
426460
initializeResult = Task { () -> InitializeBuildResponse? in
@@ -579,7 +613,7 @@ package actor BuildSystemManager: QueueBasedMessageHandler {
579613
self.cachedSourceFilesAndDirectories.clearAll(isolation: self)
580614

581615
await delegate?.buildTargetsChanged(notification.changes)
582-
await delegate?.fileBuildSettingsChanged(Set(watchedFiles.keys))
616+
await filesBuildSettingsChangedDebouncer.scheduleCall(Set(watchedFiles.keys))
583617
}
584618

585619
private func logMessage(notification: BuildServerProtocol.OnBuildLogMessageNotification) async {
@@ -837,7 +871,7 @@ package actor BuildSystemManager: QueueBasedMessageHandler {
837871
try await withTimeout(options.buildSettingsTimeoutOrDefault) {
838872
return try await self.buildSettingsFromBuildSystem(for: document, in: target, language: language)
839873
} resultReceivedAfterTimeout: {
840-
await self.delegate?.fileBuildSettingsChanged([document])
874+
await self.filesBuildSettingsChangedDebouncer.scheduleCall([document])
841875
}
842876
} else {
843877
try await self.buildSettingsFromBuildSystem(for: document, in: target, language: language)
@@ -895,7 +929,7 @@ package actor BuildSystemManager: QueueBasedMessageHandler {
895929
try await withTimeout(options.buildSettingsTimeoutOrDefault) {
896930
await self.canonicalTarget(for: mainFile)
897931
} resultReceivedAfterTimeout: {
898-
await self.delegate?.fileBuildSettingsChanged([document])
932+
await self.filesBuildSettingsChangedDebouncer.scheduleCall([document])
899933
}
900934
}
901935
var languageForFile: Language
@@ -955,6 +989,10 @@ package actor BuildSystemManager: QueueBasedMessageHandler {
955989
}
956990
// Handle any messages the build system might have sent us while updating.
957991
await messageHandlingQueue.async(metadata: .stateChange) {}.valuePropagatingCancellation
992+
993+
// Ensure that we send out all delegate calls so that everybody is informed about the changes.
994+
await filesBuildSettingsChangedDebouncer.flush()
995+
await filesDependenciesUpdatedDebouncer.flush()
958996
}
959997

960998
/// The root targets of the project have depth of 0 and all target dependencies have a greater depth than the target

Sources/SKUtilities/Debouncer.swift

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
/// Debounces calls to a function/closure. If multiple calls to the closure are made, it allows aggregating the
1414
/// parameters.
15-
package actor Debouncer<Parameter> {
15+
package actor Debouncer<Parameter: Sendable> {
1616
/// How long to wait for further `scheduleCall` calls before committing to actually calling `makeCall`.
1717
private let debounceDuration: Duration
1818

@@ -36,7 +36,7 @@ package actor Debouncer<Parameter> {
3636
/// call can be debounced), the task that would commit the call (unless cancelled), the parameter with which this
3737
/// call should be made and the time at which the call should be made. Keeping track of the time ensures that we don't
3838
/// indefinitely debounce if a new `scheduleCall` is made every 0.4s but we debounce for 0.5s.
39-
private var inProgressData: (Parameter, ContinuousClock.Instant, Task<Void, Never>)?
39+
private var inProgressData: (parameter: Parameter, targetDate: ContinuousClock.Instant, task: Task<Void, Never>)?
4040

4141
package init(
4242
debounceDuration: Duration,
@@ -66,11 +66,20 @@ package actor Debouncer<Parameter> {
6666
} catch {
6767
return
6868
}
69-
inProgressData = nil
70-
await makeCall(parameter)
69+
await self.flush()
7170
}
7271
inProgressData = (parameter, ContinuousClock.now + debounceDuration, task)
7372
}
73+
74+
/// If any debounced calls are in progress, make them now, even if the debounce duration hasn't expired yet.
75+
package func flush() async {
76+
guard let inProgressDataValue = inProgressData else {
77+
return
78+
}
79+
inProgressData = nil
80+
let parameter = inProgressDataValue.parameter
81+
await makeCall(parameter)
82+
}
7483
}
7584

7685
extension Debouncer<Void> {

0 commit comments

Comments
 (0)