@@ -30,6 +30,10 @@ public class WarmupService {
3030
3131 // Poll interval in milliseconds - frequent polling catches movement reliably
3232 private static final long POLL_INTERVAL_MS = 100 ;
33+
34+ // Maximum warmup lifetime in nanos - safety net to clear stuck entries
35+ // (e.g. world.execute() silently dropped the task, world was destroyed, etc.)
36+ private static final long MAX_WARMUP_LIFETIME_NANOS = TimeUnit .SECONDS .toNanos (60 );
3337
3438 private final ScheduledExecutorService poller ;
3539 private final Map <UUID , PendingWarmup > pending = new ConcurrentHashMap <>();
@@ -118,7 +122,10 @@ public void startWarmup(PlayerRef player, Vector3d startPosition, int warmupSeco
118122 }
119123
120124 private void ensurePollerRunning () {
121- if (pollTask != null && !pollTask .isCancelled ()) {
125+ // Check isDone() as well as isCancelled(): if the poller task threw an
126+ // uncaught exception, ScheduledExecutorService silently kills it.
127+ // isDone() returns true but isCancelled() returns false in that case.
128+ if (pollTask != null && !pollTask .isCancelled () && !pollTask .isDone ()) {
122129 return ;
123130 }
124131 pollTask = poller .scheduleAtFixedRate (
@@ -128,30 +135,61 @@ private void ensurePollerRunning() {
128135 }
129136
130137 private void pollWarmups () {
131- if (pending .isEmpty ()) {
132- return ;
133- }
134-
135- for (PendingWarmup warmup : pending .values ()) {
136- if (warmup .cancelled ) {
137- pending .remove (warmup .playerUuid );
138- continue ;
138+ // CRITICAL: This method runs inside ScheduledExecutorService.scheduleAtFixedRate().
139+ // If ANY uncaught exception escapes, the executor permanently and silently kills
140+ // the recurring task. The entire warmup system goes dead — no player can teleport.
141+ // Every code path must be wrapped in try/catch.
142+ try {
143+ if (pending .isEmpty ()) {
144+ return ;
139145 }
140146
141- World world = warmup .world ;
142- if (world == null ) {
143- pending .remove (warmup .playerUuid );
144- continue ;
147+ long now = System .nanoTime ();
148+
149+ for (PendingWarmup warmup : pending .values ()) {
150+ try {
151+ if (warmup .cancelled ) {
152+ pending .remove (warmup .playerUuid );
153+ continue ;
154+ }
155+
156+ // Safety net: clear warmups that have been pending far too long.
157+ // This catches cases where world.execute() silently drops the task,
158+ // the world was destroyed but the reference is non-null, etc.
159+ if ((now - warmup .createdAtNanos ) > MAX_WARMUP_LIFETIME_NANOS ) {
160+ logger .warning ("[Warmup] Clearing stale warmup for " + warmup .playerUuid
161+ + " (" + warmup .commandName + ") - exceeded max lifetime" );
162+ pending .remove (warmup .playerUuid );
163+ continue ;
164+ }
165+
166+ World world = warmup .world ;
167+ if (world == null ) {
168+ pending .remove (warmup .playerUuid );
169+ continue ;
170+ }
171+
172+ // Execute the tick on the game thread
173+ world .execute (() -> tickWarmup (warmup ));
174+ } catch (Exception e ) {
175+ // Per-warmup catch: one bad warmup must never kill processing for others.
176+ // This typically fires when world.execute() throws because the world
177+ // was destroyed between the null check and the execute call.
178+ logger .warning ("[Warmup] Error polling warmup for " + warmup .playerUuid
179+ + " (" + warmup .commandName + "): " + e .getMessage () + " - removing" );
180+ pending .remove (warmup .playerUuid );
181+ }
145182 }
146183
147- // Execute the tick on the game thread
148- world .execute (() -> tickWarmup (warmup ));
149- }
150-
151- // Stop poller if no more pending warmups
152- if (pending .isEmpty () && pollTask != null ) {
153- pollTask .cancel (false );
154- pollTask = null ;
184+ // Stop poller if no more pending warmups
185+ if (pending .isEmpty () && pollTask != null ) {
186+ pollTask .cancel (false );
187+ pollTask = null ;
188+ }
189+ } catch (Exception e ) {
190+ // Outer catch: absolute last resort. If we somehow get here, log it
191+ // but do NOT let the exception propagate or the poller dies permanently.
192+ logger .severe ("[Warmup] Unexpected error in pollWarmups: " + e .getMessage ());
155193 }
156194 }
157195
@@ -287,6 +325,7 @@ private static class PendingWarmup {
287325 final Ref <EntityStore > playerRef ;
288326 final Vector3d startPos ;
289327 final long endTimeNanos ;
328+ final long createdAtNanos ;
290329 final Runnable onComplete ;
291330 final String commandName ;
292331 final World world ;
@@ -302,6 +341,7 @@ private static class PendingWarmup {
302341 this .playerRef = playerRef ;
303342 this .startPos = startPos ;
304343 this .endTimeNanos = endTimeNanos ;
344+ this .createdAtNanos = System .nanoTime ();
305345 this .onComplete = onComplete ;
306346 this .commandName = commandName ;
307347 this .world = world ;
0 commit comments