Skip to content

Commit e509ee5

Browse files
committed
Stream Quick Access results per provider instead of one blocking pass
computeMatchingEntries queried every requiresUiAccess() provider in a single Display.syncExec and rendered only once that whole batch and the ranking had finished. The syncExec held the UI thread for the sum of all UI providers, so a slow provider (or a previous-pick restore resolved through a slow provider) froze the dialog and delayed results that were already available. Query providers one at a time (UI providers each in their own syncExec, non-UI on the worker) and re-render the table as each provider reports. The UI thread is released between providers, the first results show without waiting for the slowest, and a superseded compute unwinds at the next provider boundary instead of after the full batch. The assembled snapshot is unchanged: assembleEntries runs the same ranking, grouping, previous-pick dedup and perfect-match logic, and the final render is identical to the previous single pass. Give the compute job a COMPUTE_JOB_FAMILY so tests can join the streaming computation deterministically. QuickAccessDialogTest now waits for that family to drain and renders the pending result before asserting, instead of racing a fixed timeout against the per-provider round trips. This removes the macOS flake (issue #4009) and the same wall-clock race in the other result-dependent tests.
1 parent cb34c63 commit e509ee5

2 files changed

Lines changed: 171 additions & 128 deletions

File tree

bundles/org.eclipse.ui.workbench/eclipseui/org/eclipse/ui/internal/quickaccess/QuickAccessContents.java

Lines changed: 119 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import java.util.Map.Entry;
3232
import java.util.Objects;
3333
import java.util.concurrent.atomic.AtomicReference;
34+
import java.util.function.Consumer;
3435
import java.util.function.Function;
3536
import java.util.regex.Matcher;
3637
import java.util.regex.Pattern;
@@ -104,6 +105,12 @@ public abstract class QuickAccessContents {
104105
/** Trailing-edge debounce window collapsing a burst of shell resizes. */
105106
private static final int RESIZE_DEBOUNCE_MS = 100;
106107

108+
/**
109+
* Family of the background job that computes the matching entries. Lets tests
110+
* join on the streaming compute deterministically instead of polling the table.
111+
*/
112+
public static final Object COMPUTE_JOB_FAMILY = new Object();
113+
107114
protected Text filterText;
108115

109116
private final QuickAccessProvider[] providers;
@@ -181,15 +188,49 @@ public void updateProposals(String filterInput) {
181188
lastComputedItemCount = maxNumberOfItemsInTable;
182189
// Captured to detect a show-all toggle while this compute runs.
183190
final boolean requestShowAllMatches = showAllMatches;
184-
AtomicReference<List<QuickAccessEntry>[]> entries = new AtomicReference<>();
185-
final Job currentComputeEntriesJob = Job.create(computingMessage, theMonitor -> {
186-
entries.set(
187-
computeMatchingEntries(filter, perfectMatch, maxNumberOfItemsInTable, theMonitor));
188-
if (Policy.DEBUG_QUICK_ACCESS) {
189-
trace("Computed entries: " + toIds(entries)); //$NON-NLS-1$
191+
// Set once the first results are rendered, so the "computing" feedback never
192+
// flashes over content that has already streamed in.
193+
boolean[] rendered = { false };
194+
AtomicReference<UIJob> feedbackJobRef = new AtomicReference<>();
195+
final Job currentComputeEntriesJob = new Job(computingMessage) {
196+
@Override
197+
protected IStatus run(IProgressMonitor theMonitor) {
198+
// Query providers one at a time and re-render as each returns, so a slow
199+
// provider neither holds the UI thread for the whole batch nor delays the
200+
// results that are already available.
201+
computeMatchingEntries(filter, perfectMatch, maxNumberOfItemsInTable, theMonitor, entries -> {
202+
if (table.isDisposed()) {
203+
return;
204+
}
205+
display.asyncExec(() -> {
206+
if (table.isDisposed() || filterText == null || filterText.isDisposed()) {
207+
return;
208+
}
209+
// Apply only while the results still match the current filter and
210+
// show-all state, and this compute has not been superseded.
211+
if (theMonitor.isCanceled() || !filter.equals(filterText.getText().toLowerCase())
212+
|| requestShowAllMatches != showAllMatches) {
213+
return;
214+
}
215+
if (Policy.DEBUG_QUICK_ACCESS) {
216+
trace("Setting quick access contents: " + toIds(entries)); //$NON-NLS-1$
217+
}
218+
rendered[0] = true;
219+
UIJob feedbackJob = feedbackJobRef.get();
220+
if (feedbackJob != null) {
221+
feedbackJob.cancel();
222+
}
223+
refreshTable(perfectMatch, entries, filter);
224+
});
225+
});
226+
return theMonitor.isCanceled() ? Status.CANCEL_STATUS : Status.OK_STATUS;
190227
}
191-
return theMonitor.isCanceled() ? Status.CANCEL_STATUS : Status.OK_STATUS;
192-
});
228+
229+
@Override
230+
public boolean belongsTo(Object family) {
231+
return family == COMPUTE_JOB_FAMILY;
232+
}
233+
};
193234
currentComputeEntriesJob.setPriority(Job.INTERACTIVE);
194235
if (Policy.DEBUG_QUICK_ACCESS) {
195236
trace("Will compute proposals with Job: " + currentComputeEntriesJob); //$NON-NLS-1$
@@ -199,41 +240,24 @@ public void updateProposals(String filterInput) {
199240
UIJob computingFeedbackJob = new UIJob(table.getDisplay(), QuickAccessMessages.QuickAccessContents_computeMatchingEntries_displayFeedback_jobName) {
200241
@Override
201242
public IStatus runInUIThread(IProgressMonitor monitor) {
202-
if (currentComputeEntriesJob.getResult() == null && !monitor.isCanceled() && !table.isDisposed()) {
243+
if (!rendered[0] && currentComputeEntriesJob.getResult() == null && !monitor.isCanceled()
244+
&& !table.isDisposed()) {
203245
showHintText(computingMessage, grayColor);
204246
return Status.OK_STATUS;
205247
}
206248
return Status.CANCEL_STATUS;
207249
}
208250
};
251+
feedbackJobRef.set(computingFeedbackJob);
209252
currentComputeEntriesJob.addJobChangeListener(new JobChangeAdapter() {
210253
@Override
211254
public void done(IJobChangeEvent event) {
212255
if (Policy.DEBUG_QUICK_ACCESS) {
213256
trace("Compute entries Job result: " + event.getResult() + //$NON-NLS-1$
214257
", Job: " + currentComputeEntriesJob + //$NON-NLS-1$
215-
", last proposals Job: " + computeProposalsJob + //$NON-NLS-1$
216-
", entries: " + toIds(entries)); //$NON-NLS-1$
258+
", last proposals Job: " + computeProposalsJob); //$NON-NLS-1$
217259
}
218260
computingFeedbackJob.cancel();
219-
if (event.getResult().isOK() && !table.isDisposed()) {
220-
display.asyncExec(() -> {
221-
if (table.isDisposed() || filterText == null || filterText.isDisposed()) {
222-
return;
223-
}
224-
// Apply if the results still match the current filter and show-all,
225-
// not only when this is the last scheduled job.
226-
if (!filter.equals(filterText.getText().toLowerCase())
227-
|| requestShowAllMatches != showAllMatches) {
228-
return;
229-
}
230-
if (Policy.DEBUG_QUICK_ACCESS) {
231-
trace("Setting quick access contents: " + toIds(entries)); //$NON-NLS-1$
232-
}
233-
computingFeedbackJob.cancel();
234-
refreshTable(perfectMatch, entries.get(), filter);
235-
});
236-
}
237261
}
238262
});
239263
this.computeProposalsJob = currentComputeEntriesJob;
@@ -399,59 +423,46 @@ protected int getNumberOfFilteredResults() {
399423
}
400424

401425
/**
402-
* Queries every provider that requires UI access in a single UI-thread pass and
403-
* returns their sorted elements keyed by provider. Batching the queries avoids a
404-
* blocking worker-to-UI round-trip per provider on each keystroke.
426+
* Collects one provider's matching elements. Providers that require UI access are
427+
* queried on the display thread one at a time, so the UI thread is released
428+
* between providers instead of being held for the whole batch.
405429
*/
406-
private Map<QuickAccessProvider, List<QuickAccessElement>> computeUiProviderElements(String filter, String category,
430+
private List<QuickAccessElement> collectProviderElements(QuickAccessProvider provider, String filter,
407431
IProgressMonitor monitor) {
408-
List<QuickAccessProvider> uiProviders = new ArrayList<>();
409-
for (QuickAccessProvider provider : providers) {
410-
if (!provider.requiresUiAccess()) {
411-
continue;
412-
}
413-
boolean isPreviousPickProvider = provider instanceof PreviousPicksProvider;
414-
if (category != null && !category.equalsIgnoreCase(provider.getName()) && !isPreviousPickProvider) {
415-
continue;
416-
}
417-
if (!filter.isEmpty() || isPreviousPickProvider || showAllMatches) {
418-
uiProviders.add(provider);
419-
}
432+
if (!provider.requiresUiAccess()) {
433+
return Arrays.asList(provider.getElementsSorted(filter, monitor));
420434
}
421-
if (uiProviders.isEmpty() || monitor.isCanceled() || table == null || table.isDisposed()) {
422-
return Collections.emptyMap();
435+
if (monitor.isCanceled() || table == null || table.isDisposed()) {
436+
return Collections.emptyList();
423437
}
424-
Map<QuickAccessProvider, List<QuickAccessElement>> result = new HashMap<>();
438+
AtomicReference<List<QuickAccessElement>> result = new AtomicReference<>(Collections.emptyList());
425439
table.getDisplay().syncExec(() -> {
426-
for (QuickAccessProvider provider : uiProviders) {
427-
if (monitor.isCanceled()) {
428-
return;
429-
}
430-
try {
431-
result.put(provider, Arrays.asList(provider.getElementsSorted(filter, monitor)));
432-
} catch (RuntimeException e) {
433-
WorkbenchPlugin.log(e);
434-
}
440+
if (monitor.isCanceled() || table.isDisposed()) {
441+
return;
442+
}
443+
try {
444+
result.set(Arrays.asList(provider.getElementsSorted(filter, monitor)));
445+
} catch (RuntimeException e) {
446+
WorkbenchPlugin.log(e);
435447
}
436448
});
437-
return result;
449+
return result.get();
438450
}
439451

440452
/**
441-
* Returns a list per provider containing matching {@link QuickAccessEntry} that
442-
* should be displayed in the table given a text filter and a perfect match
443-
* entry that should be given priority. The number of items returned is affected
444-
* by {@link #getShowAllMatches()} and the size of the table's composite.
453+
* Queries each provider in turn and streams the matching entries to {@code render}
454+
* after every provider that contributes, so results appear as they are computed
455+
* rather than only once the slowest provider has finished. The number of entries
456+
* is affected by {@link #getShowAllMatches()} and the size of the table's
457+
* composite.
445458
*
446459
* @param filter the string text filter to apply, possibly empty
447460
* @param perfectMatch a quick access element that should be given priority or
448461
* <code>null</code>
449-
*
450-
* @return the array of lists (one per provider) contains the quick access
451-
* entries that should be added to the table, possibly empty
462+
* @param render receives one snapshot (a list per provider) per re-render
452463
*/
453-
private List<QuickAccessEntry>[] computeMatchingEntries(String filter, QuickAccessElement perfectMatch,
454-
int maxNumberOfItemsInTable, IProgressMonitor aMonitor) {
464+
private void computeMatchingEntries(String filter, QuickAccessElement perfectMatch, int maxNumberOfItemsInTable,
465+
IProgressMonitor aMonitor, Consumer<List<QuickAccessEntry>[]> render) {
455466
if (aMonitor == null) {
456467
aMonitor = new NullProgressMonitor();
457468
}
@@ -464,41 +475,58 @@ private List<QuickAccessEntry>[] computeMatchingEntries(String filter, QuickAcce
464475
}
465476
final String finalFilter = filter;
466477

467-
// Query providers that must run on the UI thread once, in a single UI pass,
468-
// rather than a separate blocking worker-to-UI round-trip per provider.
469-
Map<QuickAccessProvider, List<QuickAccessElement>> uiProviderElements = computeUiProviderElements(finalFilter,
470-
category, aMonitor);
471-
472-
// collect matching elements
478+
// Collect elements provider by provider and re-render after each, so results
479+
// stream into the table.
473480
LinkedHashMap<QuickAccessProvider, List<QuickAccessElement>> elementsForProviders = new LinkedHashMap<>(
474481
providers.length);
482+
boolean anyRendered = false;
475483
for (QuickAccessProvider provider : providers) {
476484
if (aMonitor.isCanceled()) {
477-
break;
485+
return;
478486
}
479487
boolean isPreviousPickProvider = provider instanceof PreviousPicksProvider;
480488
// skip if filter contains a category, and current provider isn't this category
481489
if (category != null && !category.equalsIgnoreCase(provider.getName()) && !isPreviousPickProvider) {
482490
continue;
483491
}
484-
if (!filter.isEmpty() || isPreviousPickProvider || showAllMatches) {
485-
List<QuickAccessElement> sortedElements;
486-
if (provider.requiresUiAccess()) {
487-
sortedElements = uiProviderElements.get(provider);
488-
} else {
489-
sortedElements = Arrays.asList(provider.getElementsSorted(filter, aMonitor));
490-
}
491-
if (sortedElements == null) {
492-
sortedElements = Collections.emptyList();
493-
}
494-
if (!(provider instanceof PreviousPicksProvider)) {
495-
for (QuickAccessElement element : sortedElements) {
496-
elementsToProviders.put(element, provider);
497-
}
492+
if (finalFilter.isEmpty() && !isPreviousPickProvider && !showAllMatches) {
493+
continue;
494+
}
495+
List<QuickAccessElement> sortedElements = collectProviderElements(provider, finalFilter, aMonitor);
496+
if (sortedElements == null) {
497+
sortedElements = Collections.emptyList();
498+
}
499+
if (!isPreviousPickProvider) {
500+
for (QuickAccessElement element : sortedElements) {
501+
elementsToProviders.put(element, provider);
498502
}
499-
elementsForProviders.put(provider, new ArrayList<>(sortedElements));
500503
}
504+
elementsForProviders.put(provider, new ArrayList<>(sortedElements));
505+
if (sortedElements.isEmpty()) {
506+
continue;
507+
}
508+
render.accept(assembleEntries(elementsForProviders, finalFilter, perfectMatch, maxNumberOfItemsInTable,
509+
aMonitor));
510+
anyRendered = true;
501511
}
512+
// Nothing matched (or only a perfect match exists): render once so the table
513+
// reflects the final result rather than stale content.
514+
if (!anyRendered && !aMonitor.isCanceled()) {
515+
render.accept(assembleEntries(elementsForProviders, finalFilter, perfectMatch, maxNumberOfItemsInTable,
516+
aMonitor));
517+
}
518+
}
519+
520+
/**
521+
* Builds the table snapshot (one list per provider, perfect match first) from the
522+
* elements gathered so far. Pure computation, safe to call repeatedly as more
523+
* providers report.
524+
*/
525+
private List<QuickAccessEntry>[] assembleEntries(
526+
LinkedHashMap<QuickAccessProvider, List<QuickAccessElement>> collected, String finalFilter,
527+
QuickAccessElement perfectMatch, int maxNumberOfItemsInTable, IProgressMonitor aMonitor) {
528+
LinkedHashMap<QuickAccessProvider, List<QuickAccessElement>> elementsForProviders = new LinkedHashMap<>(
529+
collected);
502530

503531
// Sort out the Previous Pick
504532
List<String> prevPickIds = new ArrayList<>();
@@ -966,8 +994,8 @@ public Table getTable() {
966994
return table;
967995
}
968996

969-
private static List<String> toIds(AtomicReference<List<QuickAccessEntry>[]> entries) {
970-
return Stream.of(entries.get()).flatMap(List::stream).map(e -> e.element.getId()).toList();
997+
private static List<String> toIds(List<QuickAccessEntry>[] entries) {
998+
return Stream.of(entries).filter(Objects::nonNull).flatMap(List::stream).map(e -> e.element.getId()).toList();
971999
}
9721000

9731001
private static void trace(String message) {

0 commit comments

Comments
 (0)