Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

How a server dies

Verified against Minecraft 26.2 · Part III · /stop typed at the console, an exception out of the tick loop, and a tick that never ends — three endings that write three different amounts of your world to disk.

An admin types /stop. The command sets one boolean and returns, and the tick already in progress carries on to its end. Everything a player would call shutting down — the players written, the unloads drained, level.dat rotated, session.lock released — happens afterwards, inside the finally of the loop that just exited, on the same Server thread that was ticking mobs a moment ago. Which is what makes the second ending strange, and worth a lecture: a crash saves your world and the watchdog does not. An exception out of the tick loop lands in that same finally, so a server that dies of a bad block entity writes exactly what /stop writes. ServerWatchdog instead calls System.exit, which runs the Server Shutdown Thread hook, which calls MinecraftServer.halt with wait true and waits for the Server thread to finish — the very thread wedged in the tick that tripped the watchdog. That wait never returns. Ten seconds later the watchdog’s own scheduled Runtime.halt ends the JVM with nothing written.

The cast

classwhat it decidesthread
MinecraftServerthe three booleans, the tick loop, and the finally that is the whole of shutdownServer
StopCommandthat /stop is one call to MinecraftServer.halt with wait false, at Commands.LEVEL_OWNERSServer
DedicatedServerwhat wraps the base teardown: the JSON-RPC notification, Util.shutdownExecutors, and the side threads in DedicatedServer.onServerExitServer
ServerWatchdogthat a tick past max-tick-time is a dead server, and that the JVM goes with itServer Watchdog, a daemon
PlayerListthat every player is written before anyone is disconnectedServer
ServerChunkCache · ChunkMapwhen the world is quiet enough to stop draining, and what a flush save meansServer, with the writes on the IO pool
LevelStorageSource.LevelStorageAccesslevel.dat and the DirectoryLock on session.lockServer
Utilthe process-wide pools, and the three-second grace each of the two it shuts down getsany

Three endings, side by side

/stopa tick-loop crasha watchdog kill
what clears MinecraftServer.runningMinecraftServer.halt, with wait false from StopCommand, the JSON-RPC state service or the singleplayer host logging out, with wait true from the shutdown hook and the server GUI’s close buttonnothing: the loop is left by the throw, not by the conditionthe shutdown hook, eventually — System.exit runs it and it calls MinecraftServer.halt with wait true
does MinecraftServer.runServer’s finally runyes, on the Server threadyes, on the Server thread, after the crash reportno: the Server thread never leaves the tick
are players savedyes, PlayerList.saveAll then PlayerList.removeAllyes, identicallyno
are chunks savedyes: the unload drain, then MinecraftServer.saveAllChunks with flushyes, identicallyno — only what the last autosave happened to write
is level.dat writtenyes, LevelStorageSource.LevelStorageAccess.saveDataTagyes, identicallyno
is session.lock releasedyes, LevelStorageSource.LevelStorageAccess.close drops the DirectoryLockyes, identicallynot by the game — the OS drops it when the process dies
is a crash report writtennoyes, into crash-reports/, from MinecraftServer.constructOrExtractCrashReportyes, into crash-reports/, from ServerWatchdog.createWatchdogCrashReport, before the exit
what ends the JVMnothing explicit: the Server thread returns and no non-daemon thread is leftthe sameRuntime.halt from the watchdog’s own timer, ten seconds after its System.exit

The first two columns differ in three of the eight rows: what clears the flag, when the finally runs relative to the crash report, and whether there is a crash report at all. The third differs from the first in all eight, and the rest of this page is why.

/stop, in full

sequenceDiagram
    participant SC as StopCommand
    participant MS as MinecraftServer
    participant PL as PlayerList
    participant SL as ServerLevel
    participant SCC as ServerChunkCache
    participant LSA as LevelStorageSource.LevelStorageAccess
    participant Disk

    SC->>MS: halt with wait false, so running becomes false
    Note over MS: the tick in progress finishes, then the loop condition fails
    MS->>MS: stopped = true, then stopServer, from runServer's finally
    MS->>MS: PacketProcessor.close, then ServerConnectionListener.stop
    MS->>PL: saveAll, then removeAll
    PL->>Disk: each player's dat file, stats and advancements
    MS->>SL: noSave cleared on every level
    loop while any ChunkMap.hasWork
        MS->>SCC: the deadline is pushed one millisecond out, then deactivateTicketsOnClosing and tick
    end
    MS->>SL: saveAllChunks with flush, reaching ChunkMap.saveAllChunks
    SL->>Disk: region files, entities, poi, and the chunk_tickets saved data
    MS->>LSA: saveDataTag, level.dat built into a temp file
    LSA->>Disk: the temp file replaces level.dat, the old one rotated to level.dat_old
    MS->>MS: savedDataStorage.saveAndJoin, after level.dat and not before
    MS->>SL: close, ServerChunkCache.close then the entity manager
    MS->>LSA: close, releasing the DirectoryLock on session.lock
    MS->>MS: Util.shutdownExecutors, then onServerExit stops RCON and query
    Note over MS: the Server thread returns, and no non-daemon thread is left

The command is a flag

StopCommand registers one literal at Commands.LEVEL_OWNERS, sends commands.stop.stopping and calls MinecraftServer.halt with wait false. That call assigns MinecraftServer.running and returns. Nothing else happens on that line of the console: the tick that was running the command finishes its entities, its block entities and its packet flush, and the loop condition at the top of MinecraftServer.runServer fails on the next pass. Five other places on this side of the jar call the same method: the server GUI’s window-close listener and Main’s shutdown hook, both with wait true, so that they block until the Server thread has finished; the JSON-RPC management API’s MinecraftServerStateServiceImpl, which takes the flag from its caller; ServerCommonPacketListenerImpl.onDisconnect, which stops a singleplayer server when its host logs out; and GameTestServer, which halts itself when its test run is over. The client adds three more of its own.

Teardown itself is the loop’s finally. MinecraftServer.runServer sets MinecraftServer.stopped and calls MinecraftServer.stopServer, then calls MinecraftServer.onServerExit from a nested finally, so that a teardown which throws still stops the side threads. DedicatedServer.stopServer wraps the base with NotificationManager.serverShuttingDown before and Util.shutdownExecutors after.

The front door closes, the guests do not leave

PacketProcessor.close is first, before anything is even logged. Afterwards PacketProcessor.scheduleIfPossible refuses a packet a Netty thread has just decoded, and PacketProcessor.processQueuedPackets returns without draining — so packets already in the queue go the same way as the ones still arriving. Then ServerConnectionListener.stop closes the channels it bound, and only those: closing a Netty parent channel does not close the connections accepted through it. Live sessions are severed one step later by PlayerList.removeAll, with the multiplayer.disconnect.server_shutdown reason. A connection still in handshake, login or configuration has no ServerPlayer and is in neither list, so it is closed by neither, and simply dies with the process (players and sessions).

MinecraftServer.stopped also changes how work is accepted: MinecraftServer.executeIfPossible rejects anything new outright, and MinecraftServer.scheduleExecutables reports false, so a caller reaching BlockableEventLoop.execute from another thread runs its task inline rather than queueing it for a loop that has stopped looping.

Players before chunks, and never MinecraftServer.saveEverything

Shutdown does not use the save entry point everything else uses. MinecraftServer.saveEverything — autosave, /save-all, the integrated server’s pause, the JSON-RPC save call — is players then chunks in one call. MinecraftServer.stopServer does the two halves by hand: PlayerList.saveAll (each player’s data through PlayerDataStorage, plus their ServerStatsCounter and PlayerAdvancements), then PlayerList.removeAll, and only much later MinecraftServer.saveAllChunks. PlayerList.removeAll is thinner than its name: it disconnects each connection and nothing else, so the tickets those players hold are not what goes with them. What lets the next step finish is ServerChunkCache.deactivateTicketsOnClosing, called on every level inside the drain loop itself.

ServerLevel.noSave is then cleared on every level. /save-off does not survive /stop.

The drain

ChunkMap.hasWork is the question, and it is a broad one — nine things in one or: pending light, pending unloads, a non-empty updating map, POI work, chunks queued to drop, a non-empty unload queue, the worldgen and light dispatchers, and — the reason the loop terminates at all — DistanceManager.hasTickets. While any level answers yes, the server pushes the tick deadline one millisecond out, calls ServerChunkCache.deactivateTicketsOnClosing and ServerChunkCache.tick on each level, and runs MinecraftServer.waitUntilNextTick, which drains the main-thread queue and polls each level’s chunk executor for what is left of that millisecond.

One millisecond — each slice of the drain, so unloads and their saves proceed while the main-thread queue keeps taking chunk results.

TicketStorage.deactivateTicketsOnClosing moves every ticket except TicketType.UNKNOWN into a parked map. Parked tickets stop holding chunks — TicketStorage.hasTickets counts only the live map, which is how the loop ends — but they are not forgotten. TicketStorage.packTickets writes both maps, and the types that TicketType.persist, forced and portal, go into the level’s chunk_tickets saved data. On the next boot they load back parked and TicketStorage.activateAllDeactivatedTickets re-arms them during MinecraftServer.prepareLevels (tickets and loading, starting a server).

The flush save

MinecraftServer.saveAllChunks with flush true is the real save. The scoreboard is pushed into its saved data, then each ServerLevel.save: ServerLevel.saveLevelData joins the level’s own SavedDataStorage, and ServerChunkCache.save runs the distance manager once more before ChunkMap.saveAllChunks in flush mode. That last one is a loop, not a pass — every holder that ChunkHolder.wasAccessibleSinceLastSave, waited on with BlockableEventLoop.managedBlock until ChunkHolder.isReadyForSaving, repeated until a whole round saves nothing new — and then SectionStorage.flushAll for the POI sections, the unloads processed, and SimpleRegionStorage.synchronize joined so that the IOWorker has actually put the bytes down (chunk storage). Entities follow, through the level’s PersistentEntitySectionManager.

level.dat is written the same way at every save, flush or not: LevelStorageSource.LevelStorageAccess.saveDataTag builds the tag from PrimaryLevelData.createTag, wraps it under Data, writes it gzipped to a temp file in the world directory with NbtIo.writeCompressed, and Util.safeReplaceFile swaps it in, rotating the previous file to level.dat_old. Only after that does the server-wide SavedDataStorage get its SavedDataStorage.saveAndJoin. There are two tiers of saved data, and they are flushed at opposite ends of this section.

The closes, and the last thread

ServerLevel.close is ServerChunkCache.close — which saves once more, then closes the level’s saved data, the ThreadedLevelLightEngine and ChunkMap — followed by the entity manager. Then the server’s own SavedDataStorage (whose SavedDataStorage.close is itself a final SavedDataStorage.saveAndJoin), the MinecraftServer.ReloadableResources, and last LevelStorageSource.LevelStorageAccess.close, which releases the DirectoryLock. From that moment the world is openable by anything else.

Util.shutdownExecutors then stops Util.backgroundExecutor and Util.ioPool with a three-second grace each, inside DedicatedServer.stopServer and so before DedicatedServer.onServerExit — nothing may need a worker after that point. Util.nonCriticalIoPool is untouched, and survives only because its threads are daemons. DedicatedServer.onServerExit closes the text filter and the GUI and stops RconThread, QueryThreadGs4 and the ManagementServer. There is no System.exit anywhere on this path, and none is needed. Every other thread the server started is a daemon — the console reader, the Netty groups, the management server’s group, the watchdog — except the RCON and query threads, which GenericThread.stop joins here in one-second slices, and the IO pool’s workers, which went a step earlier with Util.shutdownExecutors. So when MinecraftServer.runServer returns, the Server thread is the last one left, and the JVM ends because there is nothing to keep it (the thread reference).

The crash that saves

MinecraftServer.runServer wraps the entire loop, DedicatedServer.initServer included. Anything thrown out of a tick — a block entity, a mob’s AI, a command, a packet handler that did not catch its own trouble — is logged, turned into a report, saved, and then falls into the same finally.

MinecraftServer.constructOrExtractCrashReport walks the cause chain and keeps the innermost ReportedException it finds, using that exception’s own report and noting the outer throwable under a Wrapped in category. A throwable with no ReportedException anywhere in it becomes a fresh report titled Exception in server tick loop. Either way MinecraftServer.fillSystemReport fills it in — the value of MinecraftServer.running, the player count and roster, the selected and available data packs, the enabled feature flags, the world-generation lifecycle, the world seed, and the contents of the server’s SuppressedExceptionCollector, which has been quietly watching every chunk load failure, chunk save failure and packet-handler exception since boot — keeping the latest eight of them in full, and a running count of the rest. DedicatedServer.fillServerSystemReport adds two lines, the modded status and the words Dedicated Server. The file lands in crash-reports/ under MinecraftServer.getServerDirectory, named by Util.getFilenameFormattedDateTime. MinecraftServer.onServerCrash is a hook the dedicated server does not override.

A crash on another thread arrives here too. BlockableEventLoop keeps one static parked report. Util.onThreadException — the uncaught-exception handler on every Util.backgroundExecutor and IO-pool thread — and GenerationChunkHolder, when a generation step completes exceptionally, both call BlockableEventLoop.relayDelayCrash, which parks the report or suppresses the new one under a report already parked. The next BlockableEventLoop.pollTask on a loop constructed with crash propagation throws it as a ReportedException, and the dedicated server is constructed that way. So a worker that dies does not die silently: it dies as a tick-loop crash, on the Server thread, at whatever moment that thread next looks for a task (the server tick). The integrated server is constructed with propagation off and hands its report to the client instead, through IntegratedServer.onServerCrash.

The watchdog that does not

sequenceDiagram
    participant SW as ServerWatchdog
    participant MS as MinecraftServer
    participant JVM
    participant Hook as Server Shutdown Thread

    Note over MS: wedged inside one tick, past max-tick-time
    SW->>MS: getNextTickTime, a deadline now far in the past
    SW->>SW: createWatchdogCrashReport, every thread dumped, the Server thread's stack grafted on
    SW->>MS: fillSystemReport, read off-thread while the tick is still running
    SW->>JVM: schedule Runtime.halt for ten seconds from now
    SW->>JVM: System.exit
    JVM->>Hook: run the shutdown hooks
    Hook->>MS: halt with wait true, running becomes false
    Hook->>MS: then waits for the Server thread, which is the wedged one
    MS-->>Hook: nothing, because the tick never returns
    Note over JVM: ten seconds later, Runtime.halt, nothing written

ServerWatchdog is a daemon thread started by DedicatedServer.initServer whenever DedicatedServer.getMaxTickLength is positive — that is DedicatedServerProperties.maxTickTime, max-tick-time, default sixty thousand milliseconds, and setting it to zero or less means the thread is never created and there is no backstop at all. Its loop is short: while MinecraftServer.isRunning, compare Util.getNanos with MinecraftServer.getNextTickTime, then sleep exactly until the earliest moment a violation could be true.

What it compares matters. MinecraftServer.getNextTickTime is the deadline the loop set for the tick it is running, not a timestamp of when that tick began, and the tick loop advances it before each tick and again when catching up after an overload. The watchdog fires when the server is that far past where it promised to be.

The report comes first, and it is the good part of the design. ServerWatchdog.createWatchdogCrashReport dumps every thread in the JVM, sorts them daemon-last, appends the lot as a Thread Dump category, and grafts the Server thread’s stack trace onto a synthetic error — so the report’s headline stack is the code that hung. MinecraftServer.fillSystemReport adds the usual, plus a Performance stats category holding the random-tick game rule and ServerLevel.getWatchdogStats for every level: players, entities by type, block entities by type, block and fluid tick counts, chunk source stats. All of it is read from another thread with no synchronisation whatsoever, off a world that is mid-tick — which is exactly the trade the class makes, because the alternative is asking a wedged thread for the answer. It goes to real stdout through Bootstrap.realStdoutPrintln and to crash-reports/ like any other report.

Then the deadlock. The watchdog schedules Runtime.halt on a timer and calls System.exit, which runs the registered shutdown hooks — including the “Server Shutdown Thread” that Main registered at boot, whose whole body is MinecraftServer.halt with wait true. That sets MinecraftServer.running false, which the wedged tick will never read, and then waits for the Server thread to end. It does not end. System.exit will not return until its hooks do, so the JVM sits there until the watchdog’s timer fires.

Ten seconds — from the watchdog’s System.exit to its Runtime.halt (ServerWatchdog.MAX_SHUTDOWN_TIME), and the world is not touched in any of them.

The watchdog is a liveness backstop, and reading it as a safe stop gets the guarantee backwards. Whether anything guards shutdown depends on how shutdown was reached, because the watchdog loops while MinecraftServer.running is true and only MinecraftServer.halt ever clears that flag. After /stop it is cleared before the drain begins, so a server stuck on “Saving chunks” is stuck with no watchdog left watching it. After a crash nothing clears it — the crash path never calls MinecraftServer.halt — so the watchdog is still counting. The drain loop survives that by resetting the deadline every pass; the flush save that follows it does not reset anything, so a slow enough save after a crash can be shot by the watchdog mid-write.

Ctrl-C, the window, and a singleplayer world

Ctrl-C at the console and a SIGTERM from a service manager are the same thing as far as the game is concerned: the JVM runs its shutdown hooks, and the one Main registered calls MinecraftServer.halt with wait true. The contrast with the watchdog is only in the health of the thread being waited for. Here the Server thread is fine, notices the cleared flag at the top of its next tick, and runs the entire /stop teardown while the hook thread waits. A Ctrl-C on a healthy server is a /stop, and the JVM does not exit until the world is on disk. The server GUI’s window-close button does the same thing from the AWT thread.

Singleplayer ends on a poll. Minecraft.disconnect — reached from Save and Quit, from a disconnect, and from Minecraft.emergencySave — closes the client’s connection, then calls IntegratedServer.halt with wait false. That override first uses BlockableEventLoop.executeBlocking to remove every player who is not the host, then clears MinecraftServer.running and stops the LAN pinger. The client then puts up a GenericMessageScreen reading Gui.SAVING_LEVEL and calls Minecraft.renderFrame in a loop while MinecraftServer.isShutdown is false. The “Saving world” screen is not a progress bar and is not driven by the server: it is a render loop spinning on one question, is the Server thread dead yet (the client loop). Closing the game window reaches the same place by a different road: Window.shouldClose makes Minecraft.runTick call Minecraft.stop, which ends the frame loop, and Main then calls Minecraft.exitWorldAndClose on its way out. The client’s “Client Shutdown Thread” is a JVM shutdown hook rather than that path — the backstop for a kill signal — and after an ordinary exit it finds Minecraft.singleplayerServer already null.

The integrated server never calls Util.shutdownExecutors. IntegratedServer.stopServer tears down published state and defers to the base. The client owns those pools and shuts them down at the very end of its own life, long after the world is closed.

Three booleans and a question

MinecraftServer.running is volatile and is the loop condition, and it is the only one of the three that anything sets in order to stop the server. MinecraftServer.stopped is a plain field set in the finally just before teardown, read from other threads through MinecraftServer.isStopped, and it is what closes the task queue. MinecraftServer.isReady is volatile, set at the bottom of every loop iteration, and is not what prints Done — that is logged in DedicatedServer.initServer, before the loop is entered.

MinecraftServer.isShutdown is the odd one out, and is not a field at all: it asks whether the Server thread is still alive. Nothing sets it, nothing can lie about it, and it stays false through the whole teardown whichever ending is running — which is precisely why the singleplayer client waits on it rather than on MinecraftServer.isStopped, which goes true at the start of teardown, when nothing has been saved yet.

What you lose if you kill the process

Ordinary autosave is MinecraftServer.saveEverything with neither flush nor force, every 6000 ticks — five minutes of game clock, floored at 100 ticks. It writes every player, every dirty chunk — ChunkMap.saveAllChunks clears ChunkMap.nextChunkSaveTime on the way in, so the ten-second per-chunk spacing that throttles ordinary saving never gates an autosave — and, unconditionally, level.dat, followed by a scheduled write of the level’s SavedData. Between the two, the spawn point and the world time (in level.dat) and the weather, the game rules and the world clocks (each its own SavedData file — weather, game_rules, world_clocks) on disk are never more than one autosave stale, even on a server nobody ever stops cleanly. Everything else — a chest filled two minutes ago, a mob that walked into a new chunk, an inventory change — lives in the LevelChunk and the entity sections until something saves them.

That gives an honest answer per ending. After /stop or a tick-loop crash, nothing is lost: the drain, the flush save and the joined IOWorker mean the process does not end until the bytes are down. The crash has one asterisk the clean stop does not — the watchdog is still armed all the way through it — but short of a save slow enough to trip it, both endings land the same. After a watchdog kill, or a kill -9, or a power cut, you lose everything since the last autosave, plus anything still queued inside the IOWorker — those writes run on Util.ioPool, and Runtime.halt does not wait for a pool.

What you never lose is access to the world. session.lock is an OS advisory lock taken with FileChannel.tryLock, not a file whose contents mean anything, and the operating system releases it when the process dies however it dies. A world left behind by a killed server opens on the next start. Copying a world directory copies a session.lock that means nothing at all.

Individual failures are quieter than any of this. A chunk that cannot be written calls MinecraftServer.reportChunkSaveFailure: logged, added to the SuppressedExceptionCollector that the next crash report will print, written out as its own ReportType.CHUNK_IO_ERROR file under debug/, and followed by a disk-space check. The tick does not stop, the server does not stop, and the only sign at the time is a line in the log.

Where to look

StopCommand · MinecraftServer.halt · MinecraftServer.runServer · MinecraftServer.constructOrExtractCrashReport · MinecraftServer.stopServer · MinecraftServer.saveAllChunks · MinecraftServer.saveEverything · ServerLevel.save · ServerChunkCache.save · ChunkMap.saveAllChunks · ChunkMap.hasWork · TicketStorage.deactivateTicketsOnClosing · LevelStorageSource.LevelStorageAccess.saveDataTag · LevelStorageSource.LevelStorageAccess.close · DirectoryLock · Util.shutdownExecutors · DedicatedServer.stopServer · DedicatedServer.onServerExit · ServerWatchdog.run · ServerWatchdog.createWatchdogCrashReport · Main (the shutdown hook) · BlockableEventLoop.relayDelayCrash · IntegratedServer.halt · Minecraft.disconnect


Rules: names, never code · how the system works, not how the code reads · newest version only · every backticked name passes tools/verify_names.py.