Skip to content

Conversation

@tobiasmelcher
Copy link
Contributor

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

This pull request will solve following reported issue eclipse-platform/eclipse.platform.ui#2786 (comment)

Copy link
Contributor

@jjohnstn jjohnstn left a 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?

@mickaelistria
Copy link
Contributor

That seems like a good change

@laeubi
Copy link
Contributor

laeubi commented Dec 16, 2025

@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:

  1. There should be some kind of method to acquire the current state (from the code it seems one is interested actually in IJavaElement[]) as a completable future from the editor, this would then work like this:
  • synchronize on some lock object
  • return the current (maybe already completed) CF to the caller
  • if reconcilation is required, synchronize on the lock object, create a new CF (that will be completed one reconciliation is done with the expected result)
  1. the codeminig (and probably others) would then fetch the CF and use an thenCompose(Async) (or similar) to transform the result (IJavaElement[]) to the final result (e.g. ICodeMinings[]) and return that.

That way one does not need an additional waiting thread or additional resource allocation and no need for basic synchronize primitives.

@tobiasmelcher
Copy link
Contributor Author

@laeubi I’ve implemented the CompletableFuture-based changes you recommended with [3ab1bb4]. Could you please review the updated code when you have a chance?

Copy link
Contributor

@laeubi laeubi left a 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);
Copy link
Contributor

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?

Copy link
Contributor Author

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.

Copy link
Contributor

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?

Copy link
Contributor

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.

Copy link
Contributor

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>();});
Copy link
Contributor

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.

Copy link
Contributor Author

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.

Comment on lines 60 to 61
CompletableFuture<CompilationUnit> future= toBeReconciledViewers.get(viewer);
return future;
Copy link
Contributor

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.

Copy link
Contributor Author

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.

Copy link
Contributor

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.

Copy link
Contributor

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);
Copy link
Contributor

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.

Copy link
Contributor Author

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.

Copy link
Contributor

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:

  1. The future is already completed because reconciliation has happened before
  2. The future is currently pending because reconciliation is running.

and all this must be synchronized by the concurrent map.

Copy link
Contributor

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();
Copy link
Contributor

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?

Copy link
Contributor Author

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.

Copy link
Contributor

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...

Copy link
Contributor

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);
Copy link
Contributor

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

Copy link
Contributor Author

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
Copy link
Contributor

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;

Copy link
Contributor Author

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.

Copy link
Contributor

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...

Copy link
Contributor

@laeubi laeubi left a 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();
Copy link
Contributor

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
Copy link
Contributor

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)) {
Copy link
Contributor

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);
Copy link
Contributor

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?

Comment on lines 60 to 61
CompletableFuture<CompilationUnit> future= toBeReconciledViewers.get(viewer);
return future;
Copy link
Contributor

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);
Copy link
Contributor

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:

  1. The future is already completed because reconciliation has happened before
  2. The future is currently pending because reconciliation is running.

and all this must be synchronized by the concurrent map.

@tobiasmelcher
Copy link
Contributor Author

Thank you so much for taking the time to review my work and share your feedback; I really appreciate your effort and support.
I believe [8143972] addresses the changes you suggested. To make sure I can learn and improve more effectively, it would be extremely helpful if future feedback could include more concrete examples or pointers to the exact code changes. For instance, highlighting something like:

.handle((result, ex) -> {
    if (ex instanceof CompletionException ce &&
        ce.getCause() instanceof CancellationException) {
        monitor.setCanceled(true);
    }
    return result;
}

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.
Maybe you already tried to convey this, but I didn’t fully understand it at first. Your guidance would make the process much easier and help me deliver better results.
Thanks again for your collaboration!

Copy link
Contributor

@laeubi laeubi left a 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
Copy link
Contributor

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?

Copy link
Contributor Author

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.

Copy link
Contributor

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...

Copy link
Contributor Author

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) {
Copy link
Contributor

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.

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
Copy link
Contributor

@laeubi laeubi left a 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.

Comment on lines 54 to 56
if (ast == null) {
return;
}
Copy link
Contributor

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.

Comment on lines 62 to 64
if (future != null && !future.isDone() && ast.getTypeRoot() != null) {
future.complete(ast.getTypeRoot());
}
Copy link
Contributor

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:

Suggested change
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?

Comment on lines 73 to 76
ITypeRoot unit= EditorUtility.getEditorInputJavaElement(editor, true);
CompletableFuture<ITypeRoot> future= new CompletableFuture<>();
future.complete(unit);
return future;
Copy link
Contributor

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 -> {
Copy link
Contributor

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants