Don't block completion for items from unimported namespaces#59045
Don't block completion for items from unimported namespaces#59045genlu merged 29 commits intodotnet:mainfrom
Conversation
3094fc3 to
ccf660a
Compare
The computation of completion items is divided into two tasks: 1. "Core" items(i.e.non - expanded) which should be included in the list regardless of the selection of expander. Right now this includes all items except those from unimported namespaces. 2 .Expanded items which only show in the completion list when expander is selected, or by default if the corresponding features are enabled. Right now only items from unimported namespaces are associated with expander. #1 is the essence of completion so we'd always wait until its task is completed and return the results. However, because we have a really tight perf budget in completion, and computing those items in #2 could be expensive especially in a large solution (e.g.requires syntax / symbol indices and/ or runs in OOP,) we decide to kick off the computation in parallel when completion is triggered, but only include its results if it's completed by the time task #1 is completed, otherwise we don't wait on it and just return items from #1 immediately. Task #2 will still be running in the background (until session is dismissed/committed,) and we'd check back to see if it's completed whenever we have a chance to update the completion list, i.e.when user typed another character, a filter was selected, etc. If so, those items will be added as part of the refresh. The reason of adopting this approach is we want to minimize typing delays. There are two ways user might perceive a delay in typing. First, they could see a delay between typing a character and completion list being displayed if they want to examine the items available. Second, they might be typing continuously w/ o paying attention to completion list, and simply expect the completion to do the "right thing" when a commit char is typed(e.g.commit "cancellationToken" when typing 'can$TAB$'). However, the commit could be delayed if completion is still waiting on the computation of all available items, which manifests as UI delays and in worst case timeouts in commit which results in unexpected behavior(e.g.typing 'can$TAB$' results in a 250ms UI freeze and still ends up with "can" instead of "cancellationToken".) This approach would ensure the computation of #2 will not be the cause of such delays, with the obvious trade off of potentially not providing expanded items until later(or never) in a completion session even if the feature is enabled.Note that in most cases we'd expect task #2 to finish in time and complete result would be available from the start of the session.However, even in the case only partial result is returned at the start, we still believe this is acceptable given how critical perf is in typing scenario. Additionally, expanded items are usually considered complementary. The need for them only rise occasionally(it's rare when users need to add imports,) and when they are needed, our hypothesis is because of their more intrusive nature(adding an import to the document) users would more likely to contemplate such action thus typing slower before commit and / or spending more time examining the list, which give us some opportunities to still provide those items later before they are truly required. In this commit we have to handle adding those delayed expanded items into completion list in Roslyn. This is because the `CompletionContext.IsIncomplete` flag isn't fully supported in classic mode. Will need to move to that API once it's implemented properly.
Was broken by previous refactoring
So we can always wait for unimported items and force index creation
|
@CyrusNajmabadi @sharwell Please let me know if you have any concerns about this change to import completion. |
|
@genlu Remind me tomorrow to read through this with fresh eyes 😪 |
|
This is a clever design. |
|
There's still a couple things I'd need to confirm/add, but I think the PR is ready for review. |
d1a8e7e to
c5f5314
Compare
c5f5314 to
742bb0f
Compare
@genlu Is there a way to turn this off? The IDE keeps adding "features" to reduce the accuracy of IntelliSense lists and they are the absolute worst. The only thing I care about here is predictability in the list. There is no UI delay long enough that it warrants invalidating the results what I'm typing. I need to be able to type part of an identifier, and know with certainty that after I press |
There was a problem hiding this comment.
This feature needs a UI option to disable it. When disabled, completion would always wait for both tasks to complete before considering all results available. An acceptable alternative is to bind it to the existing "Responsive code completion" editor option, which is enabled by default.
|
Waiting on #59125 to go in. |
| } | ||
| else if (session.Properties.TryGetProperty<ImmutableArray<VSCompletionItem>>(CombinedSortedList, out var combinedSortedList)) | ||
| { | ||
| // Always use the previously saved combined list if available. |
There was a problem hiding this comment.
i don't understand ths... why are we even putting this into the properties... instead of just having this be in our model?
| AddPropertiesToSession(session, list, triggerLocation); | ||
| return context; | ||
| } | ||
| else if (!session.TextView.Options.GetOptionValue(DefaultOptions.ResponsiveCompletionOptionId)) |
There was a problem hiding this comment.
dumb question. is this type safe to use on a BG thread? i won't block the PR on this, but can yuo check with amadeus?
There was a problem hiding this comment.
Oh, that's a good point, I wasn't paying attention to the fact that it was called on UI thread.
There was a problem hiding this comment.
Move it to UI thread just in case. Will check with editor team on this
src/EditorFeatures/Core/Implementation/IntelliSense/AsyncCompletion/CompletionSource.cs
Outdated
Show resolved
Hide resolved
| /// </param> | ||
| /// <returns>Roslyn completion trigger</returns> | ||
| internal static RoslynTrigger GetRoslynTrigger(EditorAsyncCompletionData.CompletionTrigger trigger, SnapshotPoint triggerLocation) | ||
| public static RoslynTrigger GetRoslynTrigger(EditorAsyncCompletionData.CompletionTrigger trigger, SnapshotPoint triggerLocation) |
There was a problem hiding this comment.
😕 Why are these changing? This file should have worked the same without any changes.
There was a problem hiding this comment.
I initially made some change in this file which was later deleted. And I changed accessibility to make them consistent with other place in the code while doing so (but did not revert it after)
src/EditorFeatures/Core/Implementation/IntelliSense/AsyncCompletion/CompletionSource.cs
Show resolved
Hide resolved
| // For type import completion | ||
| // For import completion | ||
| SessionWithTypeImportCompletionEnabled, | ||
| SessionWithImportCompletionBlocking, |
There was a problem hiding this comment.
some comments on these might be useful. For example is SessionWithImportCompletionBlocking mean that type import completion completed by the time the normal completion items were returned?
And does SessionWithDelayedImportCompletionIncludedInUpdate count the number of times that delayed import completion items were added later? So to determine the number of times import completion failed to be included, we would subtract that value from SessionWithImportCompletionDelayed?
There was a problem hiding this comment.
SessionWithImportCompletionBlocking records the number of sessions where import completion is enabled and we wait for them to complete (because the editor's responsive completion option is disabled). Subtracting it from SessionWithTypeImportCompletionEnabled gives us the number of sessions the delay could happen (if import completion is slow.)
You are correct about the other two :)
| return AsyncCompletionData.CompletionContext.Empty; | ||
| // OK, expand item is enabled but we shouldn't block completion on its results. | ||
| // Kick off expand item calculation first in background. | ||
| Stopwatch stopwatch = new(); |
There was a problem hiding this comment.
would this type be at all useful here to avoid creating a stopwatch each time?
https://sourceroslyn.io/#Microsoft.CodeAnalysis/InternalUtilities/SharedStopwatch.cs,12
There was a problem hiding this comment.
❗ Yes this needs to use SharedStopwatch. Also I thought we had a code style rule employed to prevent creating new value types with (Stopwatch is a reference type)new().
There was a problem hiding this comment.
Hmm, then we probably need additional flag and lock since we might check the elapsed time before it even started.
| // Expanded item task still running. Save it to the session and return non-expanded items immediately. | ||
| // Also start the stopwatch since we'd like to know how long it takes for the expand task to finish | ||
| // after core providers completed (instead of how long it takes end-to-end). | ||
| stopwatch.Start(); |
There was a problem hiding this comment.
This doesn't really affect the user experience, but this can race with the read in expandedItemsTask right?
Because if the stopwatch is started after the if elapsedMs > 0 then we record a delayed completion event, but don't get the number of ticks delayed in telemetry.
Would it be easier to just record both the normal completion task time and the expanded completion task time, then when logging telemetry just wait for both to finish and calculate the diff?
There was a problem hiding this comment.
Yes, there'd be race, I ignored that since I didn't think we need high level of accuracy for this. Now that you mentioned it, I think it'd probably better to log it regardless of whether stopwatch is started (remove the >0 check), which would give us a better idea as of how much longer import completion task takes (even if it's finished almost in time but ended up not included.)
The issue with recording time for both tasks is we trigger the import completion task first, and we are more interested in the additional time needed after the regular task completed. Maybe it doesn't matter, I'm not sure
There was a problem hiding this comment.
Ah - so in the cases where it finishes before you just record a 0 additional time? That seems to make sense and would be straightforward.
There was a problem hiding this comment.
Yup, and we have another property to tell us how many time it's delayed
| session.Properties.RemoveProperty(ExpandedItemsTask); | ||
| var (expandedContext, expandedCompletionList) = await expandedItemsTask.ConfigureAwait(false); | ||
| AddPropertiesToSession(session, expandedCompletionList, triggerLocation: null); | ||
| return expandedContext; |
There was a problem hiding this comment.
taht this is a Get method that mutates properties is a bit oogy. can the data just flow outwards, and someone else can be responsible for this?
+1 from me
i'm not really sure i get why we return the expandedContext but adding the expactedComlpetionList as a property to the session...
This was confusing to me as well - but I think the expanded completion list is not actually being added to the properties bag here, right? Its just taking certain properties from the list and adding them. It might be good to explicitly pass in what you're adding to the properties bag?
| var combinedList = itemsBuilder.ToImmutable(); | ||
|
|
||
| // Add expanded items into a combined list, and save it to be used for future updates during the same session. | ||
| session.Properties[CombinedSortedList] = combinedList; |
There was a problem hiding this comment.
This is probably just my unfamiliarity with how roslyn completion is handling this - is the session properties bag the only way to pass data between calls for the same session? Is there an analogous type on the roslyn side that we could just put it on to retrieve later?
that's correct.
I have filed #59262 for this. Maybe we can use a CWT instead of those property bags? I definitely need to better understand editor code first. |
sharwell
left a comment
There was a problem hiding this comment.
Two more files left to review ...
| End Sub | ||
|
|
||
| Public Overrides Async Function ProvideCompletionsAsync(context As CompletionContext) As Task | ||
| Await checkpoint.Task.ConfigureAwait(False) |
There was a problem hiding this comment.
😕 Just use ManualResetEventSlim?
| var combinedList = itemsBuilder.MoveToImmutable(); | ||
|
|
||
| // Add expanded items into a combined list, and save it to be used for future updates during the same session. | ||
| session.Properties[CombinedSortedList] = combinedList; |
There was a problem hiding this comment.
😕 Does it not retain the previous filter? We have to add them each time?
There was a problem hiding this comment.
The FilterStates we returned from this update would be retained by the model in editor, and subsequent call to update would pass the combined one to us, so we only need to remember the combined IntialList (which is created when session is first triggered and doesn't change over updates)
src/EditorFeatures/Core/Implementation/IntelliSense/AsyncCompletion/ItemManager.cs
Outdated
Show resolved
Hide resolved
src/EditorFeatures/Core/Implementation/IntelliSense/AsyncCompletion/ItemManager.cs
Outdated
Show resolved
Hide resolved
src/EditorFeatures/Core/Implementation/IntelliSense/AsyncCompletion/CompletionSource.cs
Show resolved
Hide resolved
src/EditorFeatures/Core/Implementation/IntelliSense/AsyncCompletion/CompletionSource.cs
Outdated
Show resolved
Hide resolved
src/EditorFeatures/Core/Implementation/IntelliSense/AsyncCompletion/CompletionSource.cs
Outdated
Show resolved
Hide resolved
src/EditorFeatures/Core/Implementation/IntelliSense/AsyncCompletion/ItemManager.cs
Outdated
Show resolved
Hide resolved
| { | ||
| // the task of expanded item is completed, get the result and combine it with result of non-expanded items. | ||
| var (expandedContext, expandedCompletionList) = await expandedItemsTask.ConfigureAwait(false); | ||
| AddPropertiesToSession(session, expandedCompletionList, triggerLocation); |
There was a problem hiding this comment.
Why do we call AddPropertiesToSession here when we already called it a few lines above?
| } | ||
|
|
||
| static void AddPropertiesToSession(IAsyncCompletionSession session, CompletionList completionList, SnapshotPoint triggerLocation) | ||
| private static void AddPropertiesToSession(IAsyncCompletionSession session, CompletionList completionList, SnapshotPoint triggerLocation) |
There was a problem hiding this comment.
💡 Can you add a comment on this explaining in fair detail what its purpose is?
There was a problem hiding this comment.
➡️ Looks like this didn't change as part of this PR (just indentation).
There was a problem hiding this comment.
I will leave it as is since I'm hoping to get rid the property bag usage in a follow up.

Address #52814
I have extracted the refactoring part in to a separate PR #59125, which hopefully would make code review at least slightly easier.
In addition to the changes in #59125, this PR divided the computation of completion items into two tasks:
(1) is the essence of completion so we'd always wait until its task is completed and return the results. However, because we have a really tight perf budget in completion, and computing those items in (2) could be expensive especially in a large solution (e.g.requires syntax / symbol indices and/ or runs in OOP,) we decide to kick off the computation in parallel when completion is triggered, but only include its results if it's completed by the time task (1) is completed, otherwise we don't wait on it and just return items from (1) immediately. Task (2) will still be running in the background (until session is dismissed/committed,) and we'd check back to see if it's completed whenever we have a chance to update the completion list, i.e.when user typed another character, a filter was selected, etc. If so, those items will be added as part of the refresh.
Update: this new behavior is now tied to "responsive completion" option, which is enabled by default. It means we'd not wait for unimported items to be calculated by default, but user can change it to "always wait" by disable "responsive completion"
The reason of adopting this approach is we want to minimize typing delays. There are two ways user might perceive a delay in typing. First, they could see a delay between typing a character and completion list being displayed if they want to examine the items available. Second, they might be typing continuously w/ o paying attention to completion list, and simply expect the completion to do the "right thing" when a commit char is typed(e.g.commit "cancellationToken" when typing 'can$TAB$'). However, the commit could be delayed if completion is still waiting on the computation of all available items, which manifests as UI delays and in worst case timeouts in commit which results in unexpected behavior(e.g. typing 'can$TAB$' results in a 250ms UI freeze and still ends up with "can" instead of "cancellationToken".)
This approach would ensure the computation of (2) will not be the cause of such delays, with the obvious trade off of potentially not providing expanded items until later(or never) in a completion session even if the feature is enabled. Note that in most cases we'd expect task (2) to finish in time and complete result would be available after typing one additional chracter after triggering, if not from the start of the session (this needs some further validation, if it turns out this is not true but only a short wait is what required to fix it, then we should consider it). However, even in the case only partial result is returned at the start, we still believe this is acceptable given how critical perf is in typing scenario. Additionally, expanded items are usually considered complementary. The need for them only rise occasionally (it's rare when users need to add imports,) and when they are needed, our hypothesis is because of their more intrusive nature(adding an import to the document) users would more likely to contemplate such action thus typing slower before commit and / or spending more time examining the list, which give us some opportunities to still provide those items later before they are truly required.
In this commit we have to handle adding those delayed expanded items into completion list in Roslyn. This is because the
CompletionContext.IsIncompleteflag isn't fully supported in classic mode. Will need to move to that API once it's implemented properly.todo: