-
Notifications
You must be signed in to change notification settings - Fork 109
Fix UI flickering in code mining by adding wait/notify reconciliation coordination #2698
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Fix UI flickering in code mining by adding wait/notify reconciliation coordination #2698
Conversation
jjohnstn
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Works as stated and logic seems reasonable. I noticed in eclipse-platform/eclipse.platform.ui#2786 (comment) that you mention that you aren't sure about waiting for reconcile to finish and @laeubi states that waiting is never a good idea with a CompletableFuture, however, this patch does wait in the CompletableFuture. I didn't see you respond to @laeubi's comment. Can you comment here?
|
That seems like a good change |
|
@jjohnstn yes that's right. But on the other hand of course in a legacy code base one might need to do it incrementally. To explain the problem here what will happen is that there is a thread allocated from the common pool just for waiting. This decreases the possible concurrency level and the jvm will allocate possibly more threads... not so good but I would assume that reconciliation is hopefully fast, 30 seconds sounds way to much. Also keep in mind that spurious wakeups are possible to 30sec is just the maximum it will wait... you usually will need a while loop in this case. Also currently it seems to use ONE lock for ALL editors... meaning one (unrelated) editor can block others (e.g. two windows showing different source files). I just want to outline how it would suppose to work in the best case:
That way one does not need an additional waiting thread or additional resource allocation and no need for basic synchronize primitives. |
laeubi
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@tobiasmelcher thanks for your proposal I think it goes in the right direction already, I have added some comments below.
| final ISourceViewerExtension5 sourceViewer= fSourceViewer; // take a copy as this can be null-ed in the meantime | ||
| if (sourceViewer != null) { | ||
| reconciledViewers.add(sourceViewer); | ||
| CompletableFuture<CompilationUnit> future= toBeReconciledViewers.remove(sourceViewer); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should it really be removed here?
You are calling updateCodeMinings in line 55 what then would need the future result?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I changed the sequence with [610a874] - now updateCodeMinings() ist first called and the future is therefore available.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It still feels wrong to remove it. What if no reconcilation happens but code minings are refreshed? Or the reconilation completes before we are asked to provide codminings?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What if no reconcilation happens but code minings are refreshed?
Such case where the trigger for code mining updates is "external" to document change is not to be handled by the reconciler then, but by some other, 3rd-party strategy that one could plug-in.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
And IMO, unless it causes a problem, I think keeping the futures can be useful, for example to allow cancellation or chaining later on.
| public void aboutToBeReconciled() { | ||
| // interrupt code minings if modification occurs | ||
| reconciledViewers.remove(fSourceViewer); | ||
| toBeReconciledViewers.computeIfAbsent(fSourceViewer, unused->{return new CompletableFuture<CompilationUnit>();}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would more would use compute here and then check if there is already one (and cancel it) and creating always a fresh one.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
toBeReconciledViewers.compute(fSourceViewer, (viewer, existingFuture) -> {
if (existingFuture != null) {
existingFuture.cancel(false);
}
return new CompletableFuture<CompilationUnit>();
});
I tried with this code; but then it started again flickering. Reason: the canceled future now no longer returns the code minings and therefore the framework is removing the previous code minings provided by the Java implementation.
Maybe, I didn't get what you want to achieve. But I think that calling "cancel" will introduce the flickering again. Please convince me from the opposite if you think that calling "cancel" is a good idea.
| CompletableFuture<CompilationUnit> future= toBeReconciledViewers.get(viewer); | ||
| return future; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You should use computeIfAbsent to make sure you always get one, even if I assume due to the code flow it should probably not be needed. This also should include checking if we already have some data (is that possible?) and then retrun an already completed future.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if "null" is returned then the source is reconciled and there is no need to wait for the future.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is not a good design. If the source is reconciled it should here return an already completed future with the result, and the client should only ever call this method and there is no need to distinguish the different cases.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If the source is reconciled it should here return an already completed future with the result
I agree here that returning toBeReconciledViewers,computeIfAbsent(viewer, v -> unitFor(v)) would be cleaner and the impact on memory would be extremely low.
|
|
||
| public static boolean isReconciled(ISourceViewerExtension5 viewer) { | ||
| return reconciledViewers.contains(viewer); | ||
| return !toBeReconciledViewers.containsKey(viewer); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This would also be a computeifabsent, then checking if the future was completed.
That way there is always one future holding the last state and we can check it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The map contains only the viewers which should be reconciled. If reconciliation is done the entry will be removed from the map. If we keep the futures forever in the map, we need to find a time when to remove the entry from the map. This makes it more complex from my point of view. The current implementation of isReconciled returns the correct value. I don't see the need to change it. Please again convince me if you think that the current implementation is not correct.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we keep the futures forever in the map, we need to find a time when to remove the entry from the map.
The time is actually when uninstalling the viewer.
Please again convince me if you think that the current implementation is not correct.
Se previous comments. The current logic is all prone to timing issues and as code mining (or even reconcilation!) can happen on different threads any time and might finish at random times.
Therefore there should only be two states:
- The future is already completed because reconciliation has happened before
- The future is currently pending because reconciliation is running.
and all this must be synchronized by the concurrent map.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The time is actually when uninstalling the viewer.
I agree with that.
| } | ||
|
|
||
| private List<? extends ICodeMining> computeCodeMinings(ITextViewer viewer, IProgressMonitor monitor) { | ||
| monitor.isCanceled(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Even though in the original code, this line looks odd, what it its purpose?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Following monitor implementation is used for the code minings:
class CancellationExceptionMonitor extends NullProgressMonitor {
@Override
public boolean isCanceled() {
boolean canceled= super.isCanceled();
if (canceled) {
throw new CancellationException();
}
return canceled;
}
}
I agree it looks odd. The code existed before, I have not invented it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Alright this is really odd...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
IIRC, the trick was that in order to properly honor cancellation inside a future, then the future must throw a CancellationException (so that it completesExceptionally and so on).
But maybe here it would be much cleaner to skip this particular CacnellationExceptionMonitor and inline the if (monitor.isCanceled()) throw new CancellationException(); iside the JavaElementCodeMiningProvider directly; or maybe have a method
void checkCancelled(IProgressMonitor monitor) throws CancellationException {
if (monitor.isCancelled()) throw new CancellationException();
}and replace all calls to monitor.isCancelled() by checkCancelled(monitor)
| CompletableFuture<CompilationUnit> future= JavaCodeMiningReconciler.getFuture(v); | ||
| if (future != null) { | ||
| return future.thenApplyAsync(ast -> { | ||
| return computeCodeMinings(viewer,monitor); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is the AST unused here?
It feels we should call collectMinings directly here
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done with [610a874]
| monitor.isCanceled(); | ||
| return minings; | ||
| } catch (JavaModelException e) { | ||
| // Should never occur |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The usual pattern would be something like
future = new CompletableFuture<>
//use whatever Threadpool you think sufficient e.g the commonPool
execute(()-> {
try {
//compute your possible throwing operations here
// if progress monitor is cancel cancel and exit early
future.complete(...)
} catch (Exception) {
future.completeExceptionally(...)
}
}
return future;
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this really needed? Please give me more details and the concrete code how it should look like when JavaCodeMiningReconciler.getFuture() returns null.
I think that the current implementation fixes the original problem and works fine.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is not strictly required I think I just saw the TODO here...
laeubi
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
See some comments below
| } | ||
|
|
||
| private List<? extends ICodeMining> computeCodeMinings(ITextViewer viewer, IProgressMonitor monitor) { | ||
| monitor.isCanceled(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Alright this is really odd...
| monitor.isCanceled(); | ||
| return minings; | ||
| } catch (JavaModelException e) { | ||
| // Should never occur |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is not strictly required I think I just saw the TODO here...
| collectLineContentCodeMinings(unit, minings); | ||
| if (viewer instanceof ISourceViewerExtension5) { | ||
| ISourceViewerExtension5 codeMiningViewer= (ISourceViewerExtension5)viewer; | ||
| if (!JavaCodeMiningReconciler.isReconciled(codeMiningViewer)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This check looks odd to me... we already have the unit, we have calculated the codeminigs and now we are suddenly not able anymore?
This then would just cancel the future effectively what we previously discovered seems not working well....?
@jjohnstn any idea whats the purpose of this? For me this all looks highly questionable and prone to timing issues, the document might not be reconciled just right after this method returns, it might get (randomly) already reconciled before we complete here and so on...
| final ISourceViewerExtension5 sourceViewer= fSourceViewer; // take a copy as this can be null-ed in the meantime | ||
| if (sourceViewer != null) { | ||
| reconciledViewers.add(sourceViewer); | ||
| CompletableFuture<CompilationUnit> future= toBeReconciledViewers.remove(sourceViewer); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It still feels wrong to remove it. What if no reconcilation happens but code minings are refreshed? Or the reconilation completes before we are asked to provide codminings?
| CompletableFuture<CompilationUnit> future= toBeReconciledViewers.get(viewer); | ||
| return future; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is not a good design. If the source is reconciled it should here return an already completed future with the result, and the client should only ever call this method and there is no need to distinguish the different cases.
|
|
||
| public static boolean isReconciled(ISourceViewerExtension5 viewer) { | ||
| return reconciledViewers.contains(viewer); | ||
| return !toBeReconciledViewers.containsKey(viewer); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we keep the futures forever in the map, we need to find a time when to remove the entry from the map.
The time is actually when uninstalling the viewer.
Please again convince me if you think that the current implementation is not correct.
Se previous comments. The current logic is all prone to timing issues and as code mining (or even reconcilation!) can happen on different threads any time and might finish at random times.
Therefore there should only be two states:
- The future is already completed because reconciliation has happened before
- The future is currently pending because reconciliation is running.
and all this must be synchronized by the concurrent map.
|
Thank you so much for taking the time to review my work and share your feedback; I really appreciate your effort and support. in JavaElementCodeMiningProvider, and explaining why monitor.setCanceled(true) needs to be called so that the CodeMiningFramework does not delete previous code minings from the Java provider and flickering occurs. |
laeubi
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the improvements I think its overall going in a good direction, I added two more comments here.
| final ISourceViewerExtension5 sourceViewer= fSourceViewer; | ||
| if (editor != null && sourceViewer != null) { | ||
| sourceViewer.updateCodeMinings(); | ||
| // Use remove() to ensure atomicity |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure why you are so eager here to remove the future, I also don't understand why it is needed for atomicity?
Currently, whenever a reconcilation is request, the old feature is canceld and a new one is generated and this is one atomic operation in the map, so consumers will see a consistent state.
Then hopefully at some point in time the reconcilation is done and that feature is completed, so why then remove it?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
// CURRENT CODE - PROBLEMATIC
CompletableFuture<ITypeRoot> future = typeRootFutureByViewer.get(sourceViewer);
if (future != null && ast.getTypeRoot() != null) {
future.complete(ast.getTypeRoot()); // ❌ Race: future might be replaced after get()
}
Problem: Between get() and complete(), another thread could call aboutToBeReconciled(), which cancels and replaces the future. You’d then complete a cancelled/stale future.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this an AI suggestion or something you actually observe? The suggestion here makes no sense of course the future might be canceled but this is on purpose and our computation is going vanished and a new cycle then starts and calls (again) this method where we complete the then current one...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I could somehow follow the explanation. If it makes no sense, I will then replace the remove call.
|
|
||
| public static boolean isReconciled(ISourceViewerExtension5 viewer) { | ||
| return reconciledViewers.contains(viewer); | ||
| public static boolean isReconciled(ITextEditor editor) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we should be able to remove this method entierly, it should never bee needed as we use the CF anyways.
73fe5ab to
52a105a
Compare
coordination Problem: Previous implementation returned emptyList() immediately when reconciliation wasn't complete, causing code minings to disappear/reappear during AST reconciliation → UI flickering Solution: - Add shared RECONCILE_LOCK and reconciledViewers Set for coordination - Code mining provider now *waits* (up to 30s) for reconciliation to complete - Reconciler signals completion with notifyAll() in reconciled() - Remove viewer from reconciled set in aboutToBeReconciled() during edits
52a105a to
b7da8a6
Compare
laeubi
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It looks almost good to me, just some more comments of some details from my side.
| if (ast == null) { | ||
| return; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This check should be included in the future completion part as otherwise the future is maybe never completed.
| if (future != null && !future.isDone() && ast.getTypeRoot() != null) { | ||
| future.complete(ast.getTypeRoot()); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this needs to be split up in two parts, maybe like this:
| if (future != null && !future.isDone() && ast.getTypeRoot() != null) { | |
| future.complete(ast.getTypeRoot()); | |
| } | |
| if (future != null && !future.isDone()) { | |
| if(ast !=null && ast.getTypeRoot() != null) { | |
| future.complete(ast.getTypeRoot()); | |
| } else { | |
| future.cancel(false); | |
| } | |
| } |
but I'm not sure in wich cases the AST (or typeroot) can be null, so maybe one should then better complete it with a suitable Exception message instead?
| ITypeRoot unit= EditorUtility.getEditorInputJavaElement(editor, true); | ||
| CompletableFuture<ITypeRoot> future= new CompletableFuture<>(); | ||
| future.complete(unit); | ||
| return future; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As you check for null above, should you check here for null as well?
| return CompletableFuture.completedFuture(Collections.emptyList()); | ||
| ITextEditor textEditor= super.getAdapter(ITextEditor.class); | ||
| CompletableFuture<ITypeRoot> future= JavaCodeMiningReconciler.getFuture(textEditor); | ||
| return future.thenApplyAsync(typeRoot -> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You might want to use orTimeout here just in case so we do not block forever.
Problem: Previous implementation returned emptyList() immediately when reconciliation wasn't complete, causing code minings to disappear/reappear during AST reconciliation → UI flickering
Solution:
This pull request will solve following reported issue eclipse-platform/eclipse.platform.ui#2786 (comment)