Skip to content

Fix client scheduler shutdown - #122

Merged
thebuildcraft merged 1 commit into
thebuildcraft:mainfrom
Maggesss:fix/client-scheduler-shutdown
Sep 12, 2026
Merged

thebuildcraft merged 1 commit into
thebuildcraft:mainfrom
Maggesss:fix/client-scheduler-shutdown

Conversation

@Maggesss

@Maggesss Maggesss commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Summary

This fixes a client shutdown issue where Map Link's scheduled update executor can keep the JVM alive after Minecraft has already finished shutting down.

Since the executor uses non-daemon worker threads and was never explicitly stopped, Minecraft can eventually trigger the client shutdown watchdog.

Fix

  • cancel the fast and slow scheduled update tasks during client shutdown
  • shut down the shared scheduler with shutdownNow()
  • register the appropriate shutdown event for Fabric, NeoForge and Forge

[edit]

  • use daemon threads so the scheduler cannot keep the JVM alive
  • synchronize rescheduling and shutdown to prevent race conditions

Reproduction

Originally reproduced with:

  • Minecraft 26.2
  • Map Link 4.5.0
  • Fabric Loader 0.19.3
  • Fabric API 0.157.0+26.2
  • Java 25.0.1
  • CachyOS Linux
  • ATLauncher 3.4.41.2
  • Fabulously Optimized 14.0.0-beta.4 with a few additional mods

Minecraft itself reaches the normal shutdown point:

[Render thread/INFO]: Stopping!

but the process stays alive until the shutdown watchdog kicks in:

Description: Client shutdown from post-main
java.lang.Error: Watchdog (Client shutdown from post-main)

The thread dump still shows the two ScheduledThreadPoolExecutor workers used by Map Link.

Testing

  • Ran the project's buildAll script successfully
  • Fabric and NeoForge builds completed successfully for all configured targets
  • Manually tested the Fabric 26.2 build: Minecraft now exits normally without triggering the shutdown watchdog
  • Forge isn't currently part of the released/build targets, the same shutdown handling was added there as well, but I didnt build or test it

@Maggesss

Maggesss commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

I looked a bit further into this because the initial fix felt dirty, and it seems like a regression introduced when the previous Timer(true) implementation was replaced with Executors.newScheduledThreadPool(2) in b80a0ff.

The old timer ran on a daemon thread, while the default thread factory used by newScheduledThreadPool creates non-daemon threads. This means the scheduler has technically been able to keep the JVM alive ever since that refactor.

MC v. 26.2 changed its shutdown behavior, which appears to have made this visible.

I've adjusted the fix to restore the previous daemon-thread behavior while still explicitly shutting down the scheduler as part of the client shutdown lifecycle.
=> The executor is cleaned up normally, but it also can't prevent JVM termination if the lifecycle hook is skipped

There are other temporary executors/threads in the tile download path, could be worth auditing separately, but unrelated to this persistent scheduler leak.

@thebuildcraft

Copy link
Copy Markdown
Owner

Thanks for all your work! I will review and merge it latest next week because I do want to release an update this month. Then I will include this :)

@thebuildcraft

Copy link
Copy Markdown
Owner

I have now reviewed and tested your changes and came to the conclusion that:

private static final Object schedulerLock = new Object();
private static final AtomicInteger schedulerThreadId = new AtomicInteger();
private static final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2, runnable -> {
	Thread thread = new Thread(runnable, "MapLink-Scheduler-" + schedulerThreadId.getAndIncrement());
	thread.setDaemon(true);
	return thread;
});

(with getAndIncrement to start at 0)
and

synchronized (schedulerLock) {
	if (ms == timerDelay || scheduledSlowUpdateTask == null) return;
	timerDelay = ms;
	scheduledSlowUpdateTask.cancel(true);
	scheduledSlowUpdateTask = scheduler.scheduleAtFixedRate(slowUpdateTask::run, 0, timerDelay, TimeUnit.MILLISECONDS);
}

(without the scheduler.isShutdown)
is enough. Everything else is redundant because the JVM will terminate the deamon threads anyway.

Do you want to change your fix to only have these changes or should i commit it and add you as co-author? :)
And thanks again for noticing the problem in the firstplace!

@Maggesss

Copy link
Copy Markdown
Contributor Author

I have now reviewed and tested your changes and came to the conclusion that:

private static final Object schedulerLock = new Object();
private static final AtomicInteger schedulerThreadId = new AtomicInteger();
private static final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2, runnable -> {
	Thread thread = new Thread(runnable, "MapLink-Scheduler-" + schedulerThreadId.getAndIncrement());
	thread.setDaemon(true);
	return thread;
});

(with getAndIncrement to start at 0) and

synchronized (schedulerLock) {
	if (ms == timerDelay || scheduledSlowUpdateTask == null) return;
	timerDelay = ms;
	scheduledSlowUpdateTask.cancel(true);
	scheduledSlowUpdateTask = scheduler.scheduleAtFixedRate(slowUpdateTask::run, 0, timerDelay, TimeUnit.MILLISECONDS);
}

(without the scheduler.isShutdown) is enough. Everything else is redundant because the JVM will terminate the deamon threads anyway.

Do you want to change your fix to only have these changes or should i commit it and add you as co-author? :) And thanks again for noticing the problem in the firstplace!

Sure, makes sense ^^

changed to getAndIncrement() and removed the scheduler.isShutdown() check + the extra shutdown method.

Thanks for testing it and for the feedback!

@Maggesss
Maggesss force-pushed the fix/client-scheduler-shutdown branch from b67cd97 to ebdbae9 Compare September 12, 2026 19:50
@thebuildcraft
thebuildcraft merged commit 3fbab76 into thebuildcraft:main Sep 12, 2026
@thebuildcraft

Copy link
Copy Markdown
Owner

Thank you 😄

@Maggesss
Maggesss deleted the fix/client-scheduler-shutdown branch September 12, 2026 20:18
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.

2 participants