Use a ScheduledExecutorService to handle periodic tasks#433
Use a ScheduledExecutorService to handle periodic tasks#433johanvos merged 3 commits intogluonhq:mainfrom
Conversation
There was a problem hiding this comment.
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/TimerTaskwithScheduledExecutorService+ScheduledFuture. - Implement
pause()via task cancellation and executor shutdown. - Implement
start()viascheduleAtFixedRate(...)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.
| 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); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 ?
There was a problem hiding this comment.
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.
| scheduledTask.cancel(true); | ||
| scheduledTask = null; | ||
| } | ||
| if (scheduler != null) { | ||
| scheduler.shutdownNow(); |
There was a problem hiding this comment.
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.
| scheduledTask.cancel(true); | |
| scheduledTask = null; | |
| } | |
| if (scheduler != null) { | |
| scheduler.shutdownNow(); | |
| scheduledTask.cancel(false); | |
| scheduledTask = null; | |
| } | |
| if (scheduler != null) { | |
| scheduler.shutdown(); |
|
I think there is a pre-existing, FX thread violation here.
I would consider whether a JavaFX |
| scheduledTask = null; | ||
| } | ||
| if (scheduler != null) { | ||
| scheduler.shutdownNow(); |
There was a problem hiding this comment.
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() { |
There was a problem hiding this comment.
Both start() and pause() are only ever called from the FX thread, so the synchronized adds cost without benefit in current usage
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
should set a name like "rta-cache-eviction" for debuggability
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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()?
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
For cache eviction, scheduleWithFixedDelay is semantically more correct (wait N after completion, not wall-clock intervals), comparing to scheduleAtFixedRate
True, but that is a different (and as you said pre-existing) bug. |
Fix #432