How a server dies
Verified against Minecraft 26.3 · Part III ·
/stoptyped at the console, an exception out of the tick loop, and a tick that never ends — three endings, and what each writes of your world to disk.
An admin types /stop. The command sets one boolean and returns, and the
lap 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 in the tick 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 no save run.
Three endings, side by side
/stop | a crash in the tick | a watchdog kill | |
|---|---|---|---|
what clears MinecraftServer.running | MinecraftServer.halt, called by StopCommand with wait false and by five other callers below | nothing: the loop is left by the throw, not by the condition | the shutdown hook, eventually — System.exit runs it and it calls MinecraftServer.halt with wait true |
does MinecraftServer.runServer’s finally run | yes, on the Server thread | yes, on the Server thread, after the crash report | no: the Server thread never leaves the tick |
| are players saved | yes, PlayerList.saveAll then PlayerList.removeAll | yes, identically | no |
| are chunks saved | yes: the unload drain, then MinecraftServer.saveAllChunks with flush | yes, identically | no — only what an autosave, an unload or the eager sweep had already written |
is level.dat written | yes, LevelStorageSource.LevelStorageAccess.saveDataTag | yes, identically | no |
is session.lock released | yes, LevelStorageSource.LevelStorageAccess.close drops the DirectoryLock | yes, identically | not by the game — the OS drops it when the process dies |
| is a crash report written | no | yes, into crash-reports/, from MinecraftServer.constructOrExtractCrashReport | yes, into crash-reports/, from ServerWatchdog.createWatchdogCrashReport, before the exit |
| what ends the JVM | nothing explicit: the Server thread returns and no non-daemon thread is left | the same | Runtime.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.
The cast
| class | what it decides | thread |
|---|---|---|
MinecraftServer | the two shutdown flags, the tick loop, and the finally that is the whole of shutdown | Server |
StopCommand | that /stop is one call to MinecraftServer.halt with wait false, at Commands.LEVEL_OWNERS | Server |
DedicatedServer | what wraps the base teardown: the JSON-RPC notification, Util.shutdownExecutors, and the side threads in DedicatedServer.onServerExit | Server |
ServerWatchdog | that a tick past max-tick-time is a dead server, and that the JVM goes with it | Server Watchdog, a daemon |
PlayerList | that every player is written before anyone is disconnected | Server |
ServerChunkCache · ChunkMap | when the world is quiet enough to stop draining, and what a flush save means | Server, with the writes on the IO pool |
LevelStorageSource.LevelStorageAccess | level.dat and the DirectoryLock on session.lock | Server |
Util | the process-wide pools, and the three-second grace each of the two it shuts down gets | any |
/stop, in full
sequenceDiagram
participant MS as MinecraftServer
participant PL as PlayerList
participant SL as ServerLevel
participant SCC as ServerChunkCache
participant LSA as LevelStorageSource.<br/>LevelStorageAccess
participant Disk
Note over MS: StopCommand calls halt with wait false, so running goes false
Note over MS: the lap in progress finishes, then the loop condition fails
MS->>MS: stopped goes true, then stopServer, out of runServer's finally
MS->>MS: PacketProcessor.close, then the connection listener is stopped
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->>MS: the tick deadline pushed one millisecond out
MS->>SCC: deactivateTicketsOnClosing, then tick, on every level
MS->>MS: waitUntilNextTick drains the queue for what is left of it
end
rect rgba(0, 0, 0, 0.04)
Note over MS,Disk: one call: MinecraftServer.saveAllChunks with flush
MS->>SL: save, per level, 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 replaces level.dat, the old rotated to level.dat_old
MS->>MS: SavedDataStorage.<br/>saveAndJoin, after level.dat and not before
end
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 whole of shutdown, which is one finally block. The shaded band is a
single call, and the three saves inside it are in that order because that call
puts them there — not because MinecraftServer.stopServer asks for them one at
a time.
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 — volatile, and the loop’s only
condition — and returns. Nothing else
happens on that line of the console: the rest of the lap runs out — a console
command runs in the connection phase, after every level has ticked, and a
player’s between ticks — 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, the plain field other threads read through
MinecraftServer.isStopped, 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 after the players are saved, 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 (protocol
phases: a session exists only
once configuration is acknowledged).
MinecraftServer.stopped also changes how work is accepted, and the two
doors answer differently. MinecraftServer.executeIfPossible throws a
RejectedExecutionException outright. MinecraftServer.scheduleExecutables
merely reports false, so a caller reaching BlockableEventLoop.execute from
another thread runs its task inline on its own thread rather than queueing it
for a loop that has stopped looping (the server
tick is the same two doors
while the server runs).
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 DistanceManager.hasTickets, the
reason the loop terminates at all. 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 — the flush save below writes the
persistent ones out as the dimension’s chunk_tickets saved data, under
TicketStorage.TYPE — and that is what ends the loop:
TicketStorage.hasTickets counts only the live map, so parking a ticket stops
it holding a chunk. Parked is not forgotten — which of them survive to the
next boot, and what re-arms them there, belong to tickets and
loading and
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 next, the same way at every save, flush or not —
LevelStorageSource.LevelStorageAccess.saveDataTag, a gzipped temp file
through NbtIo.writeCompressed, and a replace that keeps the previous copy (level data and
rules has
the path and the retries). 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, and the
chat filter’s pool, whose workers the text filter’s close shuts down, and, with the GUI up, Swing’s event dispatch thread, which ends once MinecraftServerGui.close disposes the frame. 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 — is
logged, turned into a report, saved, and then falls into the same finally.
A packet handler’s exception and a command’s are caught before they get here;
what escapes is an Error, or, from a packet handler, a ReportedException
carrying an OutOfMemoryError.
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 (the message, the class and the place, without the
stack) and a count of every one by place and class.
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). It does
not save like one. The parked report is never cleared, so the shutdown’s own
drain throws it again the first time it waits for chunks: the players are
written, and so are the chunks the drain’s first pass unloads, and then the
teardown stops before the flush save, leaving the entities, level.dat and
the saved data unwritten, the executors never shut down — the IO pool’s
workers keep the JVM alive until they idle out — and the lock for the
operating system to drop. 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
participant Disk
Note over MS: wedged inside one tick, past max-tick-time
SW->>MS: getNextTickTime, a deadline now far in the past
SW->>SW: createWatchdog<br/>CrashReport<br/>dumps every thread
SW->>MS: fillSystemReport, read off-thread while the tick still runs
SW->>Disk: the report, to stdout and to crash-reports
SW->>JVM: Runtime.halt armed 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 SW,Disk: ten seconds later the armed halt fires, with no save run
The only ending that waits on a thread that will never finish: the exit waits for the hook, the hook for the wedged thread, and the armed halt is the only thing that moves. The one message to the Disk lane is the difference between this and a server that dies silently.
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, and the watchdog then adds a Performance stats category
holding the random-tick game rule and ServerLevel.getWatchdogStats for every
level: players, the five commonest entity types and ticking block-entity
types, block and fluid tick counts, chunk source stats. All of it is read from another thread
with no lock on the world, 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 wait that cannot end. 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 Server thread writes nothing in
any of them; only writes already queued on Util.ioPool can still land.
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.
The endings that are /stop under another name
Ctrl-C, SIGTERM and the window button
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 in the health of the thread being waited for,
and in the halt nothing here has armed. Here the Server thread is fine, notices the cleared flag at the top of
its next lap, 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.
MinecraftServer.isShutdown is the right question to spin on, and it is the
one fact in this teardown that is not a field: it asks whether the Server
thread is still alive, so nothing can set it and nothing can lie about it.
MinecraftServer.isStopped, which the screen could have watched instead,
goes true at the start of teardown, when nothing has been saved yet — a
“Saving world” screen driven by that one would come down before the world
was written.
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.
What you lose if you kill the process
What an autosave has already written
Ordinary autosave is MinecraftServer.saveEverything with neither flush
nor force, on the countdown the tick keeps at the bottom of every lap
(the server tick) — five
minutes of the server’s scheduled time, whatever the tick rate. It writes every player, every
dirty chunk — under none of the per-chunk spacing that throttles ordinary
saving, because the sweep clears it on the way in (chunk
storage) —
and, unconditionally, level.dat, followed by a scheduled write of the
server’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 a moment ago, a mob that walked
into a new chunk, an inventory change — lives in the LevelChunk, the entity
sections and the player until something saves them; a changed chunk goes out
with the eager sweep once the tick has time and ten seconds have passed since
its last write (chunk
storage).
The honest answer, per ending
After /stop or a crash thrown by the tick itself,
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 no autosave, unload, eager sweep or logout had written, 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. The lock the boot took is an operating-system one (starting a server), and an operating system drops it when the process dies however it dies — so a world left behind by a killed server opens on the next start.
What you lose with no ending 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 signs at the time are a line in the log and that file, with a toast
as well in singleplayer.
Where to look
The /stop road first, then the other two. StopCommand ·
MinecraftServer.halt · MinecraftServer.runServer (the loop and its
finally) · MinecraftServer.stopServer · DedicatedServer.stopServer ·
ChunkMap.hasWork · MinecraftServer.saveAllChunks ·
LevelStorageSource.LevelStorageAccess.close · DedicatedServer.onServerExit ·
MinecraftServer.constructOrExtractCrashReport ·
BlockableEventLoop.relayDelayCrash · ServerWatchdog.run ·
Main (the shutdown hook) · 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.