Skip to content

Commit 0bb08ad

Browse files
committed
Implement per-file analysis queues, don't analyse stale contents
1 parent 548704c commit 0bb08ad

File tree

2 files changed

+71
-50
lines changed

2 files changed

+71
-50
lines changed

src/PowerShellEditorServices/Services/Analysis/AnalysisService.cs

Lines changed: 64 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,6 @@ internal static string GetUniqueIdFromDiagnostic(Diagnostic diagnostic)
9494

9595
private Lazy<PssaCmdletAnalysisEngine> _analysisEngineLazy;
9696

97-
private CancellationTokenSource _diagnosticsCancellationTokenSource;
98-
9997
private readonly string _pssaModulePath;
10098

10199
private string _pssaSettingsFilePath;
@@ -135,37 +133,32 @@ public void StartScriptDiagnostics(ScriptFile[] filesToAnalyze)
135133

136134
EnsureEngineSettingsCurrent();
137135

138-
// If there's an existing task, we want to cancel it here;
139-
CancellationTokenSource cancellationSource = new();
140-
CancellationTokenSource oldTaskCancellation = Interlocked.Exchange(ref _diagnosticsCancellationTokenSource, cancellationSource);
141-
if (oldTaskCancellation is not null)
142-
{
143-
try
144-
{
145-
oldTaskCancellation.Cancel();
146-
oldTaskCancellation.Dispose();
147-
}
148-
catch (Exception e)
149-
{
150-
_logger.LogError(e, "Exception occurred while cancelling analysis task");
151-
}
152-
}
153-
154136
if (filesToAnalyze.Length == 0)
155137
{
156138
return;
157139
}
158140

159-
Task analysisTask = Task.Run(() => DelayThenInvokeDiagnosticsAsync(filesToAnalyze, _diagnosticsCancellationTokenSource.Token), _diagnosticsCancellationTokenSource.Token);
160-
161-
// Ensure that any next corrections request will wait for this diagnostics publication
141+
// Analyze each file independently with its own cancellation token
162142
foreach (ScriptFile file in filesToAnalyze)
163143
{
164-
CorrectionTableEntry fileCorrectionsEntry = _mostRecentCorrectionsByFile.GetOrAdd(
165-
file,
166-
CorrectionTableEntry.CreateForFile);
144+
CorrectionTableEntry fileAnalysisEntry = _mostRecentCorrectionsByFile.GetOrAdd(file, CorrectionTableEntry.CreateForFile);
167145

168-
fileCorrectionsEntry.DiagnosticPublish = analysisTask;
146+
CancellationTokenSource cancellationSource = new();
147+
CancellationTokenSource oldTaskCancellation = Interlocked.Exchange(ref fileAnalysisEntry.CancellationSource, cancellationSource);
148+
if (oldTaskCancellation is not null)
149+
{
150+
try
151+
{
152+
oldTaskCancellation.Cancel();
153+
oldTaskCancellation.Dispose();
154+
}
155+
catch (Exception e)
156+
{
157+
_logger.LogError(e, "Exception occurred while cancelling analysis task");
158+
}
159+
}
160+
161+
_ = Task.Run(() => DelayThenInvokeDiagnosticsAsync(file, fileAnalysisEntry), cancellationSource.Token);
169162
}
170163
}
171164

@@ -222,7 +215,9 @@ public async Task<IReadOnlyDictionary<string, IEnumerable<MarkerCorrection>>> Ge
222215
}
223216

224217
// Wait for diagnostics to be published for this file
218+
#pragma warning disable VSTHRD003
225219
await corrections.DiagnosticPublish.ConfigureAwait(false);
220+
#pragma warning restore VSTHRD003
226221

227222
return corrections.Corrections;
228223
}
@@ -345,18 +340,20 @@ private void ClearOpenFileMarkers()
345340
}
346341
}
347342

348-
internal async Task DelayThenInvokeDiagnosticsAsync(ScriptFile[] filesToAnalyze, CancellationToken cancellationToken)
343+
internal async Task DelayThenInvokeDiagnosticsAsync(ScriptFile fileToAnalyze, CorrectionTableEntry fileAnalysisEntry)
349344
{
350-
if (cancellationToken.IsCancellationRequested)
351-
{
352-
return;
353-
}
345+
CancellationToken cancellationToken = fileAnalysisEntry.CancellationSource.Token;
346+
Task previousAnalysisTask = fileAnalysisEntry.DiagnosticPublish;
354347

355-
try
356-
{
357-
await Task.Delay(_analysisDelayMillis, cancellationToken).ConfigureAwait(false);
358-
}
359-
catch (TaskCanceledException)
348+
// Shouldn't start a new analysis task until:
349+
// 1. Delay/debounce period finishes (i.e. user has not started typing again)
350+
// 2. Previous analysis task finishes (runspace pool is capped at 1, so we'd be sitting in a queue there)
351+
Task debounceAndPrevious = Task.WhenAll(Task.Delay(_analysisDelayMillis), previousAnalysisTask ?? Task.CompletedTask);
352+
353+
// In parallel, we will keep an eye on our cancellation token
354+
Task cancellationTask = Task.Delay(Timeout.Infinite, cancellationToken);
355+
356+
if (cancellationTask == await Task.WhenAny(debounceAndPrevious, cancellationTask).ConfigureAwait(false))
360357
{
361358
return;
362359
}
@@ -368,16 +365,35 @@ internal async Task DelayThenInvokeDiagnosticsAsync(ScriptFile[] filesToAnalyze,
368365
// on. It makes sense to send back the results from the first
369366
// delay period while the second one is ticking away.
370367

371-
foreach (ScriptFile scriptFile in filesToAnalyze)
368+
TaskCompletionSource<ScriptFileMarker[]> placeholder = new TaskCompletionSource<ScriptFileMarker[]>();
369+
370+
// Try to take the place of the currently running task by atomically writing our task in the fileAnalysisEntry.
371+
Task valueAtExchange = Interlocked.CompareExchange(ref fileAnalysisEntry.DiagnosticPublish, placeholder.Task, previousAnalysisTask);
372+
373+
if (valueAtExchange != previousAnalysisTask) {
374+
// Some other task has managed to jump in front of us i.e. fileAnalysisEntry.DiagnosticPublish is
375+
// no longer equal to previousAnalysisTask which we noted down at the start of this method
376+
_logger.LogDebug("Failed to claim the running analysis task spot");
377+
return;
378+
}
379+
380+
// Successfully claimed the running task slot, we can actually run the analysis now
381+
try
372382
{
373-
ScriptFileMarker[] semanticMarkers = await AnalysisEngine.AnalyzeScriptAsync(scriptFile.Contents).ConfigureAwait(false);
383+
ScriptFileMarker[] semanticMarkers = await AnalysisEngine.AnalyzeScriptAsync(fileToAnalyze.Contents).ConfigureAwait(false);
384+
placeholder.SetResult(semanticMarkers);
374385

375386
// Clear existing PSScriptAnalyzer markers (but keep parser errors where the source is "PowerShell")
376387
// so that they are not duplicated when re-opening files.
377-
scriptFile.DiagnosticMarkers.RemoveAll(m => m.Source == "PSScriptAnalyzer");
378-
scriptFile.DiagnosticMarkers.AddRange(semanticMarkers);
388+
fileToAnalyze.DiagnosticMarkers.RemoveAll(m => m.Source == "PSScriptAnalyzer");
389+
fileToAnalyze.DiagnosticMarkers.AddRange(semanticMarkers);
379390

380-
PublishScriptDiagnostics(scriptFile);
391+
PublishScriptDiagnostics(fileToAnalyze);
392+
}
393+
catch (Exception ex)
394+
{
395+
placeholder.SetException(ex);
396+
throw;
381397
}
382398
}
383399

@@ -480,8 +496,6 @@ protected virtual void Dispose(bool disposing)
480496
{
481497
_analysisEngineLazy.Value.Dispose();
482498
}
483-
484-
_diagnosticsCancellationTokenSource?.Dispose();
485499
}
486500

487501
disposedValue = true;
@@ -501,19 +515,25 @@ public void Dispose() =>
501515
/// wait for analysis to finish if needed,
502516
/// and then fetch the corrections set in the table entry by PSSA.
503517
/// </summary>
504-
private class CorrectionTableEntry
518+
internal class CorrectionTableEntry : IDisposable
505519
{
506520
public static CorrectionTableEntry CreateForFile(ScriptFile _) => new();
507521

508522
public CorrectionTableEntry()
509523
{
510524
Corrections = new ConcurrentDictionary<string, IEnumerable<MarkerCorrection>>();
511525
DiagnosticPublish = Task.CompletedTask;
526+
CancellationSource = new CancellationTokenSource();
512527
}
513528

514529
public ConcurrentDictionary<string, IEnumerable<MarkerCorrection>> Corrections { get; }
515530

516-
public Task DiagnosticPublish { get; set; }
531+
public Task DiagnosticPublish;
532+
533+
public CancellationTokenSource CancellationSource;
534+
535+
public void Dispose() =>
536+
CancellationSource?.Dispose();
517537
}
518538
}
519539
}

test/PowerShellEditorServices.Test/Services/Symbols/PSScriptAnalyzerTests.cs

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT License.
33

4-
using System.Threading;
54
using System.Threading.Tasks;
65
using Microsoft.Extensions.Logging.Abstractions;
76
using Microsoft.PowerShell.EditorServices.Hosting;
@@ -69,15 +68,16 @@ public async Task CanLoadPSScriptAnalyzerAsync()
6968
public async Task DoesNotDuplicateScriptMarkersAsync()
7069
{
7170
ScriptFile scriptFile = workspaceService.GetFileBuffer("untitled:Untitled-1", script);
72-
ScriptFile[] scriptFiles = { scriptFile };
71+
AnalysisService.CorrectionTableEntry fileAnalysisEntry =
72+
AnalysisService.CorrectionTableEntry.CreateForFile(scriptFile);
7373

7474
await analysisService
75-
.DelayThenInvokeDiagnosticsAsync(scriptFiles, CancellationToken.None);
75+
.DelayThenInvokeDiagnosticsAsync(scriptFile, fileAnalysisEntry);
7676
Assert.Single(scriptFile.DiagnosticMarkers);
7777

7878
// This is repeated to test that the markers are not duplicated.
7979
await analysisService
80-
.DelayThenInvokeDiagnosticsAsync(scriptFiles, CancellationToken.None);
80+
.DelayThenInvokeDiagnosticsAsync(scriptFile, fileAnalysisEntry);
8181
Assert.Single(scriptFile.DiagnosticMarkers);
8282
}
8383

@@ -86,10 +86,11 @@ public async Task DoesNotClearParseErrorsAsync()
8686
{
8787
// Causing a missing closing } parser error
8888
ScriptFile scriptFile = workspaceService.GetFileBuffer("untitled:Untitled-2", script.TrimEnd('}'));
89-
ScriptFile[] scriptFiles = { scriptFile };
89+
AnalysisService.CorrectionTableEntry fileAnalysisEntry =
90+
AnalysisService.CorrectionTableEntry.CreateForFile(scriptFile);
9091

9192
await analysisService
92-
.DelayThenInvokeDiagnosticsAsync(scriptFiles, CancellationToken.None);
93+
.DelayThenInvokeDiagnosticsAsync(scriptFile, fileAnalysisEntry);
9394

9495
Assert.Collection(scriptFile.DiagnosticMarkers,
9596
(actual) =>

0 commit comments

Comments
 (0)