Skip to content

Use a ScheduledExecutorService to handle periodic tasks#433

Merged
johanvos merged 3 commits intogluonhq:mainfrom
johanvos:432-scheduler
Mar 13, 2026
Merged

Use a ScheduledExecutorService to handle periodic tasks#433
johanvos merged 3 commits intogluonhq:mainfrom
johanvos:432-scheduler

Conversation

@johanvos
Copy link
Copy Markdown
Contributor

Fix #432

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR refactors the internal SmartTimer utility to replace java.util.Timer/TimerTask with a ScheduledExecutorService-based implementation for periodic execution.

Changes:

  • Replace Timer/TimerTask with ScheduledExecutorService + ScheduledFuture.
  • Implement pause() via task cancellation and executor shutdown.
  • Implement start() via scheduleAtFixedRate(...) on a single-thread scheduled executor.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

Comment on lines +62 to +68
if (scheduler == null || scheduler.isShutdown()) {
scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r);
t.setDaemon(true);
return t;
});
scheduledTask = scheduler.scheduleAtFixedRate(task, delay, period, TimeUnit.MILLISECONDS);
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

start() schedules the provided task to run on the executor’s background thread. In this project, the only current use (paragraphListView::evictUnusedObjects) traverses JavaFX scene-graph nodes and mutates caches from RichTextAreaSkin, which must occur on the JavaFX Application Thread; running it off-thread can lead to IllegalStateException/undefined behavior. Consider wrapping the runnable so it executes via Platform.runLater(...) (or switching this utility to a JavaFX Timeline), so SmartTimer callbacks are always executed on the FX thread.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are not going to make assumptions on how clients of this code are working. There is no guarantee that all users of this code require running on the JavaFX appthread. Moreover, it's bad practice to use the JavaFX appthread until the very last moment where the scene graph is manipulated.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can there be a scenario where scheduler is null but scheduledTask is not? It doesn't harm to add another if statement before re-initializing scheduledTask ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is very unlikely, but theoretically possible if one thread invokes pause and another invokes start. In that case, it doesn't help adding an if-statement, as the system is unstable already.
In order to prevent the potential race condition, I made the start and pause synchronized. There is no scenario now where scheduler is null while scheduledTask is not.

Comment on lines +52 to +56
scheduledTask.cancel(true);
scheduledTask = null;
}
if (scheduler != null) {
scheduler.shutdownNow();
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pause() uses scheduledTask.cancel(true) and shutdownNow(), which will interrupt the timer thread if the task is currently running. This is a behavior change from the previous Timer.cancel() (which does not interrupt a running task) and can leave work half-done if the runnable performs multi-step updates. If interruption is not explicitly required, prefer non-interrupting cancellation (cancel(false)) and a graceful shutdown (shutdown()), or otherwise document/guarantee that task is interruption-safe.

Suggested change
scheduledTask.cancel(true);
scheduledTask = null;
}
if (scheduler != null) {
scheduler.shutdownNow();
scheduledTask.cancel(false);
scheduledTask = null;
}
if (scheduler != null) {
scheduler.shutdown();

Copilot uses AI. Check for mistakes.
@eugener
Copy link
Copy Markdown
Collaborator

eugener commented Mar 12, 2026

I think there is a pre-existing, FX thread violation here.

evictUnusedObjects reads scene graph nodes from the executor's background thread. The scene graph is not thread-safe. The old Timer code had the same bug. The task body needs Platform.runLater() wrapping, or SmartTimer should use a JavaFX ScheduledService/Timeline instead.

I would consider whether a JavaFX Timeline (which fires on the FX thread and has built-in pause/play/stop lifecycle) would be a simpler, safer replacement that avoids all the threading issues at once.

scheduledTask = null;
}
if (scheduler != null) {
scheduler.shutdownNow();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No awaitTermination after shutdownNow().
shutdownNow() returns immediately. If evictUnusedObjects is mid-execution when dispose() calls pause(), the task continues running against potentially nulled-out fields. We have to add

scheduler.shutdownNow();
scheduler.awaitTermination(100, TimeUnit.MILLISECONDS);

if ( timer != null ) {
timer.cancel();
timer = null;
public synchronized void pause() {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both start() and pause() are only ever called from the FX thread, so the synchronized adds cost without benefit in current usage

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no guarantee that those will not be called from other threads, so the cost of synchronizing is minimal compared to the risk

public synchronized void start( ) {
if (scheduler == null || scheduler.isShutdown()) {
scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r);
Copy link
Copy Markdown
Collaborator

@eugener eugener Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should set a name like "rta-cache-eviction" for debuggability

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that would limit the usage to the existing cache eviction (which TBH I'm not sure we need)

timer = null;
public synchronized void pause() {
if (scheduledTask != null) {
scheduledTask.cancel(true);
Copy link
Copy Markdown
Collaborator

@eugener eugener Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cancel(true) before shutdownNow() is redundant -- shutdownNow() already interrupts workers and drains the queue.

cancel(true) + shutdownNow() interrupts a running task, The original Timer.cancel() did not interrupt - it only prevented future executions. This is a behavior change that could leave evictUnusedObjects half-done (e.g. fonts evicted but images not). Should we use cancel(false) + shutdown()?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about that. I'm more worried about jobs that keep running, so I'm personally in favor of interrupting.

t.setDaemon(true);
return t;
});
scheduledTask = scheduler.scheduleAtFixedRate(task, delay, period, TimeUnit.MILLISECONDS);
Copy link
Copy Markdown
Collaborator

@eugener eugener Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For cache eviction, scheduleWithFixedDelay is semantically more correct (wait N after completion, not wall-clock intervals), comparing to scheduleAtFixedRate

@johanvos
Copy link
Copy Markdown
Contributor Author

I think there is a pre-existing, FX thread violation here.

evictUnusedObjects reads scene graph nodes from the executor's background thread. The scene graph is not thread-safe. The old Timer code had the same bug. The task body needs Platform.runLater() wrapping, or SmartTimer should use a JavaFX ScheduledService/Timeline instead.

I would consider whether a JavaFX Timeline (which fires on the FX thread and has built-in pause/play/stop lifecycle) would be a simpler, safer replacement that avoids all the threading issues at once.

True, but that is a different (and as you said pre-existing) bug.
I'd like to use as much standard JDK code as possible, instead of JavaFX code. There is nothing that forces this timer class to use JavaFX, so using JavaFX here would limit its usage.

@johanvos
Copy link
Copy Markdown
Contributor Author

I filed #436 as a follow-up. Since this PR fixes a critical memory issue, and it does not contain regression, I will merge this.
The discussion about eviction and what parts should be done on the FX Thread can be done in #436

@johanvos johanvos merged commit 433e8ed into gluonhq:main Mar 13, 2026
3 checks passed
@johanvos johanvos deleted the 432-scheduler branch March 13, 2026 08:00
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.

Replace TimerTask with ScheduledExecutorService

4 participants