Java server development on Paper · 3 min read

Background reads in Paper: return results to the same player session

We take the file reading out of the game stream, and return the message to the player through the scheduler. Between these actions, the player can exit, submit a new request, or disable the plugin: the /note example takes these cases into account.

LonelyCraft editors · Updated

Desktop with computer and world Minecraft on screen
Illustration: desktop with a computer and world Minecraft on the screen. This is an artistic scene, not a snapshot of a specific server.

Prepare a small file and command

Use the Paper 26.3 and Java 25 project. Replace the main class with the code below; command is registered in onEnable, there is no need to add commands to manifest. After the first run, create plugins/TrailNotes/note.txt in UTF-8, for example with the line “Today we are building a bridge.”

Check the scheme of synchronous and background tasks in Paper Scheduling. The background part of this experience only reads Path and bytes. It does not access world or the saved Player object.

Exchange between game thread, file read and session check
The background job only returns data. The decision about who to show the result to remains in the game flow.Scroll the diagram horizontally.Open full-size diagram

Capture the UUID, read the bytes and check the request number

The full class replaces TrailNotesPlugin.java. The requests map is used only in the main thread: before starting work, upon exit, and when processing the result. Reading 4097 bytes allows you to reject a file larger than 4 KiB without loading the entire content into memory.

package ru.example.trailnotes;

import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.plugin.java.JavaPlugin;

public final class TrailNotesPlugin extends JavaPlugin implements Listener {
    private final Map<UUID, Long> requests = new HashMap<>();
    private long sequence;

    @Override public void onEnable() {
        getDataFolder().mkdirs();
        registerCommand("note", (source, args) -> readNote(source.getSender(), args));
        getServer().getPluginManager().registerEvents(this, this);
    }
    @EventHandler public void onQuit(PlayerQuitEvent e) {
        requests.remove(e.getPlayer().getUniqueId());
    }
    private void readNote(CommandSender sender, String[] args) {
        if (!(sender instanceof Player player)) {
            sender.sendMessage("Нужен игрок.");
            return;
        }
        UUID uuid = player.getUniqueId();
        long request = ++sequence;
        requests.put(uuid, request);
        Path path = getDataFolder().toPath().resolve("note.txt");
        getServer().getScheduler().runTaskAsynchronously(this, () -> {
            String message;
            try {
                byte[] bytes;
                try (InputStream input = Files.newInputStream(path)) {
                    bytes = input.readNBytes(4097);
                }
                if (bytes.length > 4096) throw new java.io.IOException("Слишком большой файл");
                message = new String(bytes, StandardCharsets.UTF_8);
            } catch (Exception error) {
                message = "Не удалось прочитать note.txt";
            }
            String result = message;
            if (!isEnabled()) return;
            try {
                getServer().getScheduler().runTask(this, () -> {
                    if (!Long.valueOf(request).equals(requests.get(uuid))) return;
                    Player current = getServer().getPlayer(uuid);
                    requests.remove(uuid);
                    if (current != null) current.sendMessage(result);
                });
            } catch (org.bukkit.plugin.IllegalPluginAccessException ignored) {
                // Плагин отключился до постановки результата.
            }
        });
        return;
    }
    @Override public void onDisable() {
        requests.clear();
        getServer().getScheduler().cancelTasks(this);
    }
}

Check file boundaries and stale responses

Build the JAR and install it on a test server. Execute /note: expected line from file. The deleted file should give an understandable failure, and not disable the plugin. To generate boundary files on the test server machine, use Python:

  • Two quick /note: the response of the last request number will be applied.
  • Exit and new entry: The old response should not come to the new session.
  • Disabling the test server during reading: the game callback is not executed after disable.
from pathlib import Path
p = Path('plugins/TrailNotes/note.txt')
p.write_bytes(b'a' * 4096)  # /note: читается
# Затем замените 4096 на 4097: /note сообщает об отказе.

Limit the number of tasks before publishing

Discarding a stale response does not cancel a read already in progress. For a public command, allow one active request per UUID and impose a total queue limit. HTTP needs a separate timeout and response-size limit; SQL needs a bounded connection pool.

Don't wait for the result via join or get on the game thread: this will stop the tick again. The time in tick is also not equal to wall seconds under load. The example applies to regular Paper; porting to Folia requires different planning.

The request number is also useful for screens that the player has already changed. Link a response to an open menu.

Background data parsing should not change operating settings piecemeal. Replace the entire tested model.

Frequently asked questions

Why is one isOnline check not enough?

The same UUID could log out and log in again. After the PlayerQuitEvent, the old request number is removed, so the old result is discarded.

Will callback CompletableFuture become a game thread?

No. Before contacting the player, plan a synchronous task and re-check its relevance.

Why is Files.size before reading not enough?

The file may grow after verification. The limit must be applied to the bytes actually read.

Views: … · One view per browser per day

Was this useful?Total score: …

One reaction per browser. Click again to remove it, or choose the other reaction.

Sources and documentation

Minecraftplugin developmentPaper

Read on

Background reading in Paper: return response to same player session - LonelyCraft — Lonely Craft