mojira.dev
MC-312009

SavedDataStorage clears dirty before the write, tryWrite swallows IOException: write never retried

When writing a file in data/ fails, SavedDataStorage#tryWrite logs Could not save data to <file>. collectDirtyTagsToSave cleared the dirty flag before the write was even attempted and nothing sets it again, so every later save skips that data, including the one on stop. The cause can be long gone by then. Whatever changed since the last good write is lost at shutdown, unless something happens to touch the same data again first.

The failed save still ends with "Saved the game".

Any IOException ends up in that same catch block. I used a permission error for the repro because it's easy to switch on and off again.

Not the same as MC-121243, which wants failed chunk writes to be fatal. Here the server is asked to save again after the problem is gone, says it did, and didn't.

Steps to reproduce

Tested on Linux and macOS, as a normal user (root ignores the chmod).

  1. Start a 26.3 server. scoreboard objectives add before dummy, then save-all flush.

  2. chmod 444 world/data/minecraft/scoreboard.dat

  3. scoreboard objectives add after dummy, then save-all flush.

  4. chmod 644 world/data/minecraft/scoreboard.dat

  5. save-all flush again. Check the modification time of scoreboard.dat.

  6. stop. Check it again.

  7. Start the server, scoreboard objectives list.

The attached script automates this: python3 saveddata_repro.py --accept-eula lost-write. It types these commands into a stock server and does the chmod.

Expected result

Step 5 writes the file, or at the latest step 6 does. after is still there in step 7.

Actual result

Step 3:

[Server thread/INFO]: System chat: Saving the game (this may take a moment!)
[IO-Worker-8/ERROR]: Could not save data to scoreboard.dat
java.nio.file.AccessDeniedException: ./world/data/minecraft/scoreboard.dat
...
[Server thread/INFO]: System chat: Saved the game

Steps 5 and 6 don't touch the file, the modification time is still the one from step 1.

Step 7: There are 1 objective(s): [before]

Same on Linux 6.10 and macOS 27, every run.

Code analysis

// SavedDataStorage
private Map<SavedDataType<?>, CompoundTag> collectDirtyTagsToSave() {
   ...
   this.cache.forEach((type, optional) -> optional.filter(SavedData::isDirty).ifPresent(data -> {
      tagsToSave.put(type, this.encodeUnchecked(type, data, ops));
      data.setDirty(false);
   }));
   return tagsToSave;
}

private void tryWrite(final SavedDataType<?> type, final CompoundTag tag) {
   ...
   } catch (IOException e) {
      LOGGER.error("Could not save data to {}", path.getFileName(), e);
   }
}

scheduleSave, saveAndJoin and close all go through collectDirtyTagsToSave, so nothing picks the data up again. The future from scheduleSave completes normally too, so a caller can't tell that a write failed.

Keeping the types whose write failed and marking them dirty again at the start of the next collectDirtyTagsToSave would be enough, I think.

Related to MC-312008: the failed write opened the file with TRUNCATE_EXISTING, so depending on where it fails the old content is already gone as well.

Environment

Dedicated server. Linux 6.10 (ext4) with Temurin 25.0.4, macOS 27 (APFS) with Temurin 25.0.3.

Attachments

Comments 2

Thank you for helping us improve Minecraft! We saved your files:

[media][media][media][media]

mk7a

(Unassigned)

Plausible

26.3

Retrieved