From fabacf8e0b65d6a0bed8ebf82ed82b318c4d6555 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 14 Oct 2026 08:50:07 -0600 Subject: [PATCH 1/7] Add bounded asynchronous checked player-presence evidence --- .../advancedcore/AdvancedCorePlugin.java | 52 +++ .../presence/BukkitPlayerPresenceLookup.java | 210 ++++++++++++ .../user/presence/PlayerHistoryFormat.java | 122 +++++++ .../api/user/presence/PlayerPresence.java | 8 + .../user/presence/PlayerPresenceLookup.java | 13 + .../user/presence/PlayerPresenceService.java | 128 +++++++ .../AdvancedCorePlayerPresenceTest.java | 199 +++++++++++ .../BukkitPlayerPresenceLookupTest.java | 311 ++++++++++++++++++ .../presence/PlayerPresenceServiceTest.java | 220 +++++++++++++ docs/checked-player-presence.md | 54 +++ 10 files changed, 1317 insertions(+) create mode 100644 AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/BukkitPlayerPresenceLookup.java create mode 100644 AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/PlayerHistoryFormat.java create mode 100644 AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/PlayerPresence.java create mode 100644 AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/PlayerPresenceLookup.java create mode 100644 AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/PlayerPresenceService.java create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/presence/AdvancedCorePlayerPresenceTest.java create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/presence/BukkitPlayerPresenceLookupTest.java create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/presence/PlayerPresenceServiceTest.java create mode 100644 docs/checked-player-presence.md diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java index 64a1fbe47..5caecff8a 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java @@ -1342,8 +1342,44 @@ private void loadVersionFile() { advancedCoreBuildNumber = conf.getString("buildnumber", "NOTSET"); } + private volatile com.bencodez.advancedcore.api.user.presence.PlayerPresenceService playerPresenceService; + private com.bencodez.advancedcore.api.user.presence.BukkitPlayerPresenceLookup playerPresenceLookup; + + /** + * Read checked history for an already resolved server UUID, without name/profile + * resolution or storage on the caller. UNKNOWN is retained by display filters. + * Completion may run on a worker; schedule platform effects explicitly. + */ + public java.util.concurrent.CompletionStage + checkPlayerPresenceAsync(java.util.UUID identity) { + var service = playerPresenceService; + return service == null + ? java.util.concurrent.CompletableFuture.completedStage( + com.bencodez.advancedcore.api.user.presence.PlayerPresence.UNKNOWN) + : service.checkAsync(identity); + } + + /** Check verified current/historical UUIDs; unresolved aliases must not be omitted. */ + public java.util.concurrent.CompletionStage + checkPlayerPresenceIdentitiesAsync(java.util.Collection identities) { + var service = playerPresenceService; + return service == null + ? java.util.concurrent.CompletableFuture.completedStage( + com.bencodez.advancedcore.api.user.presence.PlayerPresence.UNKNOWN) + : service.checkIdentitiesAsync(identities); + } + @Override public void onDisable() { + var presence = playerPresenceService; + playerPresenceService = null; + if (presence != null) presence.close(); + var lookup = playerPresenceLookup; + playerPresenceLookup = null; + if (lookup != null) { + lookup.close(); + org.bukkit.event.HandlerList.unregisterAll(lookup); + } new com.bencodez.advancedcore.lifecycle.AdvancedCoreLifecycle(this).shutdown(); javaPlugin = null; } @@ -1358,6 +1394,22 @@ public void onEnable() { loginTimer = runtimeExecutors.getLoginTimer(); advancedCoreCommandLoader = CommandLoader.getInstance(); inventoryTimer = runtimeExecutors.getInventoryTimer(); + var previousPresence = playerPresenceService; + if (previousPresence != null) previousPresence.close(); + playerPresenceService = null; + var previousLookup = playerPresenceLookup; + if (previousLookup != null) { + previousLookup.close(); + org.bukkit.event.HandlerList.unregisterAll(previousLookup); + } + var presenceServer = getServer(); + var presenceDirectory = com.bencodez.advancedcore.api.user.presence.BukkitPlayerPresenceLookup + .capturePrimaryHistoryDirectory(presenceServer); + var presenceLookup = new com.bencodez.advancedcore.api.user.presence.BukkitPlayerPresenceLookup(presenceDirectory); + playerPresenceLookup = presenceLookup; + registerEvents(presenceLookup); + presenceLookup.captureJoinedPlayers(presenceServer); + playerPresenceService = new com.bencodez.advancedcore.api.user.presence.PlayerPresenceService(timer, presenceLookup); onPreLoad(); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/BukkitPlayerPresenceLookup.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/BukkitPlayerPresenceLookup.java new file mode 100644 index 000000000..38b005336 --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/BukkitPlayerPresenceLookup.java @@ -0,0 +1,210 @@ +package com.bencodez.advancedcore.api.user.presence; + +import java.io.File; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.HashSet; +import java.util.UUID; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import java.nio.file.attribute.FileTime; +import org.bukkit.Server; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; + +/** Read-only retained server history, with owner-observed joins in this generation. */ +public final class BukkitPlayerPresenceLookup implements PlayerPresenceLookup, Listener, AutoCloseable { + private static final int MAX_DIRECTORY_ENTRIES = 1_000_000; + private static final int MAX_RECOVERY_IDENTITIES = 65536; + private record RecoveryIndex(Object fileKey, FileTime modified, Set identities) { } + private static final RecoveryIndex RETIRED = new RecoveryIndex(null, null, Set.of()); + private final AtomicReference recoveryIndex = new AtomicReference<>(); + private final Object recoveryScan = new Object(); + private final Path historyDirectory; + private final HashSet joined = new HashSet<>(); + private boolean negativeUnavailable; + private boolean closed; + + /** The caller must supply the actual server-owned playerdata directory. */ + public BukkitPlayerPresenceLookup(Path historyDirectory) { + Path candidate = historyDirectory == null ? null : historyDirectory.toAbsolutePath(); + if (candidate != null) for (Path part : candidate) { + if (part.toString().equals("..")) { candidate = null; break; } + } + this.historyDirectory = candidate == null ? null : candidate.normalize(); + } + + /** Metadata only, on the platform owner. Never infer storage from a dimension path. */ + public static Path capturePrimaryHistoryDirectory(Server server) { + if (server == null || !server.getClass().getName().matches( + "org\\.bukkit\\.craftbukkit(?:\\.v[0-9]+_[0-9]+_R[0-9]+)?\\.CraftServer")) return null; + try { + try { + Object directory = server.getClass().getMethod("getLevelDirectory").invoke(server); + return directory instanceof Path path ? path.resolve("playerdata") : null; + } catch (NoSuchMethodException olderApi) { + Object players = server.getClass().getMethod("getHandle").invoke(server); + Object storage = players.getClass().getField("playerIo").get(players); + Object directory = storage.getClass().getMethod("getPlayerDir").invoke(storage); + return directory instanceof File file ? file.toPath() : null; + } + } catch (ReflectiveOperationException | RuntimeException | LinkageError unavailable) { + return null; + } + } + + /** Capture UUID-only online metadata on the platform owner after registering this listener. */ + public void captureJoinedPlayers(Server server) { + try { + if (server == null) throw new IllegalStateException("Unavailable server"); + int examined = 0; + for (var player : server.getOnlinePlayers()) { + if (++examined > 65536) throw new IllegalStateException("Join evidence limit"); + observeJoined(player.getUniqueId()); + } + } catch (RuntimeException | LinkageError unavailable) { + synchronized (joined) { negativeUnavailable = true; } + } + } + + @EventHandler(priority = EventPriority.LOWEST) + public void onJoin(PlayerJoinEvent event) { + try { observeJoined(event.getPlayer().getUniqueId()); } + catch (RuntimeException | LinkageError unavailable) { + synchronized (joined) { negativeUnavailable = true; } + } + } + + private void observeJoined(UUID identity) { + synchronized (joined) { + if (closed) return; + if (identity == null || (!joined.contains(identity) && joined.size() >= 65536)) + negativeUnavailable = true; + else joined.add(identity); + } + } + + private PlayerPresence liveEvidence(UUID identity) { + synchronized (joined) { + if (closed) return PlayerPresence.UNKNOWN; + return joined.contains(identity) ? PlayerPresence.JOINED : null; + } + } + + @Override + public PlayerPresence check(UUID identity) { + if (identity == null) return PlayerPresence.UNKNOWN; + PlayerPresence live = liveEvidence(identity); + if (live != null) return live; + if (historyDirectory == null) return PlayerPresence.UNKNOWN; + try { + if (!unlinkedScope()) return PlayerPresence.UNKNOWN; + BasicFileAttributes before = attributes(historyDirectory); + if (!before.isDirectory() || before.fileKey() == null) return PlayerPresence.UNKNOWN; + try (var ignored = Files.newDirectoryStream(historyDirectory)) { } + boolean present = false, valid = false; + for (String suffix : new String[] { ".dat", ".dat_old", ".dat_new", ".dat.tmp", + ".dat.offline-read", ".dat_old.offline-read" }) { + Path file = historyDirectory.resolve(identity + suffix); + try { + BasicFileAttributes record = attributes(file); + present = true; + if (!record.isRegularFile() || record.fileKey() == null + || record.size() > PlayerHistoryFormat.MAX_COMPRESSED) continue; + byte[] bytes; + try (var input = FileChannel.open(file, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) { + ByteBuffer buffer = ByteBuffer.allocate((int) record.size() + 1); + while (buffer.hasRemaining() && input.read(buffer) != -1) { } + if (buffer.position() != record.size()) continue; + bytes = java.util.Arrays.copyOf(buffer.array(), buffer.position()); + } + BasicFileAttributes after = attributes(file); + if (!record.fileKey().equals(after.fileKey()) || record.size() != after.size() + || !record.lastModifiedTime().equals(after.lastModifiedTime())) continue; + try { valid |= PlayerHistoryFormat.valid(bytes); } + catch (IOException invalid) { /* Malformed history stays unknown, never absent. */ } + } catch (NoSuchFileException absent) { + // Only a confirmed missing-file result can support a negative. + } + } + // Native saves use UUID-random.dat; their existence prevents absence + // proof even if a crash left no canonical record. Never load or alter them. + boolean recovery = !valid && !present && hasRecovery(identity, before); + BasicFileAttributes after = attributes(historyDirectory); + if (!unlinkedScope() || !after.isDirectory() || !before.fileKey().equals(after.fileKey())) + return PlayerPresence.UNKNOWN; + live = liveEvidence(identity); + if (live != null) return live; + if (valid) return PlayerPresence.JOINED; + synchronized (joined) { + if (closed || negativeUnavailable || present || recovery + || !before.lastModifiedTime().equals(after.lastModifiedTime())) return PlayerPresence.UNKNOWN; + return joined.contains(identity) ? PlayerPresence.JOINED : PlayerPresence.NEVER_JOINED; + } + } catch (IOException | RuntimeException unavailable) { + return PlayerPresence.UNKNOWN; + } + } + + private boolean hasRecovery(UUID identity, BasicFileAttributes scope) throws IOException { + synchronized (recoveryScan) { + RecoveryIndex cached = recoveryIndex.get(); + if (cached == RETIRED) return true; + if (cached != null && cached.fileKey().equals(scope.fileKey()) + && cached.modified().equals(scope.lastModifiedTime())) + return cached.identities().contains(identity); + HashSet identities = new HashSet<>(); + int examined = 0; + try (var entries = Files.newDirectoryStream(historyDirectory)) { + for (Path entry : entries) { + if (++examined > MAX_DIRECTORY_ENTRIES) return true; + String name = entry.getFileName().toString(); + if (name.length() <= 40) continue; + String suffix = name.substring(36); + boolean temporary = suffix.startsWith("-") && suffix.endsWith(".dat") + && suffix.length() > 5; + boolean corrupted = suffix.startsWith("_corrupted_") + && (suffix.endsWith(".dat") || suffix.endsWith(".dat_old")); + if (!temporary && !corrupted) continue; + UUID record; + try { record = UUID.fromString(name.substring(0, 36)); } + catch (IllegalArgumentException unrelated) { continue; } + if (!record.toString().equals(name.substring(0, 36))) continue; + identities.add(record); + if (identities.size() > MAX_RECOVERY_IDENTITIES) return true; + } + } + BasicFileAttributes after = attributes(historyDirectory); + if (!unlinkedScope() || !after.isDirectory() || !scope.fileKey().equals(after.fileKey()) + || !scope.lastModifiedTime().equals(after.lastModifiedTime())) return true; + RecoveryIndex scanned = new RecoveryIndex(scope.fileKey(), scope.lastModifiedTime(), Set.copyOf(identities)); + // close never waits for the scan and a retired scan cannot repopulate it. + if (!recoveryIndex.compareAndSet(cached, scanned)) return true; + return identities.contains(identity); + } + } + + private boolean unlinkedScope() { + for (Path part = historyDirectory; part != null; part = part.getParent()) + if (Files.isSymbolicLink(part)) return false; + return true; + } + + private static BasicFileAttributes attributes(Path path) throws IOException { + return Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + } + + @Override public void close() { + synchronized (joined) { closed = true; joined.clear(); } + recoveryIndex.set(RETIRED); + } +} diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/PlayerHistoryFormat.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/PlayerHistoryFormat.java new file mode 100644 index 000000000..f212591a4 --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/PlayerHistoryFormat.java @@ -0,0 +1,122 @@ +package com.bencodez.advancedcore.api.user.presence; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.IOException; +import java.util.HashSet; +import java.util.zip.CRC32; +import java.util.zip.DataFormatException; +import java.util.zip.Inflater; + +/** Bounded structural inspection only: no server loader, data fixing or writes. */ +final class PlayerHistoryFormat { + static final int MAX_COMPRESSED = 1024 * 1024; + private static final int MAX_EXPANDED = 8 * 1024 * 1024; + private int nodes; + + static boolean valid(byte[] compressed) throws IOException { + byte[] payload = expand(compressed); + try (var input = new DataInputStream(new ByteArrayInputStream(payload))) { + if (input.readUnsignedByte() != 10) return false; + input.readUTF(); + new PlayerHistoryFormat().tag(input, 10, 0); + return input.read() == -1; + } + } + + private static byte[] expand(byte[] bytes) throws IOException { + if (bytes.length < 18 || bytes.length > MAX_COMPRESSED + || (bytes[0] & 255) != 31 || (bytes[1] & 255) != 139 || bytes[2] != 8) + throw new IOException("Invalid history compression"); + int flags = bytes[3] & 255; + if ((flags & 224) != 0) throw new IOException("Invalid history flags"); + int offset = 10; + if ((flags & 4) != 0) { + if (offset + 2 > bytes.length - 8) throw new IOException("Truncated history header"); + int length = (bytes[offset] & 255) | ((bytes[offset + 1] & 255) << 8); + offset += 2 + length; + } + for (int flag : new int[] {8, 16}) if ((flags & flag) != 0) { + boolean terminated = false; + while (offset < bytes.length - 8) if (bytes[offset++] == 0) { terminated = true; break; } + if (!terminated) + throw new IOException("Truncated history header"); + } + if ((flags & 2) != 0) { + if (offset + 2 > bytes.length - 8) throw new IOException("Truncated history header"); + var headerCrc = new CRC32(); headerCrc.update(bytes, 0, offset); + int expected = (bytes[offset] & 255) | ((bytes[offset + 1] & 255) << 8); + if ((headerCrc.getValue() & 65535) != expected) throw new IOException("Invalid history header checksum"); + offset += 2; + } + if (offset > bytes.length - 8) throw new IOException("Truncated history frame"); + var inflater = new Inflater(true); + try { + inflater.setInput(bytes, offset, bytes.length - offset); + var output = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + while (!inflater.finished()) { + int count = inflater.inflate(buffer); + if (count == 0 && !inflater.finished()) throw new IOException("Incomplete history frame"); + if (output.size() > MAX_EXPANDED - count) throw new IOException("History expansion limit"); + output.write(buffer, 0, count); + } + int trailer = bytes.length - inflater.getRemaining(); + if (trailer != bytes.length - 8) throw new IOException("Adjacent history frame data"); + byte[] payload = output.toByteArray(); + var crc = new CRC32(); crc.update(payload); + if (littleInt(bytes, trailer) != crc.getValue() || littleInt(bytes, trailer + 4) != payload.length) + throw new IOException("Invalid history checksum"); + return payload; + } catch (DataFormatException invalid) { + throw new IOException("Invalid history deflate", invalid); + } finally { + inflater.end(); + } + } + + private static long littleInt(byte[] data, int offset) { + long result = 0; + for (int index = 0; index < 4; index++) result |= (long) (data[offset + index] & 255) << (8 * index); + return result; + } + + private void tag(DataInputStream input, int type, int depth) throws IOException { + if (depth > 64 || ++nodes > 100000) throw new IOException("History structure limit"); + switch (type) { + case 1 -> input.readByte(); + case 2 -> input.readShort(); + case 3, 5 -> input.readInt(); + case 4, 6 -> input.readLong(); + case 7 -> array(input, 1); + case 8 -> input.readUTF(); + case 9 -> { + int element = input.readUnsignedByte(); + int count = input.readInt(); + if (count < 0 || count > 100000 || element > 12 || (element == 0 && count != 0)) + throw new IOException("Invalid history list"); + for (int index = 0; index < count; index++) tag(input, element, depth + 1); + } + case 10 -> { + var names = new HashSet(); + int child; + while ((child = input.readUnsignedByte()) != 0) { + String name = input.readUTF(); + if (!names.add(name)) throw new IOException("Duplicate history name"); + tag(input, child, depth + 1); + } + } + case 11 -> array(input, 4); + case 12 -> array(input, 8); + default -> throw new IOException("Invalid history tag"); + } + } + + private void array(DataInputStream input, int width) throws IOException { + int count = input.readInt(); + if (count < 0 || count > MAX_EXPANDED / width || (long) count * width > input.available()) + throw new IOException("Invalid history array"); + input.skipNBytes((long) count * width); + } +} diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/PlayerPresence.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/PlayerPresence.java new file mode 100644 index 000000000..250fbaec9 --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/PlayerPresence.java @@ -0,0 +1,8 @@ +package com.bencodez.advancedcore.api.user.presence; + +/** Checked server history; plugin user-row existence is not server-history evidence. */ +public enum PlayerPresence { + JOINED, + NEVER_JOINED, + UNKNOWN +} diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/PlayerPresenceLookup.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/PlayerPresenceLookup.java new file mode 100644 index 000000000..49e64e244 --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/PlayerPresenceLookup.java @@ -0,0 +1,13 @@ +package com.bencodez.advancedcore.api.user.presence; + +import java.util.UUID; + +/** + * A read-only lookup of an already resolved identity on a persistence worker. + * Implementations must return UNKNOWN for incomplete identity/history evidence. + * Absence from a plugin's storage is insufficient evidence for NEVER_JOINED. + */ +@FunctionalInterface +public interface PlayerPresenceLookup { + PlayerPresence check(UUID identity) throws Exception; +} diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/PlayerPresenceService.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/PlayerPresenceService.java new file mode 100644 index 000000000..8c9f99aad --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/PlayerPresenceService.java @@ -0,0 +1,128 @@ +package com.bencodez.advancedcore.api.user.presence; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.IdentityHashMap; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.Executor; + +/** + * Bounded asynchronous checked history. The supplied executor belongs to the + * existing persistence runtime; this service creates no worker or storage. + * Completions can run on the worker or the requesting/retiring thread. Callers + * must schedule platform effects explicitly, never attach blocking callbacks. + */ +public final class PlayerPresenceService implements AutoCloseable { + private static final int MAX_PENDING = 128; + private final Executor worker; + private final PlayerPresenceLookup lookup; + private final Object admission = new Object(); + private final IdentityHashMap, Boolean> pending = new IdentityHashMap<>(); + private boolean closed; + + public PlayerPresenceService(Executor worker, PlayerPresenceLookup lookup) { + this.worker = Objects.requireNonNull(worker, "worker"); + this.lookup = Objects.requireNonNull(lookup, "lookup"); + } + + /** No name/UUID resolution, storage or Bukkit call is performed by this method. */ + public CompletionStage checkAsync(UUID identity) { + CompletableFuture result = new CompletableFuture<>(); + if (identity == null) return CompletableFuture.completedStage(PlayerPresence.UNKNOWN); + synchronized (admission) { + if (closed || pending.size() >= MAX_PENDING) + return CompletableFuture.completedStage(PlayerPresence.UNKNOWN); + pending.put(result, Boolean.TRUE); + } + try { + worker.execute(() -> completeLookup(identity, result)); + } catch (RuntimeException rejected) { + finish(result, PlayerPresence.UNKNOWN); + } + return result.minimalCompletionStage(); + } + + /** Check up to eight verified current/historical UUIDs for the same identity. */ + public CompletionStage checkIdentitiesAsync(Collection identities) { + LinkedHashSet distinct = new LinkedHashSet<>(); + if (identities == null) return CompletableFuture.completedStage(PlayerPresence.UNKNOWN); + try { + int examined = 0; + for (UUID identity : identities) { + // Bound iteration too: a very large duplicate collection isn't free. + if (++examined > 8 || identity == null) + return CompletableFuture.completedStage(PlayerPresence.UNKNOWN); + distinct.add(identity); + } + } catch (RuntimeException unavailable) { + return CompletableFuture.completedStage(PlayerPresence.UNKNOWN); + } + if (distinct.isEmpty()) return CompletableFuture.completedStage(PlayerPresence.UNKNOWN); + CompletableFuture aggregate = new CompletableFuture<>(); + synchronized (admission) { + if (closed || pending.size() >= MAX_PENDING) + return CompletableFuture.completedStage(PlayerPresence.UNKNOWN); + // The public aggregate owns admission until every alias has settled. + // A completed child cannot outlive retirement of this request. + pending.put(aggregate, Boolean.TRUE); + } + var results = distinct.stream().map(this::checkAsync).map(CompletionStage::toCompletableFuture).toList(); + CompletableFuture.allOf(results.toArray(CompletableFuture[]::new)).thenRun(() -> { + boolean unknown = false; + for (var result : results) { + PlayerPresence evidence = result.join(); // All reads have already completed. + if (evidence == PlayerPresence.JOINED) { + finish(aggregate, PlayerPresence.JOINED); + return; + } + unknown |= evidence == PlayerPresence.UNKNOWN; + } + finish(aggregate, unknown ? PlayerPresence.UNKNOWN : PlayerPresence.NEVER_JOINED); + }); + return aggregate.minimalCompletionStage(); + } + + private void completeLookup(UUID identity, CompletableFuture result) { + synchronized (admission) { + // Queued retired work never touches the provider or replacement state. + if (closed || !pending.containsKey(result)) return; + } + PlayerPresence evidence; + try { + evidence = lookup.check(identity); + if (evidence == null) evidence = PlayerPresence.UNKNOWN; + } catch (Exception failed) { + if (failed instanceof InterruptedException) Thread.currentThread().interrupt(); + evidence = PlayerPresence.UNKNOWN; + } + finish(result, evidence); + } + + private void finish(CompletableFuture result, PlayerPresence evidence) { + synchronized (admission) { + if (pending.remove(result) == null) return; + // Publish the terminal result before retirement can acquire admission. + // Callbacks are reentrant and, by contract, must never block. + if (closed) evidence = PlayerPresence.UNKNOWN; + result.complete(evidence); + } + } + + /** Retire this generation without waiting for or shutting down its shared worker. */ + @Override + public void close() { + ArrayList> retiring; + synchronized (admission) { + if (closed) return; + closed = true; + retiring = new ArrayList<>(pending.keySet()); + pending.clear(); + } + for (CompletableFuture result : retiring) + result.complete(PlayerPresence.UNKNOWN); + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/presence/AdvancedCorePlayerPresenceTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/presence/AdvancedCorePlayerPresenceTest.java new file mode 100644 index 000000000..0093d35dc --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/presence/AdvancedCorePlayerPresenceTest.java @@ -0,0 +1,199 @@ +package com.bencodez.advancedcore.tests.user.presence; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Field; +import java.util.ArrayDeque; +import java.util.UUID; +import java.util.concurrent.CompletionStage; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import org.bukkit.Server; +import org.bukkit.World; +import org.bukkit.OfflinePlayer; +import org.junit.jupiter.api.io.TempDir; +import net.bytebuddy.ByteBuddy; +import net.bytebuddy.dynamic.loading.ClassLoadingStrategy; +import net.bytebuddy.implementation.FixedValue; +import net.bytebuddy.implementation.InvocationHandlerAdapter; +import net.bytebuddy.implementation.StubMethod; +import static net.bytebuddy.matcher.ElementMatchers.named; +import static net.bytebuddy.matcher.ElementMatchers.takesArguments; +import org.junit.jupiter.api.Test; +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.user.presence.PlayerPresence; +import com.bencodez.advancedcore.api.user.presence.PlayerPresenceService; +import com.bencodez.advancedcore.api.user.presence.BukkitPlayerPresenceLookup; +import com.bencodez.advancedcore.lifecycle.AdvancedCoreLifecycle; + +class AdvancedCorePlayerPresenceTest { + private static final UUID USER = UUID.fromString("d4519fc4-a782-4d11-8235-617209238441"); + @TempDir Path temporary; + + public static class StorageFixture { + private final java.io.File directory; + public StorageFixture(Path directory) { this.directory = directory.toFile(); } + public java.io.File getPlayerDir() { return directory; } + } + + public static class PlayerListFixture { + public final StorageFixture playerIo; + public PlayerListFixture(Path directory) { playerIo = new StorageFixture(directory); } + } + + private Server levelServer(Path level) throws Exception { + return (Server) new ByteBuddy().subclass(Object.class).implement(Server.class) + .name("org.bukkit.craftbukkit.CraftServer") + .method(net.bytebuddy.matcher.ElementMatchers.any()).intercept(StubMethod.INSTANCE) + .defineMethod("getLevelDirectory", Path.class, java.lang.reflect.Modifier.PUBLIC) + .intercept(FixedValue.value(level)) + .method(named("getOnlinePlayers")).intercept(FixedValue.value(java.util.List.of())) + .make().load(getClass().getClassLoader(), ClassLoadingStrategy.Default.WRAPPER) + .getLoaded().getDeclaredConstructor().newInstance(); + } + + @Test void olderApiCapturesActualPlayerStorageWithoutWorldDirectoryAssumptions() throws Exception { + Path history = Files.createDirectory(temporary.resolve("legacy-playerdata")); + Server server = (Server) new ByteBuddy().subclass(Object.class).implement(Server.class) + .name("org.bukkit.craftbukkit.v1_21_R1.CraftServer") + .method(net.bytebuddy.matcher.ElementMatchers.any()).intercept(StubMethod.INSTANCE) + .defineMethod("getHandle", PlayerListFixture.class, java.lang.reflect.Modifier.PUBLIC) + .intercept(FixedValue.value(new PlayerListFixture(history))) + .make().load(getClass().getClassLoader(), ClassLoadingStrategy.Default.WRAPPER) + .getLoaded().getDeclaredConstructor().newInstance(); + assertEquals(history, BukkitPlayerPresenceLookup.capturePrimaryHistoryDirectory(server)); + assertEquals(PlayerPresence.NEVER_JOINED, new BukkitPlayerPresenceLookup(history).check(USER)); + } + + @Test void actualEnableRegistersJoinEvidenceBeforePublishingAndDisableRetiresIt() throws Exception { + Path level = Files.createDirectory(temporary.resolve("runtime-level")); + Path history = Files.createDirectory(level.resolve("playerdata")); + Server server = levelServer(level); + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, CALLS_REAL_METHODS); + doReturn(server).when(plugin).getServer(); + var worker = mock(java.util.concurrent.ScheduledExecutorService.class); + var tasks = new ArrayDeque(); + doAnswer(call -> { tasks.add(call.getArgument(0)); return null; }).when(worker).execute(any(Runnable.class)); + var executors = mock(AdvancedCoreLifecycle.RuntimeExecutors.class); + when(executors.getTimer()).thenReturn(worker); + var registered = new java.util.concurrent.atomic.AtomicReference(); + doAnswer(call -> { + registered.set(call.getArgument(0)); + assertEquals(PlayerPresence.UNKNOWN, value(plugin.checkPlayerPresenceAsync(USER))); + return null; + }).when(plugin).registerEvents(any(BukkitPlayerPresenceLookup.class)); + doThrow(new IllegalStateException("stop after presence initialization")).when(plugin).onPreLoad(); + try (var runtime = mockStatic(AdvancedCoreLifecycle.class)) { + runtime.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(plugin)).thenReturn(executors); + assertThrows(IllegalStateException.class, plugin::onEnable); + } + assertNotNull(registered.get()); + var result = plugin.checkPlayerPresenceAsync(USER); + assertFalse(result.toCompletableFuture().isDone()); tasks.remove().run(); + assertEquals(PlayerPresence.NEVER_JOINED, value(result)); + try (var lifecycle = mockConstruction(AdvancedCoreLifecycle.class)) { plugin.onDisable(); } + assertEquals(PlayerPresence.UNKNOWN, registered.get().check(USER)); + verify(worker).execute(any(Runnable.class)); verifyNoMoreInteractions(worker); + try (var files = Files.list(history)) { assertEquals(0, files.count()); } + } + + private static void attach(AdvancedCorePlugin plugin, PlayerPresenceService service) throws Exception { + Field field = AdvancedCorePlugin.class.getDeclaredField("playerPresenceService"); + field.setAccessible(true); field.set(plugin, service); + } + + private static PlayerPresence value(CompletionStage result) { + return result.toCompletableFuture().join(); + } + + @Test void partialStartupHasUnknownHistoryInsteadOfCreatingAnIdentity() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, CALLS_REAL_METHODS); + assertEquals(PlayerPresence.UNKNOWN, value(plugin.checkPlayerPresenceAsync(USER))); + assertEquals(PlayerPresence.UNKNOWN, value(plugin.checkPlayerPresenceAsync(null))); + verify(plugin, never()).getServer(); + verify(plugin, never()).getUserManager(); + } + + @Test void publicApiKeepsUuidLookupAndHistoryOffRequestingThread() throws Exception { + var worker = new ArrayDeque(); + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, CALLS_REAL_METHODS); + try (var service = new PlayerPresenceService(worker::add, id -> { + assertEquals(USER, id); return PlayerPresence.JOINED; + })) { + attach(plugin, service); + var result = plugin.checkPlayerPresenceAsync(USER); + assertFalse(result.toCompletableFuture().isDone()); + worker.remove().run(); + assertEquals(PlayerPresence.JOINED, value(result)); + verify(plugin, never()).getServer(); + verify(plugin, never()).getUserManager(); + } + } + + @Test void actualDisableRetiresRequestsBeforeExistingLifecycleRuns() throws Exception { + var worker = new ArrayDeque(); + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, CALLS_REAL_METHODS); + var reads = new java.util.concurrent.atomic.AtomicInteger(); + var service = new PlayerPresenceService(worker::add, id -> { + reads.incrementAndGet(); return PlayerPresence.JOINED; + }); + attach(plugin, service); + var pending = plugin.checkPlayerPresenceAsync(USER); + try (var lifecycle = mockConstruction(AdvancedCoreLifecycle.class, (instance, context) -> + doAnswer(call -> { + assertEquals(PlayerPresence.UNKNOWN, value(pending)); + assertEquals(PlayerPresence.UNKNOWN, value(plugin.checkPlayerPresenceAsync(USER))); + return null; + }).when(instance).shutdown())) { + plugin.onDisable(); + assertEquals(1, lifecycle.constructed().size()); + verify(lifecycle.constructed().getFirst()).shutdown(); + } + worker.remove().run(); + assertEquals(0, reads.get()); + assertEquals(PlayerPresence.UNKNOWN, value(pending)); + } + + @Test void completeCapturedScopeAndPublicApiReadHistoryOnActualWorker() throws Exception { + Path levelDirectory = Files.createDirectory(temporary.resolve("server-level")); + Path worldDirectory = Files.createDirectory(temporary.resolve("dimension")); + Path historyDirectory = Files.createDirectory(levelDirectory.resolve("playerdata")); + World world = mock(World.class); + when(world.getEnvironment()).thenReturn(World.Environment.NORMAL); + when(world.getWorldFolder()).thenReturn(worldDirectory.toFile()); + // A complete synthetic CraftServer-shaped scope, isolated in a child loader. + Server server = (Server) new ByteBuddy().subclass(Object.class).implement(Server.class) + .name("org.bukkit.craftbukkit.CraftServer").method(net.bytebuddy.matcher.ElementMatchers.any()) + .intercept(StubMethod.INSTANCE) + .defineMethod("getLevelDirectory", Path.class, java.lang.reflect.Modifier.PUBLIC) + .intercept(FixedValue.value(levelDirectory)) + .method(named("getWorlds")).intercept(FixedValue.value(java.util.List.of(world))) + .method(named("getOfflinePlayer").and(takesArguments(UUID.class))) + .intercept(InvocationHandlerAdapter.of((proxy, method, arguments) -> { + fail("Checked reads must never invoke the server history loader"); return null; + })).make().load(getClass().getClassLoader(), ClassLoadingStrategy.Default.WRAPPER) + .getLoaded().getDeclaredConstructor().newInstance(); + Path captured = BukkitPlayerPresenceLookup.capturePrimaryHistoryDirectory(server); + assertEquals(historyDirectory, captured); + long requester = Thread.currentThread().threadId(); + AtomicLong historyThread = new AtomicLong(-1); + var actualLookup = new BukkitPlayerPresenceLookup(captured); + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, CALLS_REAL_METHODS); + try (var worker = Executors.newSingleThreadExecutor(); + var service = new PlayerPresenceService(worker, id -> { + historyThread.set(Thread.currentThread().threadId()); return actualLookup.check(id); + })) { + attach(plugin, service); + assertEquals(PlayerPresence.NEVER_JOINED, + plugin.checkPlayerPresenceAsync(USER).toCompletableFuture().get(5, TimeUnit.SECONDS)); + assertNotEquals(requester, historyThread.get()); + verifyNoInteractions(world); + verify(plugin, never()).getUserManager(); + try (var contents = Files.list(historyDirectory)) { assertEquals(0, contents.count()); } + } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/presence/BukkitPlayerPresenceLookupTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/presence/BukkitPlayerPresenceLookupTest.java new file mode 100644 index 000000000..8599f3de3 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/presence/BukkitPlayerPresenceLookupTest.java @@ -0,0 +1,311 @@ +package com.bencodez.advancedcore.tests.user.presence; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; +import java.util.zip.GZIPOutputStream; +import org.bukkit.Server; +import org.bukkit.entity.Player; +import org.bukkit.event.player.PlayerJoinEvent; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.io.TempDir; +import com.bencodez.advancedcore.api.user.presence.BukkitPlayerPresenceLookup; +import com.bencodez.advancedcore.api.user.presence.PlayerPresence; + +class BukkitPlayerPresenceLookupTest { + private static final UUID USER = UUID.fromString("d4519fc4-a782-4d11-8235-617209238441"); + @TempDir Path directory; + + static byte[] gzip(byte[] payload) throws Exception { + var bytes = new ByteArrayOutputStream(); + try (var gzip = new GZIPOutputStream(bytes)) { gzip.write(payload); } + return bytes.toByteArray(); + } + + static byte[] completeHistory() throws Exception { + var bytes = new ByteArrayOutputStream(); + try (var out = new DataOutputStream(bytes)) { + out.writeByte(10); out.writeUTF(""); + for (int type = 1; type <= 12; type++) { + out.writeByte(type); out.writeUTF("entry-" + type); + switch (type) { + case 1 -> out.writeByte(1); + case 2 -> out.writeShort(2); + case 3 -> out.writeInt(3); + case 4 -> out.writeLong(4); + case 5 -> out.writeFloat(5); + case 6 -> out.writeDouble(6); + case 7 -> { out.writeInt(2); out.write(new byte[] {1, 2}); } + case 8 -> out.writeUTF("déjà-joined-歴史 🎉\r\n adjacent text"); + case 9 -> { out.writeByte(3); out.writeInt(2); out.writeInt(1); out.writeInt(2); } + case 10 -> { out.writeByte(8); out.writeUTF("name"); out.writeUTF("nested"); out.writeByte(0); } + case 11 -> { out.writeInt(1); out.writeInt(1); } + case 12 -> { out.writeInt(1); out.writeLong(1); } + } + } + out.writeByte(0); + } + return bytes.toByteArray(); + } + + @Test void completeAllTagHistoryIsReadOnlyAndDoesNotNeedServerLoader() throws Exception { + byte[] record = gzip(completeHistory()); + Path file = Files.write(directory.resolve(USER + ".dat"), record); + assertEquals(PlayerPresence.JOINED, new BukkitPlayerPresenceLookup(directory).check(USER)); + assertArrayEquals(record, Files.readAllBytes(file)); + try (var files = Files.list(directory)) { assertEquals(1, files.count()); } + } + + @Test void missingRecordNeedsReadableScopeAndNeverCreatesState() { + assertEquals(PlayerPresence.NEVER_JOINED, new BukkitPlayerPresenceLookup(directory).check(USER)); + assertEquals(PlayerPresence.UNKNOWN, new BukkitPlayerPresenceLookup(directory.resolve("missing")).check(USER)); + assertEquals(PlayerPresence.UNKNOWN, new BukkitPlayerPresenceLookup(null).check(USER)); + assertEquals(PlayerPresence.UNKNOWN, new BukkitPlayerPresenceLookup(directory).check(null)); + } + + @Test void everyRecoveryOrMigrationSuffixIsRetainedAndMalformedIsUnknown() throws Exception { + for (String suffix : new String[] {".dat", ".dat_old", ".dat_new", ".dat.tmp", + ".dat.offline-read", ".dat_old.offline-read"}) { + Path file = directory.resolve(USER + suffix); + Files.write(file, new byte[] {0}); + assertEquals(PlayerPresence.UNKNOWN, new BukkitPlayerPresenceLookup(directory).check(USER)); + byte[] valid = gzip(completeHistory()); Files.write(file, valid); + assertEquals(PlayerPresence.JOINED, new BukkitPlayerPresenceLookup(directory).check(USER)); + assertArrayEquals(valid, Files.readAllBytes(file)); Files.delete(file); + } + } + + @Test void unrelatedIdentityAndMigrationFilesAreNotClaimedOrRenamed() throws Exception { + UUID historical = UUID.fromString("b3f2bd99-5325-4a51-a642-12674e147381"); + byte[] record = gzip(completeHistory()); Path old = Files.write(directory.resolve(historical + ".dat"), record); + var lookup = new BukkitPlayerPresenceLookup(directory); + assertEquals(PlayerPresence.NEVER_JOINED, lookup.check(USER)); + assertEquals(PlayerPresence.JOINED, lookup.check(historical)); + assertArrayEquals(record, Files.readAllBytes(old)); + assertFalse(Files.exists(directory.resolve(historical + ".dat.offline-read"))); + } + + @Test void migratedBackupCannotBeMistakenForMissingHistory() throws Exception { + Path retained = directory.resolve(USER + ".dat_old.offline-read"); + byte[] valid = gzip(completeHistory()); Files.write(retained, valid); + assertEquals(PlayerPresence.JOINED, new BukkitPlayerPresenceLookup(directory).check(USER)); + assertArrayEquals(valid, Files.readAllBytes(retained)); + byte[] malformed = {0}; Files.write(retained, malformed); + assertEquals(PlayerPresence.UNKNOWN, new BukkitPlayerPresenceLookup(directory).check(USER)); + assertArrayEquals(malformed, Files.readAllBytes(retained)); + try (var files = Files.list(directory)) { assertEquals(1, files.count()); } + } + + @Test void interruptedNativeFirstSaveIsUnknownAndRetainedVerbatim() throws Exception { + Path file = Files.createTempFile(directory, USER + "-", ".dat"); + byte[] record = gzip(completeHistory()); Files.write(file, record); + assertEquals(PlayerPresence.UNKNOWN, new BukkitPlayerPresenceLookup(directory).check(USER)); + assertArrayEquals(record, Files.readAllBytes(file)); + Files.write(file, new byte[] {0}); + assertEquals(PlayerPresence.UNKNOWN, new BukkitPlayerPresenceLookup(directory).check(USER)); + assertArrayEquals(new byte[] {0}, Files.readAllBytes(file)); + } + + @Test void recoveryEvidenceIsScopedToItsIdentityAndSurvivesMalformedContent() throws Exception { + UUID other = UUID.fromString("b3f2bd99-5325-4a51-a642-12674e147381"); + Path temp = Files.createTempFile(directory, other + "-", ".dat"); + Files.write(temp, new byte[] {0}); + var lookup = new BukkitPlayerPresenceLookup(directory); + assertEquals(PlayerPresence.NEVER_JOINED, lookup.check(USER)); + assertEquals(PlayerPresence.UNKNOWN, lookup.check(other)); + Files.write(directory.resolve(USER + "_corrupted_2026-10-14_01-00-00.dat_old"), new byte[] {0}); + Files.setLastModifiedTime(directory, java.nio.file.attribute.FileTime.fromMillis(System.currentTimeMillis() + 2000)); + assertEquals(PlayerPresence.UNKNOWN, lookup.check(USER)); + assertArrayEquals(new byte[] {0}, Files.readAllBytes(temp)); + Files.write(directory.resolve(USER + ".dat"), gzip(completeHistory())); + assertEquals(PlayerPresence.JOINED, lookup.check(USER)); + lookup.close(); + assertEquals(PlayerPresence.UNKNOWN, lookup.check(USER)); + } + + @Test void recoveryIndexRefreshesAfterCreationDeletionAndDirectoryReplacement() throws Exception { + Path scope = Files.createDirectory(directory.resolve("playerdata")); + var lookup = new BukkitPlayerPresenceLookup(scope); + assertEquals(PlayerPresence.NEVER_JOINED, lookup.check(USER)); + Path pending = Files.createTempFile(scope, USER + "-", ".dat"); + Files.setLastModifiedTime(scope, java.nio.file.attribute.FileTime.fromMillis(1000)); + assertEquals(PlayerPresence.UNKNOWN, lookup.check(USER)); + Files.delete(pending); + Files.setLastModifiedTime(scope, java.nio.file.attribute.FileTime.fromMillis(2000)); + assertEquals(PlayerPresence.NEVER_JOINED, lookup.check(USER)); + Files.move(scope, directory.resolve("previous")); + Files.createDirectory(scope); + Path replacementPending = Files.createTempFile(scope, USER + "-", ".dat"); + assertEquals(PlayerPresence.UNKNOWN, lookup.check(USER)); + Files.delete(replacementPending); + Files.delete(scope); + Files.createSymbolicLink(scope, directory.resolve("previous")); + assertEquals(PlayerPresence.UNKNOWN, lookup.check(USER)); + } + + @Test void normalLargeHistoryDirectoryStillSupportsNegativeProof() throws Exception { + for (int i = 0; i < 1200; i++) + Files.write(directory.resolve(new UUID(0, i) + ".dat"), new byte[] {0}); + Files.write(directory.resolve("unrelated-random.dat"), new byte[] {0}); + var lookup = new BukkitPlayerPresenceLookup(directory); + assertEquals(PlayerPresence.NEVER_JOINED, lookup.check(USER)); + assertEquals(PlayerPresence.NEVER_JOINED, + lookup.check(UUID.fromString("b3f2bd99-5325-4a51-a642-12674e147381"))); + try (var files = Files.list(directory)) { assertEquals(1201, files.count()); } + } + + @Test void symbolicFileScopeAndDotDotCannotSupplyNegativeProof() throws Exception { + Path real = Files.createDirectory(directory.resolve("déjà-joined-歴史")); + Path alias = Files.createSymbolicLink(directory.resolve("alias"), real); + assertEquals(PlayerPresence.UNKNOWN, new BukkitPlayerPresenceLookup(alias).check(USER)); + assertEquals(PlayerPresence.UNKNOWN, + new BukkitPlayerPresenceLookup(alias.resolve("..").resolve(real.getFileName())).check(USER)); + Path file = Files.write(directory.resolve("record"), gzip(completeHistory())); + Files.createSymbolicLink(real.resolve(USER + ".dat"), file); + assertEquals(PlayerPresence.UNKNOWN, new BukkitPlayerPresenceLookup(real).check(USER)); + assertEquals(PlayerPresence.UNKNOWN, new BukkitPlayerPresenceLookup(file).check(USER)); + } + + @Test void joinedSnapshotAndEventsCoverUnsavedPlayersAndRetirement() { + Player live = mock(Player.class); when(live.getUniqueId()).thenReturn(USER); + Server server = mock(Server.class); doReturn(java.util.List.of(live)).when(server).getOnlinePlayers(); + var lookup = new BukkitPlayerPresenceLookup(directory); lookup.captureJoinedPlayers(server); + assertEquals(PlayerPresence.JOINED, lookup.check(USER)); + verify(server).getOnlinePlayers(); verifyNoMoreInteractions(server); + lookup.close(); lookup.onJoin(new PlayerJoinEvent(live, "synthetic")); + assertEquals(PlayerPresence.UNKNOWN, lookup.check(USER)); + var replacement = new BukkitPlayerPresenceLookup(null); + replacement.onJoin(new PlayerJoinEvent(live, "synthetic")); + assertEquals(PlayerPresence.JOINED, replacement.check(USER)); + } + + @Test void failedOnlineSnapshotDisablesNegativeEvidenceButKeepsValidHistoryPositive() throws Exception { + Server server = mock(Server.class); when(server.getOnlinePlayers()).thenThrow(new IllegalStateException("synthetic")); + var lookup = new BukkitPlayerPresenceLookup(directory); lookup.captureJoinedPlayers(server); + assertEquals(PlayerPresence.UNKNOWN, lookup.check(USER)); + Files.write(directory.resolve(USER + ".dat"), gzip(completeHistory())); + assertEquals(PlayerPresence.JOINED, lookup.check(USER)); + } + + @Test void unsupportedServerCannotSupplyNegativeScope() { + Server unsupported = mock(Server.class); + assertNull(BukkitPlayerPresenceLookup.capturePrimaryHistoryDirectory(unsupported)); + verifyNoInteractions(unsupported); + assertNull(BukkitPlayerPresenceLookup.capturePrimaryHistoryDirectory(null)); + } + + @TestFactory java.util.stream.Stream everyTruncatedCompleteFrameStaysUnknownAndUnchanged() throws Exception { + byte[] record = gzip(completeHistory()); + return java.util.stream.IntStream.range(0, record.length).mapToObj(length -> + DynamicTest.dynamicTest("truncated gzip bytes=" + length, () -> { + byte[] truncated = java.util.Arrays.copyOf(record, length); + Path file = Files.write(directory.resolve(USER + ".dat"), truncated); + assertEquals(PlayerPresence.UNKNOWN, new BukkitPlayerPresenceLookup(directory).check(USER)); + assertArrayEquals(truncated, Files.readAllBytes(file)); + })); + } + + @Test void malformedTypedAndAdjacentPayloadsCannotBecomePositiveOrNegativeHistory() throws Exception { + var bad = new java.util.ArrayList(); + byte[] payload = completeHistory(); byte[] valid = gzip(payload); + bad.add(gzip(java.util.Arrays.copyOf(payload, payload.length - 1))); + bad.add(gzip(java.util.Arrays.copyOf(payload, payload.length + 1))); + bad.add(gzip(new byte[] {10, 0, 0, 13, 0, 0, 0})); // Unknown named tag. + bad.add(gzip(new byte[] {10, 0, 0, 7, 0, 0, -1, -1, -1, -1, 0})); // Negative array length. + bad.add(gzip(new byte[] {10, 0, 0, 9, 0, 0, 0, 0, 0, 0, 1, 0})); // Nonempty END list. + bad.add(gzip(new byte[] {10, 0, 0, 8, 0, 0, 0, 1, (byte) 0xc2, 0})); // Truncated modified UTF. + bad.add(gzip(new byte[] {10, 0, 0, 1, 0, 0, 1, 1, 0, 0, 2, 0})); // Duplicate name. + byte[] checksum = valid.clone(); checksum[checksum.length - 8] ^= 1; bad.add(checksum); + byte[] reserved = valid.clone(); reserved[3] = (byte) 128; bad.add(reserved); + bad.add(java.util.Arrays.copyOf(valid, valid.length + 1)); + byte[] doubleFrame = new byte[valid.length * 2]; + System.arraycopy(valid, 0, doubleFrame, 0, valid.length); + System.arraycopy(valid, 0, doubleFrame, valid.length, valid.length); bad.add(doubleFrame); + var depthBytes = new ByteArrayOutputStream(); + try (var out = new DataOutputStream(depthBytes)) { + out.writeByte(10); out.writeUTF(""); + for (int depth = 0; depth < 70; depth++) { out.writeByte(10); out.writeUTF("nested"); } + for (int depth = 0; depth <= 70; depth++) out.writeByte(0); + } + bad.add(gzip(depthBytes.toByteArray())); + var nodes = new ByteArrayOutputStream(); + try (var out = new DataOutputStream(nodes)) { + out.writeByte(10); out.writeUTF(""); out.writeByte(9); out.writeUTF("list"); + out.writeByte(1); out.writeInt(100001); out.write(new byte[100001]); out.writeByte(0); + } + bad.add(gzip(nodes.toByteArray())); + byte[] expanded = new byte[8 * 1024 * 1024 + 1]; bad.add(gzip(expanded)); + bad.add(new byte[1024 * 1024 + 1]); + for (int index = 0; index < bad.size(); index++) { + Path file = Files.write(directory.resolve(USER + ".dat"), bad.get(index)); + assertEquals(PlayerPresence.UNKNOWN, new BukkitPlayerPresenceLookup(directory).check(USER), "case=" + index); + assertArrayEquals(bad.get(index), Files.readAllBytes(file)); + } + } + + @Test void optionalGzipFilenameAndHeaderChecksumRemainValidData() throws Exception { + byte[] original = gzip(completeHistory()); + var header = new ByteArrayOutputStream(); + header.write(original, 0, 10); + byte[] first = header.toByteArray(); first[3] = 10; header.reset(); header.write(first); + header.write("synthetic.dat".getBytes(java.nio.charset.StandardCharsets.US_ASCII)); header.write(0); + var crc = new java.util.zip.CRC32(); crc.update(header.toByteArray()); + header.write((int) crc.getValue() & 255); header.write((int) (crc.getValue() >> 8) & 255); + header.write(original, 10, original.length - 10); + byte[] framed = header.toByteArray(); Files.write(directory.resolve(USER + ".dat"), framed); + assertEquals(PlayerPresence.JOINED, new BukkitPlayerPresenceLookup(directory).check(USER)); + } + + @Test void validExactByteDepthAndNodeLimitsRemainAccepted() throws Exception { + var records = new java.util.ArrayList(); + byte[] original = gzip(completeHistory()); + byte[] compressedLimit = new byte[1024 * 1024]; + System.arraycopy(original, 0, compressedLimit, 0, 10); compressedLimit[3] = 16; + int padding = compressedLimit.length - original.length; + java.util.Arrays.fill(compressedLimit, 10, 10 + padding - 1, (byte) 'a'); + System.arraycopy(original, 10, compressedLimit, 10 + padding, original.length - 10); + records.add(compressedLimit); + var expanded = new ByteArrayOutputStream(); + try (var out = new DataOutputStream(expanded)) { + out.writeByte(10); out.writeUTF(""); out.writeByte(7); out.writeUTF(""); + int count = 8 * 1024 * 1024 - 11; + out.writeInt(count); out.write(new byte[count]); out.writeByte(0); + } + assertEquals(8 * 1024 * 1024, expanded.size()); records.add(gzip(expanded.toByteArray())); + var depth = new ByteArrayOutputStream(); + try (var out = new DataOutputStream(depth)) { + out.writeByte(10); out.writeUTF(""); + for (int index = 0; index < 64; index++) { out.writeByte(10); out.writeUTF("nested"); } + for (int index = 0; index <= 64; index++) out.writeByte(0); + } + records.add(gzip(depth.toByteArray())); + var nodes = new ByteArrayOutputStream(); + try (var out = new DataOutputStream(nodes)) { + out.writeByte(10); out.writeUTF(""); out.writeByte(9); out.writeUTF("list"); + out.writeByte(1); out.writeInt(99998); out.write(new byte[99998]); out.writeByte(0); + } + records.add(gzip(nodes.toByteArray())); + for (int index = 0; index < records.size(); index++) { + Files.write(directory.resolve(USER + ".dat"), records.get(index)); + assertEquals(PlayerPresence.JOINED, new BukkitPlayerPresenceLookup(directory).check(USER), "limit=" + index); + } + } + + @Test void joinEvidenceSaturationKeepsUnknownVisibleAndPreservesKnownPositive() { + var lookup = new BukkitPlayerPresenceLookup(directory); + Player live = mock(Player.class); + var index = new java.util.concurrent.atomic.AtomicLong(); + when(live.getUniqueId()).thenAnswer(call -> new UUID(0, index.incrementAndGet())); + PlayerJoinEvent event = new PlayerJoinEvent(live, "synthetic"); + for (int count = 0; count < 65537; count++) lookup.onJoin(event); + assertEquals(PlayerPresence.JOINED, lookup.check(new UUID(0, 1))); + assertEquals(PlayerPresence.UNKNOWN, lookup.check(new UUID(0, 65537))); + assertEquals(PlayerPresence.UNKNOWN, lookup.check(USER)); + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/presence/PlayerPresenceServiceTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/presence/PlayerPresenceServiceTest.java new file mode 100644 index 000000000..d97290c9f --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/presence/PlayerPresenceServiceTest.java @@ -0,0 +1,220 @@ +package com.bencodez.advancedcore.tests.user.presence; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.util.ArrayDeque; +import java.util.UUID; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.atomic.AtomicInteger; +import com.bencodez.advancedcore.api.user.presence.PlayerPresence; +import com.bencodez.advancedcore.api.user.presence.PlayerPresenceService; +import org.junit.jupiter.api.Test; + +class PlayerPresenceServiceTest { + private static final UUID USER = UUID.fromString("d4519fc4-a782-4d11-8235-617209238441"); + + private static PlayerPresence value(CompletionStage result) { + return result.toCompletableFuture().join(); + } + + @Test void callerDoesNotReadHistoryAndPreservesAllThreeEvidenceOutcomes() { + for (PlayerPresence evidence : PlayerPresence.values()) { + var queued = new ArrayDeque(); + var reads = new AtomicInteger(); + try (var service = new PlayerPresenceService(queued::add, identity -> { + assertEquals(USER, identity); reads.incrementAndGet(); return evidence; + })) { + var result = service.checkAsync(USER); + assertFalse(result.toCompletableFuture().isDone()); + assertEquals(0, reads.get()); + queued.remove().run(); + assertEquals(evidence, value(result)); + assertEquals(1, reads.get()); + } + } + } + + @Test void absentIdentityAndRejectedWorkerDoNotBecomeNegativeHistory() { + var reads = new AtomicInteger(); + try (var service = new PlayerPresenceService(task -> { throw new RejectedExecutionException(); }, id -> { + reads.incrementAndGet(); return PlayerPresence.NEVER_JOINED; + })) { + assertEquals(PlayerPresence.UNKNOWN, value(service.checkAsync(null))); + assertEquals(PlayerPresence.UNKNOWN, value(service.checkAsync(USER))); + assertEquals(0, reads.get()); + } + } + + @Test void failedAndMissingProviderEvidenceRemainUnknown() { + try (var service = new PlayerPresenceService(Runnable::run, id -> { throw new IOException("synthetic"); })) { + assertEquals(PlayerPresence.UNKNOWN, value(service.checkAsync(USER))); + } + try (var service = new PlayerPresenceService(Runnable::run, id -> null)) { + assertEquals(PlayerPresence.UNKNOWN, value(service.checkAsync(USER))); + } + } + + @Test void saturationIsBoundedAndCompletedReadFreesAdmission() { + var queued = new ArrayDeque(); + try (var service = new PlayerPresenceService(queued::add, id -> PlayerPresence.JOINED)) { + for (int i = 0; i < 128; i++) assertFalse(service.checkAsync(USER).toCompletableFuture().isDone()); + assertEquals(PlayerPresence.UNKNOWN, value(service.checkAsync(USER))); + assertEquals(128, queued.size()); + queued.remove().run(); + assertFalse(service.checkAsync(USER).toCompletableFuture().isDone()); + assertEquals(128, queued.size()); + } + } + + @Test void retirementCompletesQueuedRequestsWithoutReadingOrStoppingSharedWorker() { + var queued = new ArrayDeque(); + var reads = new AtomicInteger(); + var service = new PlayerPresenceService(queued::add, id -> { reads.incrementAndGet(); return PlayerPresence.JOINED; }); + var result = service.checkAsync(USER); + service.close(); service.close(); + assertEquals(PlayerPresence.UNKNOWN, value(result)); + queued.remove().run(); + assertEquals(0, reads.get()); + assertEquals(PlayerPresence.UNKNOWN, value(service.checkAsync(USER))); + queued.add(reads::incrementAndGet); queued.remove().run(); + assertEquals(1, reads.get()); + } + + @Test void retiredStartedReadCannotPublishPositiveIntoItsSuccessor() { + var queued = new ArrayDeque(); + var holder = new PlayerPresenceService[1]; + holder[0] = new PlayerPresenceService(queued::add, id -> { + holder[0].close(); return PlayerPresence.JOINED; + }); + var predecessor = holder[0].checkAsync(USER); + queued.remove().run(); + assertEquals(PlayerPresence.UNKNOWN, value(predecessor)); + try (var successor = new PlayerPresenceService(queued::add, id -> PlayerPresence.NEVER_JOINED)) { + var result = successor.checkAsync(USER); queued.remove().run(); + assertEquals(PlayerPresence.NEVER_JOINED, value(result)); + assertEquals(PlayerPresence.UNKNOWN, value(predecessor)); + } + } + + @Test void verifiedHistoricalUuidCanSupplyPositiveMigrationEvidence() { + var worker = new ArrayDeque(); + UUID historical = UUID.fromString("b3f2bd99-5325-4a51-a642-12674e147381"); + try (var service = new PlayerPresenceService(worker::add, + id -> id.equals(historical) ? PlayerPresence.JOINED : PlayerPresence.NEVER_JOINED)) { + var result = service.checkIdentitiesAsync(java.util.List.of(USER, historical)); + worker.remove().run(); + assertFalse(result.toCompletableFuture().isDone()); + worker.remove().run(); + assertEquals(PlayerPresence.JOINED, value(result)); + } + } + + @Test void incompleteMigrationProofAndUnboundedAliasInputsStayUnknown() { + var worker = new ArrayDeque(); + UUID historical = UUID.fromString("b3f2bd99-5325-4a51-a642-12674e147381"); + try (var service = new PlayerPresenceService(worker::add, + id -> id.equals(historical) ? PlayerPresence.UNKNOWN : PlayerPresence.NEVER_JOINED)) { + var result = service.checkIdentitiesAsync(java.util.List.of(USER, historical)); + worker.remove().run(); worker.remove().run(); + assertEquals(PlayerPresence.UNKNOWN, value(result)); + assertEquals(PlayerPresence.UNKNOWN, value(service.checkIdentitiesAsync(java.util.Arrays.asList(USER, null)))); + assertEquals(PlayerPresence.UNKNOWN, value(service.checkIdentitiesAsync(java.util.List.of()))); + assertEquals(PlayerPresence.UNKNOWN, value(service.checkIdentitiesAsync(java.util.Collections.nCopies(9, USER)))); + assertTrue(worker.isEmpty()); + } + } + + @Test void completeNegativeAliasesAreDeduplicatedAndRetirementStillWins() { + var worker = new ArrayDeque(); + var service = new PlayerPresenceService(worker::add, id -> PlayerPresence.NEVER_JOINED); + var negative = service.checkIdentitiesAsync(java.util.List.of(USER, USER)); + assertEquals(1, worker.size()); worker.remove().run(); + assertEquals(PlayerPresence.NEVER_JOINED, value(negative)); + var retiring = service.checkIdentitiesAsync(java.util.List.of(USER)); + service.close(); + assertEquals(PlayerPresence.UNKNOWN, value(retiring)); + worker.remove().run(); + } + + @Test void retirementInvalidatesUnfinishedAggregateEvenAfterOneAliasJoined() { + var worker = new ArrayDeque(); + UUID historical = UUID.fromString("b3f2bd99-5325-4a51-a642-12674e147381"); + var reads = new AtomicInteger(); + var service = new PlayerPresenceService(worker::add, id -> { + reads.incrementAndGet(); + return id.equals(USER) ? PlayerPresence.JOINED : PlayerPresence.NEVER_JOINED; + }); + var aggregate = service.checkIdentitiesAsync(java.util.List.of(USER, historical)); + worker.remove().run(); + assertFalse(aggregate.toCompletableFuture().isDone()); + service.close(); + assertEquals(PlayerPresence.UNKNOWN, value(aggregate)); + worker.remove().run(); + assertEquals(1, reads.get()); + assertEquals(PlayerPresence.UNKNOWN, value(aggregate)); + } + + @Test void completedAggregateKeepsPositiveEvidenceAfterRetirement() { + var worker = new ArrayDeque(); + UUID historical = UUID.fromString("b3f2bd99-5325-4a51-a642-12674e147381"); + var service = new PlayerPresenceService(worker::add, + id -> id.equals(USER) ? PlayerPresence.JOINED : PlayerPresence.UNKNOWN); + var aggregate = service.checkIdentitiesAsync(java.util.List.of(USER, historical)); + worker.remove().run(); worker.remove().run(); + assertEquals(PlayerPresence.JOINED, value(aggregate)); + service.close(); + assertEquals(PlayerPresence.JOINED, value(aggregate)); + } + + @Test void aggregateAdmissionCannotGrowPastTheSharedPendingBound() { + var worker = new ArrayDeque(); + var service = new PlayerPresenceService(worker::add, id -> PlayerPresence.JOINED); + for (int index = 0; index < 64; index++) + assertFalse(service.checkIdentitiesAsync(java.util.List.of(USER)).toCompletableFuture().isDone()); + assertEquals(64, worker.size()); + assertEquals(PlayerPresence.UNKNOWN, value(service.checkIdentitiesAsync(java.util.List.of(USER)))); + assertEquals(PlayerPresence.UNKNOWN, value(service.checkAsync(USER))); + worker.remove().run(); + assertFalse(service.checkIdentitiesAsync(java.util.List.of(USER)).toCompletableFuture().isDone()); + service.close(); + while (!worker.isEmpty()) worker.remove().run(); + } + @Test void completionCanReentrantlyRetireQueuedIndividualAndAggregateRequests() { + var worker = new ArrayDeque(); + var reads = new AtomicInteger(); + var service = new PlayerPresenceService(worker::add, id -> { + reads.incrementAndGet(); return PlayerPresence.JOINED; + }); + var first = service.checkAsync(USER); + var queued = service.checkAsync(USER); + var aggregate = service.checkIdentitiesAsync(java.util.List.of(USER)); + var callback = first.thenAccept(evidence -> { + assertEquals(PlayerPresence.JOINED, evidence); + service.close(); + assertEquals(PlayerPresence.UNKNOWN, value(service.checkAsync(USER))); + }); + worker.remove().run(); + callback.toCompletableFuture().join(); + assertEquals(PlayerPresence.JOINED, value(first)); + assertEquals(PlayerPresence.UNKNOWN, value(queued)); + assertEquals(PlayerPresence.UNKNOWN, value(aggregate)); + while (!worker.isEmpty()) worker.remove().run(); + assertEquals(1, reads.get()); + } + + @Test void aggregateCompletionCanReentrantlyRetireAnotherAggregate() { + var worker = new ArrayDeque(); + var service = new PlayerPresenceService(worker::add, id -> PlayerPresence.NEVER_JOINED); + var first = service.checkIdentitiesAsync(java.util.List.of(USER)); + var queued = service.checkIdentitiesAsync(java.util.List.of(USER)); + var callback = first.thenRun(service::close); + worker.remove().run(); + callback.toCompletableFuture().join(); + assertEquals(PlayerPresence.NEVER_JOINED, value(first)); + assertEquals(PlayerPresence.UNKNOWN, value(queued)); + while (!worker.isEmpty()) worker.remove().run(); + } + +} diff --git a/docs/checked-player-presence.md b/docs/checked-player-presence.md new file mode 100644 index 000000000..64900c3ca --- /dev/null +++ b/docs/checked-player-presence.md @@ -0,0 +1,54 @@ +# Checked server player presence + +`AdvancedCorePlugin.checkPlayerPresenceAsync(UUID)` reads server join-history evidence for an already resolved UUID. +It returns `CompletionStage` with `JOINED`, `NEVER_JOINED` or `UNKNOWN`. It does not resolve a name, +create a user, write history or use plugin-row existence as join proof. Existing boolean identity-validation APIs +remain unchanged. Resolve the correct current server UUID on an appropriate worker before calling this API; +an unresolved or ambiguous migrated identity must remain unknown rather than being guessed from a name. + +`checkPlayerPresenceIdentitiesAsync(Collection)` accepts up to eight verified current/historical UUIDs +belonging to the same person. After input validation, positive evidence from any supplied identity is sufficient. Negative evidence +requires every supplied identity to be confirmed negative; uncertain, empty, missing or oversized input stays +unknown. The API does not discover aliases or validate their ownership. Supply a null member to represent an +unresolved part of a migration, rather than silently omitting it: this makes the complete request unknown before +any read is queued. Duplicate UUIDs are read only once. + +The requesting thread performs no history, filesystem or UUID lookup. The existing AdvancedCore storage executor +owns the read, with at most 128 admitted requests per service. Missing identity, saturation, executor rejection, +provider failure and retired requests return `UNKNOWN`. Completions may run on a worker, requesting or retiring +thread: use an asynchronous continuation or the correct platform scheduler for client work. Never attach +blocking storage or player effects directly. A result is an inspection snapshot, not permission to mutate a +player or proof that a later menu/configuration generation is still current. + +The runtime captures server-wide player-storage directory metadata during enable on the platform owner. +Recognized CraftBukkit implementations provide either the level-directory API or the player's storage-directory +metadata; a world/dimension folder is never assumed to own player data. Unsupported layouts remain `UNKNOWN`. +The worker reads UUID-named history directly without calling the server's offline-player loader, profile lookup, +data fixer or migration logic. Complete GZIP/NBT records supply `JOINED`; malformed or incomplete records remain +`UNKNOWN`. Current, recovery and retained migration files are read without creating, moving or rewriting them. +Input is bounded to 1 MiB compressed, 8 MiB expanded, depth 64 and 100,000 structural nodes; oversized history is +unknown. Negative evidence requires a readable unchanged directory with no record or recovery file for that UUID. +Native interrupted-save files (`UUID-random.dat`) and corruption backups prevent negative answers even when no +canonical record survives; their contents are never read as positive proof or changed. A worker-owned directory +index is shared across negative checks and refreshed when the directory identity or modification time changes. +Scanning is bounded to 1,000,000 entries and 65,536 recovery identities; incomplete, failed or unstable scans +remain unknown. Closing the lookup clears the index without waiting for an in-flight scan. +A missing directory, symbolic link, failed read or changed scope remains unknown. No raw history or paths are +returned or logged. + +A generation-owned join listener and initial UUID-only online snapshot supply positive evidence for players whose +first history file has not yet been saved. The requesting/worker threads never inspect player entity state. +This evidence retains at most 65,536 joined UUIDs; failed snapshot capture or capacity exhaustion disables new +negative answers while retained positive evidence remains usable. Retired listeners cannot repopulate the old +generation. Alias requests share the same admission/retirement boundary as individual reads: an unfinished +aggregate becomes `UNKNOWN` at retirement even if one alias already completed positive. + +These are checks against the server's retained history for the resolved identity. They cannot reconstruct +deleted history or unrelated UUIDs after an identity-mode change. Keep uncertain migrated identities visible. +Shutdown retires requests before existing cleanup shuts down the shared worker. Re-enable retires the previous +service; delayed reads do not publish into its replacement. The service creates no executor or alternate user +storage and does not replace existing lifecycle cleanup. + +An optional display filter must keep both `JOINED` and `UNKNOWN`, and exclude only confirmed `NEVER_JOINED`. +Filtering must not change award eligibility. Exact consumer integration and live platform validation are +separate acceptance checks; passing the library's focused tests does not prove either. From cc06406e0711cf2d4e29efee61e548cf4df29190 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 14 Oct 2026 09:18:18 -0600 Subject: [PATCH 2/7] Reserve checked-presence alias admission atomically --- .../user/presence/PlayerPresenceService.java | 29 ++++++--- .../presence/PlayerPresenceServiceTest.java | 63 +++++++++++++++++++ docs/checked-player-presence.md | 4 +- 3 files changed, 86 insertions(+), 10 deletions(-) diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/PlayerPresenceService.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/PlayerPresenceService.java index 8c9f99aad..1bb42f0a7 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/PlayerPresenceService.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/PlayerPresenceService.java @@ -38,11 +38,7 @@ public CompletionStage checkAsync(UUID identity) { return CompletableFuture.completedStage(PlayerPresence.UNKNOWN); pending.put(result, Boolean.TRUE); } - try { - worker.execute(() -> completeLookup(identity, result)); - } catch (RuntimeException rejected) { - finish(result, PlayerPresence.UNKNOWN); - } + submit(identity, result); return result.minimalCompletionStage(); } @@ -63,14 +59,19 @@ public CompletionStage checkIdentitiesAsync(Collection ide } if (distinct.isEmpty()) return CompletableFuture.completedStage(PlayerPresence.UNKNOWN); CompletableFuture aggregate = new CompletableFuture<>(); + var results = new ArrayList>(distinct.size()); synchronized (admission) { - if (closed || pending.size() >= MAX_PENDING) + if (closed || MAX_PENDING - pending.size() < 1 + distinct.size()) return CompletableFuture.completedStage(PlayerPresence.UNKNOWN); - // The public aggregate owns admission until every alias has settled. - // A completed child cannot outlive retirement of this request. + // Reserve the aggregate and every child before scheduling any lookup. + // Concurrent requests cannot turn an admitted family into partial work. pending.put(aggregate, Boolean.TRUE); + for (UUID ignored : distinct) { + var child = new CompletableFuture(); + pending.put(child, Boolean.TRUE); + results.add(child); + } } - var results = distinct.stream().map(this::checkAsync).map(CompletionStage::toCompletableFuture).toList(); CompletableFuture.allOf(results.toArray(CompletableFuture[]::new)).thenRun(() -> { boolean unknown = false; for (var result : results) { @@ -83,9 +84,19 @@ public CompletionStage checkIdentitiesAsync(Collection ide } finish(aggregate, unknown ? PlayerPresence.UNKNOWN : PlayerPresence.NEVER_JOINED); }); + int index = 0; + for (UUID identity : distinct) submit(identity, results.get(index++)); return aggregate.minimalCompletionStage(); } + private void submit(UUID identity, CompletableFuture result) { + try { + worker.execute(() -> completeLookup(identity, result)); + } catch (RuntimeException rejected) { + finish(result, PlayerPresence.UNKNOWN); + } + } + private void completeLookup(UUID identity, CompletableFuture result) { synchronized (admission) { // Queued retired work never touches the provider or replacement state. diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/presence/PlayerPresenceServiceTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/presence/PlayerPresenceServiceTest.java index d97290c9f..7c9fc930c 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/presence/PlayerPresenceServiceTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/presence/PlayerPresenceServiceTest.java @@ -217,4 +217,67 @@ private static PlayerPresence value(CompletionStage result) { while (!worker.isEmpty()) worker.remove().run(); } + @Test void aliasFamilyRejectsWithoutQueueingPartialReadsWhenCapacityIsInsufficient() { + var worker = new ArrayDeque(); + try (var service = new PlayerPresenceService(worker::add, id -> PlayerPresence.NEVER_JOINED)) { + for (int index = 0; index < 126; index++) service.checkAsync(USER); + var result = service.checkIdentitiesAsync(java.util.List.of(USER, new UUID(0, 2))); + assertTrue(result.toCompletableFuture().isDone(), "Insufficient capacity rejects the complete request immediately"); + assertEquals(PlayerPresence.UNKNOWN, value(result)); + assertEquals(126, worker.size(), "No partial alias work is admitted"); + } + } + + @Test void aliasFamilyReservesEveryChildBeforeReentrantWorkerAdmission() { + var worker = new ArrayDeque(); + var service = new PlayerPresenceService[1]; + var arm = new java.util.concurrent.atomic.AtomicBoolean(); + var competing = new java.util.concurrent.atomic.AtomicReference>(); + service[0] = new PlayerPresenceService(task -> { + if (arm.compareAndSet(true, false)) competing.set(service[0].checkAsync(new UUID(0, 3))); + worker.add(task); + }, id -> PlayerPresence.NEVER_JOINED); + try (var current = service[0]) { + for (int index = 0; index < 125; index++) current.checkAsync(USER); + arm.set(true); + var result = current.checkIdentitiesAsync(java.util.List.of(USER, new UUID(0, 2))); + assertNotNull(competing.get()); + assertTrue(competing.get().toCompletableFuture().isDone()); + assertEquals(PlayerPresence.UNKNOWN, value(competing.get())); + assertEquals(127, worker.size()); + while (!worker.isEmpty()) worker.remove().run(); + assertEquals(PlayerPresence.NEVER_JOINED, value(result)); + } + } + + @Test void rejectedAliasSubmissionSettlesFamilyAndReleasesEveryReservation() { + var worker = new ArrayDeque(); var submissions = new AtomicInteger(); + try (var service = new PlayerPresenceService(task -> { + if (submissions.incrementAndGet() == 1) throw new RejectedExecutionException(); + worker.add(task); + }, id -> PlayerPresence.NEVER_JOINED)) { + var family = service.checkIdentitiesAsync(java.util.List.of(USER, new UUID(0, 2))); + assertFalse(family.toCompletableFuture().isDone()); + assertEquals(1, worker.size()); worker.remove().run(); + assertEquals(PlayerPresence.UNKNOWN, value(family)); + for (int index = 0; index < 128; index++) + assertFalse(service.checkAsync(USER).toCompletableFuture().isDone()); + assertEquals(128, worker.size()); + assertEquals(PlayerPresence.UNKNOWN, value(service.checkAsync(USER))); + } + } + + @Test void retirementDuringFirstAliasSubmissionCancelsEveryReservedChild() { + var worker = new ArrayDeque(); var reads = new AtomicInteger(); + var holder = new PlayerPresenceService[1]; + holder[0] = new PlayerPresenceService(task -> { + holder[0].close(); worker.add(task); + }, id -> { reads.incrementAndGet(); return PlayerPresence.JOINED; }); + var family = holder[0].checkIdentitiesAsync(java.util.List.of(USER, new UUID(0, 2))); + assertEquals(PlayerPresence.UNKNOWN, value(family)); + while (!worker.isEmpty()) worker.remove().run(); + assertEquals(0, reads.get()); + assertEquals(PlayerPresence.UNKNOWN, value(holder[0].checkAsync(USER))); + } + } diff --git a/docs/checked-player-presence.md b/docs/checked-player-presence.md index 64900c3ca..deba8f636 100644 --- a/docs/checked-player-presence.md +++ b/docs/checked-player-presence.md @@ -14,7 +14,9 @@ unresolved part of a migration, rather than silently omitting it: this makes the any read is queued. Duplicate UUIDs are read only once. The requesting thread performs no history, filesystem or UUID lookup. The existing AdvancedCore storage executor -owns the read, with at most 128 admitted requests per service. Missing identity, saturation, executor rejection, +owns the read, with at most 128 admitted entries per service. An alias request atomically reserves one entry +for its public aggregate plus one for each distinct child. Insufficient capacity rejects the complete request +as unknown without scheduling partial alias reads. Missing identity, saturation, executor rejection, provider failure and retired requests return `UNKNOWN`. Completions may run on a worker, requesting or retiring thread: use an asynchronous continuation or the correct platform scheduler for client work. Never attach blocking storage or player effects directly. A result is an inspection snapshot, not permission to mutate a From 82e172a3b3a5867303ca374370e1e9c42675e5a8 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 14 Oct 2026 10:13:12 -0600 Subject: [PATCH 3/7] Preserve checked history on keyless filesystems and unreadable siblings --- .../presence/BukkitPlayerPresenceLookup.java | 63 ++++-- .../presence/PlayerHistoryStabilityTest.java | 190 ++++++++++++++++++ docs/checked-player-presence.md | 7 +- 3 files changed, 243 insertions(+), 17 deletions(-) create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/api/user/presence/PlayerHistoryStabilityTest.java diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/BukkitPlayerPresenceLookup.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/BukkitPlayerPresenceLookup.java index 38b005336..a52a90630 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/BukkitPlayerPresenceLookup.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/BukkitPlayerPresenceLookup.java @@ -25,10 +25,15 @@ public final class BukkitPlayerPresenceLookup implements PlayerPresenceLookup, Listener, AutoCloseable { private static final int MAX_DIRECTORY_ENTRIES = 1_000_000; private static final int MAX_RECOVERY_IDENTITIES = 65536; - private record RecoveryIndex(Object fileKey, FileTime modified, Set identities) { } + private record ScopeIdentity(Object fileKey, FileTime created) { } + private record RecoveryIndex(ScopeIdentity scope, FileTime modified, Set identities) { } private static final RecoveryIndex RETIRED = new RecoveryIndex(null, null, Set.of()); private final AtomicReference recoveryIndex = new AtomicReference<>(); private final Object recoveryScan = new Object(); + @FunctionalInterface interface AttributeReader { + BasicFileAttributes read(Path path) throws IOException; + } + private final AttributeReader attributeReader; private final Path historyDirectory; private final HashSet joined = new HashSet<>(); private boolean negativeUnavailable; @@ -36,6 +41,11 @@ private record RecoveryIndex(Object fileKey, FileTime modified, Set identi /** The caller must supply the actual server-owned playerdata directory. */ public BukkitPlayerPresenceLookup(Path historyDirectory) { + this(historyDirectory, path -> Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS)); + } + + BukkitPlayerPresenceLookup(Path historyDirectory, AttributeReader attributeReader) { + this.attributeReader = java.util.Objects.requireNonNull(attributeReader); Path candidate = historyDirectory == null ? null : historyDirectory.toAbsolutePath(); if (candidate != null) for (Path part : candidate) { if (part.toString().equals("..")) { candidate = null; break; } @@ -109,16 +119,17 @@ public PlayerPresence check(UUID identity) { try { if (!unlinkedScope()) return PlayerPresence.UNKNOWN; BasicFileAttributes before = attributes(historyDirectory); - if (!before.isDirectory() || before.fileKey() == null) return PlayerPresence.UNKNOWN; - try (var ignored = Files.newDirectoryStream(historyDirectory)) { } - boolean present = false, valid = false; + ScopeIdentity scope = identity(before); + if (!before.isDirectory() || before.isOther() || scope == null) return PlayerPresence.UNKNOWN; + boolean present = false, valid = false, failed = false; for (String suffix : new String[] { ".dat", ".dat_old", ".dat_new", ".dat.tmp", ".dat.offline-read", ".dat_old.offline-read" }) { Path file = historyDirectory.resolve(identity + suffix); try { BasicFileAttributes record = attributes(file); present = true; - if (!record.isRegularFile() || record.fileKey() == null + ScopeIdentity recordIdentity = identity(record); + if (!record.isRegularFile() || record.isOther() || recordIdentity == null || record.size() > PlayerHistoryFormat.MAX_COMPRESSED) continue; byte[] bytes; try (var input = FileChannel.open(file, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) { @@ -128,25 +139,30 @@ public PlayerPresence check(UUID identity) { bytes = java.util.Arrays.copyOf(buffer.array(), buffer.position()); } BasicFileAttributes after = attributes(file); - if (!record.fileKey().equals(after.fileKey()) || record.size() != after.size() + if (!recordIdentity.equals(identity(after)) || !after.isRegularFile() || after.isOther() + || record.size() != after.size() || !record.lastModifiedTime().equals(after.lastModifiedTime())) continue; - try { valid |= PlayerHistoryFormat.valid(bytes); } - catch (IOException invalid) { /* Malformed history stays unknown, never absent. */ } + try { + if (PlayerHistoryFormat.valid(bytes)) { valid = true; break; } + } catch (IOException invalid) { /* Malformed history stays unknown, never absent. */ } } catch (NoSuchFileException absent) { // Only a confirmed missing-file result can support a negative. + } catch (IOException unavailable) { + failed = true; + // A sibling may still establish positive retained history. } } // Native saves use UUID-random.dat; their existence prevents absence // proof even if a crash left no canonical record. Never load or alter them. - boolean recovery = !valid && !present && hasRecovery(identity, before); + boolean recovery = !valid && !present && !failed && hasRecovery(identity, before); BasicFileAttributes after = attributes(historyDirectory); - if (!unlinkedScope() || !after.isDirectory() || !before.fileKey().equals(after.fileKey())) + if (!unlinkedScope() || !after.isDirectory() || after.isOther() || !scope.equals(identity(after))) return PlayerPresence.UNKNOWN; live = liveEvidence(identity); if (live != null) return live; if (valid) return PlayerPresence.JOINED; synchronized (joined) { - if (closed || negativeUnavailable || present || recovery + if (closed || negativeUnavailable || present || failed || recovery || !before.lastModifiedTime().equals(after.lastModifiedTime())) return PlayerPresence.UNKNOWN; return joined.contains(identity) ? PlayerPresence.JOINED : PlayerPresence.NEVER_JOINED; } @@ -156,10 +172,16 @@ public PlayerPresence check(UUID identity) { } private boolean hasRecovery(UUID identity, BasicFileAttributes scope) throws IOException { + // Even a cached absence index needs currently readable directory scope. + try (var ignored = Files.newDirectoryStream(historyDirectory)) { } synchronized (recoveryScan) { + ScopeIdentity scopeIdentity = identity(scope); + if (scopeIdentity == null) return true; RecoveryIndex cached = recoveryIndex.get(); if (cached == RETIRED) return true; - if (cached != null && cached.fileKey().equals(scope.fileKey()) + // Timestamps alone cannot certify that cached entries describe the + // current keyless directory; replacement or coarse times may collide. + if (scopeIdentity.fileKey() != null && cached != null && cached.scope().equals(scopeIdentity) && cached.modified().equals(scope.lastModifiedTime())) return cached.identities().contains(identity); HashSet identities = new HashSet<>(); @@ -184,23 +206,32 @@ private boolean hasRecovery(UUID identity, BasicFileAttributes scope) throws IOE } } BasicFileAttributes after = attributes(historyDirectory); - if (!unlinkedScope() || !after.isDirectory() || !scope.fileKey().equals(after.fileKey()) + if (!unlinkedScope() || !after.isDirectory() || after.isOther() || !scopeIdentity.equals(identity(after)) || !scope.lastModifiedTime().equals(after.lastModifiedTime())) return true; - RecoveryIndex scanned = new RecoveryIndex(scope.fileKey(), scope.lastModifiedTime(), Set.copyOf(identities)); + RecoveryIndex scanned = new RecoveryIndex(scopeIdentity, scope.lastModifiedTime(), Set.copyOf(identities)); // close never waits for the scan and a retired scan cannot repopulate it. if (!recoveryIndex.compareAndSet(cached, scanned)) return true; return identities.contains(identity); } } + private static ScopeIdentity identity(BasicFileAttributes attributes) { + Object key = attributes.fileKey(); + FileTime created = attributes.creationTime(); + // Some providers (including Windows) omit keys. An unavailable birth time + // cannot support the fallback scope check; retained results stay unknown. + if (key == null && (created == null || created.equals(FileTime.fromMillis(0)))) return null; + return new ScopeIdentity(key, created); + } + private boolean unlinkedScope() { for (Path part = historyDirectory; part != null; part = part.getParent()) if (Files.isSymbolicLink(part)) return false; return true; } - private static BasicFileAttributes attributes(Path path) throws IOException { - return Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + private BasicFileAttributes attributes(Path path) throws IOException { + return attributeReader.read(path); } @Override public void close() { diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/user/presence/PlayerHistoryStabilityTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/user/presence/PlayerHistoryStabilityTest.java new file mode 100644 index 000000000..a06582363 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/user/presence/PlayerHistoryStabilityTest.java @@ -0,0 +1,190 @@ +package com.bencodez.advancedcore.api.user.presence; + +import static org.junit.jupiter.api.Assertions.*; +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.nio.file.AccessDeniedException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.FileTime; +import java.util.UUID; +import java.util.zip.GZIPOutputStream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class PlayerHistoryStabilityTest { + private static final UUID USER = UUID.fromString("d4519fc4-a782-4d11-8235-617209238441"); + @TempDir Path directory; + + private byte[] history() throws Exception { + var nbt = new ByteArrayOutputStream(); + try (var out = new DataOutputStream(nbt)) { + out.writeByte(10); out.writeUTF(""); + out.writeByte(3); out.writeUTF("DataVersion"); out.writeInt(1); + out.writeByte(11); out.writeUTF("UUID"); out.writeInt(4); + out.writeInt((int) (USER.getMostSignificantBits() >>> 32)); out.writeInt((int) USER.getMostSignificantBits()); + out.writeInt((int) (USER.getLeastSignificantBits() >>> 32)); out.writeInt((int) USER.getLeastSignificantBits()); + out.writeByte(10); out.writeUTF("bukkit"); + out.writeByte(8); out.writeUTF("lastKnownName"); out.writeUTF("Synthetic\r\n歴史"); out.writeByte(0); + out.writeByte(0); + } + var bytes = new ByteArrayOutputStream(); + try (var gzip = new GZIPOutputStream(bytes)) { gzip.write(nbt.toByteArray()); } + return bytes.toByteArray(); + } + + private static BasicFileAttributes actual(Path path) throws java.io.IOException { + return Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + } + private static BasicFileAttributes withoutKey(BasicFileAttributes source) { + return metadata(source, null, source.creationTime()); + } + private static BasicFileAttributes metadata(BasicFileAttributes source, Object key, FileTime created) { + return new BasicFileAttributes() { + public FileTime lastModifiedTime() { return source.lastModifiedTime(); } + public FileTime lastAccessTime() { return source.lastAccessTime(); } + public FileTime creationTime() { return created; } + public boolean isRegularFile() { return source.isRegularFile(); } + public boolean isDirectory() { return source.isDirectory(); } + public boolean isSymbolicLink() { return source.isSymbolicLink(); } + public boolean isOther() { return source.isOther(); } + public long size() { return source.size(); } + public Object fileKey() { return key; } + }; + } + + @Test void completeKeylessRecordCanSupplyPositiveHistoryWithoutMutation() throws Exception { + byte[] record = history(); Path file = Files.write(directory.resolve(USER + ".dat"), record); + try (var lookup = new BukkitPlayerPresenceLookup(directory, path -> withoutKey(actual(path)))) { + assertEquals(PlayerPresence.JOINED, lookup.check(USER)); + assertArrayEquals(record, Files.readAllBytes(file)); + } + } + + @Test void keylessReadableUnchangedEmptyDirectoryCanSupplyNegativeHistory() { + try (var lookup = new BukkitPlayerPresenceLookup(directory, path -> withoutKey(actual(path)))) { + assertEquals(PlayerPresence.NEVER_JOINED, lookup.check(USER)); + } + } + + @Test void readableValidCurrentRecordSurvivesUnreadableSibling() throws Exception { + byte[] record = history(); Path file = Files.write(directory.resolve(USER + ".dat"), record); + Path sibling = Files.write(directory.resolve(USER + ".dat_old"), new byte[] {0}); + try (var lookup = new BukkitPlayerPresenceLookup(directory, path -> { + if (path.equals(sibling)) throw new AccessDeniedException("synthetic"); + return actual(path); + })) { + assertEquals(PlayerPresence.JOINED, lookup.check(USER)); + assertArrayEquals(record, Files.readAllBytes(file)); + } + } + + @Test void unreadableCurrentRecordDoesNotHideReadableBackupEvidence() throws Exception { + Path current = Files.write(directory.resolve(USER + ".dat"), new byte[] {0}); + byte[] record = history(); Path backup = Files.write(directory.resolve(USER + ".dat_old"), record); + try (var lookup = new BukkitPlayerPresenceLookup(directory, path -> { + if (path.equals(current)) throw new AccessDeniedException("synthetic"); + return actual(path); + })) { + assertEquals(PlayerPresence.JOINED, lookup.check(USER)); + assertArrayEquals(record, Files.readAllBytes(backup)); + } + } + @Test void absentKeysAndUnavailableCreationMetadataStayUnknown() throws Exception { + try (var lookup = new BukkitPlayerPresenceLookup(directory, + path -> metadata(actual(path), null, FileTime.fromMillis(0)))) { + assertEquals(PlayerPresence.UNKNOWN, lookup.check(USER)); + Files.write(directory.resolve(USER + ".dat"), history()); + assertEquals(PlayerPresence.UNKNOWN, lookup.check(USER)); + } + } + + @Test void changedKeylessDirectoryCreationCannotSupplyNegativeProof() { + var reads = new java.util.concurrent.atomic.AtomicInteger(); + try (var lookup = new BukkitPlayerPresenceLookup(directory, path -> { + var attrs = actual(path); + FileTime created = attrs.creationTime(); + if (path.equals(directory) && reads.incrementAndGet() > 1) + created = FileTime.fromMillis(created.toMillis() + 1000); + return metadata(attrs, null, created); + })) { + assertEquals(PlayerPresence.UNKNOWN, lookup.check(USER)); + } + } + + @Test void changedKeylessRecordCreationCannotSupplyPositiveProof() throws Exception { + Path file = Files.write(directory.resolve(USER + ".dat"), history()); + var reads = new java.util.concurrent.atomic.AtomicInteger(); + try (var lookup = new BukkitPlayerPresenceLookup(directory, path -> { + var attrs = actual(path); FileTime created = attrs.creationTime(); + if (path.equals(file) && reads.incrementAndGet() > 1) + created = FileTime.fromMillis(created.toMillis() + 1000); + return metadata(attrs, null, created); + })) { + assertEquals(PlayerPresence.UNKNOWN, lookup.check(USER)); + } + } + + @Test void disappearingAvailableRecordKeyCannotSupplyPositiveProof() throws Exception { + Path file = Files.write(directory.resolve(USER + ".dat"), history()); + var reads = new java.util.concurrent.atomic.AtomicInteger(); + try (var lookup = new BukkitPlayerPresenceLookup(directory, path -> { + var attrs = actual(path); + if (path.equals(file) && reads.incrementAndGet() > 1) return withoutKey(attrs); + return attrs; + })) { + assertEquals(PlayerPresence.UNKNOWN, lookup.check(USER)); + } + } + + @Test void unreadableAbsentCandidateCannotSupplyNegativeProof() { + try (var lookup = new BukkitPlayerPresenceLookup(directory, path -> { + if (path.equals(directory.resolve(USER + ".dat"))) throw new AccessDeniedException("synthetic"); + return actual(path); + })) { + assertEquals(PlayerPresence.UNKNOWN, lookup.check(USER)); + } + } + + @Test void keylessRecoveryRescansEvenWhenDirectoryTimestampsAreUnchanged() throws Exception { + BasicFileAttributes unchangedScope = withoutKey(actual(directory)); + try (var lookup = new BukkitPlayerPresenceLookup(directory, + path -> path.equals(directory) ? unchangedScope : withoutKey(actual(path)))) { + assertEquals(PlayerPresence.NEVER_JOINED, lookup.check(USER)); + Files.createTempFile(directory, USER + "-", ".dat"); + assertEquals(PlayerPresence.UNKNOWN, lookup.check(USER)); + } + } + + @Test void keylessReplacementWithMatchingTimestampsCannotReuseAbsentRecovery() throws Exception { + BasicFileAttributes unchangedScope = withoutKey(actual(directory)); + Path displaced = directory.resolveSibling(directory.getFileName() + "-displaced"); + try (var lookup = new BukkitPlayerPresenceLookup(directory, + path -> path.equals(directory) ? unchangedScope : withoutKey(actual(path)))) { + assertEquals(PlayerPresence.NEVER_JOINED, lookup.check(USER)); + Files.move(directory, displaced); + Files.createDirectory(directory); + Files.createTempFile(directory, USER + "-", ".dat"); + assertEquals(PlayerPresence.UNKNOWN, lookup.check(USER)); + } finally { + Files.deleteIfExists(displaced); + } + } + + @Test void keylessCachedRecoveryIndexRefreshesAfterDirectoryChange() throws Exception { + try (var lookup = new BukkitPlayerPresenceLookup(directory, path -> withoutKey(actual(path)))) { + assertEquals(PlayerPresence.NEVER_JOINED, lookup.check(USER)); + Path retained = Files.createTempFile(directory, USER + "-", ".dat"); + Files.setLastModifiedTime(directory, FileTime.fromMillis(System.currentTimeMillis() + 2000)); + assertEquals(PlayerPresence.UNKNOWN, lookup.check(USER)); + Files.delete(retained); + Files.setLastModifiedTime(directory, FileTime.fromMillis(System.currentTimeMillis() + 4000)); + assertEquals(PlayerPresence.NEVER_JOINED, lookup.check(USER)); + lookup.close(); + assertEquals(PlayerPresence.UNKNOWN, lookup.check(USER)); + } + } + +} diff --git a/docs/checked-player-presence.md b/docs/checked-player-presence.md index deba8f636..86eb8b9d8 100644 --- a/docs/checked-player-presence.md +++ b/docs/checked-player-presence.md @@ -35,7 +35,12 @@ canonical record survives; their contents are never read as positive proof or ch index is shared across negative checks and refreshed when the directory identity or modification time changes. Scanning is bounded to 1,000,000 entries and 65,536 recovery identities; incomplete, failed or unstable scans remain unknown. Closing the lookup clears the index without waiting for an in-flight scan. -A missing directory, symbolic link, failed read or changed scope remains unknown. No raw history or paths are +File keys strengthen stability checks when the provider supplies them. Providers without file keys use +unchanged creation timestamps, modification times and record sizes; unavailable creation metadata remains +unknown. A stable valid record provides positive evidence without inspecting later siblings. A failed +candidate read can prevent negative evidence but cannot erase a readable valid sibling. Directory readability +is rechecked before using an absence index. A missing directory, symbolic link, failed scope read or changed +scope remains unknown. No raw history or paths are returned or logged. A generation-owned join listener and initial UUID-only online snapshot supply positive evidence for players whose From b796ccd4cb8e831614c4d253db14cf65d0f93ac3 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 14 Oct 2026 11:15:32 -0600 Subject: [PATCH 4/7] Require retained scope proof for online player history --- .../advancedcore/AdvancedCorePlugin.java | 10 ++ .../presence/BukkitPlayerPresenceLookup.java | 54 +++++-- .../user/presence/PlayerPresenceLookup.java | 9 ++ .../user/presence/PlayerPresenceService.java | 18 ++- .../presence/CompleteOnlinePresenceTest.java | 67 +++++++++ .../presence/PlayerHistoryStabilityTest.java | 139 +++++++++++++++++- docs/checked-player-presence.md | 15 +- 7 files changed, 291 insertions(+), 21 deletions(-) create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/api/user/presence/CompleteOnlinePresenceTest.java diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java index 5caecff8a..b51023a5b 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java @@ -1369,6 +1369,16 @@ private void loadVersionFile() { : service.checkIdentitiesAsync(identities); } + /** Also require proof that no unmapped offline identity can retain earlier history. */ + public java.util.concurrent.CompletionStage + checkPlayerPresenceIdentitiesAsync(java.util.Collection identities, boolean completeOnlineHistory) { + var service = playerPresenceService; + return service == null + ? java.util.concurrent.CompletableFuture.completedStage( + com.bencodez.advancedcore.api.user.presence.PlayerPresence.UNKNOWN) + : service.checkIdentitiesAsync(identities, completeOnlineHistory); + } + @Override public void onDisable() { var presence = playerPresenceService; diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/BukkitPlayerPresenceLookup.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/BukkitPlayerPresenceLookup.java index a52a90630..4c8d1812d 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/BukkitPlayerPresenceLookup.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/BukkitPlayerPresenceLookup.java @@ -26,8 +26,8 @@ public final class BukkitPlayerPresenceLookup implements PlayerPresenceLookup, L private static final int MAX_DIRECTORY_ENTRIES = 1_000_000; private static final int MAX_RECOVERY_IDENTITIES = 65536; private record ScopeIdentity(Object fileKey, FileTime created) { } - private record RecoveryIndex(ScopeIdentity scope, FileTime modified, Set identities) { } - private static final RecoveryIndex RETIRED = new RecoveryIndex(null, null, Set.of()); + private record RecoveryIndex(ScopeIdentity scope, FileTime modified, Set identities, boolean offlineHistory) { } + private static final RecoveryIndex RETIRED = new RecoveryIndex(null, null, Set.of(), true); private final AtomicReference recoveryIndex = new AtomicReference<>(); private final Object recoveryScan = new Object(); @FunctionalInterface interface AttributeReader { @@ -37,6 +37,7 @@ private record RecoveryIndex(ScopeIdentity scope, FileTime modified, Set i private final Path historyDirectory; private final HashSet joined = new HashSet<>(); private boolean negativeUnavailable; + private boolean observedOfflineHistory; private boolean closed; /** The caller must supply the actual server-owned playerdata directory. */ @@ -97,6 +98,7 @@ public void onJoin(PlayerJoinEvent event) { private void observeJoined(UUID identity) { synchronized (joined) { if (closed) return; + if (identity != null && !isOnlineIdentity(identity)) observedOfflineHistory = true; if (identity == null || (!joined.contains(identity) && joined.size() >= 65536)) negativeUnavailable = true; else joined.add(identity); @@ -112,6 +114,15 @@ private PlayerPresence liveEvidence(UUID identity) { @Override public PlayerPresence check(UUID identity) { + return check(identity, false); + } + + @Override + public PlayerPresence checkOnlineHistory(UUID identity) { + return check(identity, true); + } + + private PlayerPresence check(UUID identity, boolean completeOnlineHistory) { if (identity == null) return PlayerPresence.UNKNOWN; PlayerPresence live = liveEvidence(identity); if (live != null) return live; @@ -154,7 +165,7 @@ public PlayerPresence check(UUID identity) { } // Native saves use UUID-random.dat; their existence prevents absence // proof even if a crash left no canonical record. Never load or alter them. - boolean recovery = !valid && !present && !failed && hasRecovery(identity, before); + boolean recovery = !valid && !present && !failed && hasRecovery(identity, before, completeOnlineHistory); BasicFileAttributes after = attributes(historyDirectory); if (!unlinkedScope() || !after.isDirectory() || after.isOther() || !scope.equals(identity(after))) return PlayerPresence.UNKNOWN; @@ -163,6 +174,7 @@ public PlayerPresence check(UUID identity) { if (valid) return PlayerPresence.JOINED; synchronized (joined) { if (closed || negativeUnavailable || present || failed || recovery + || (completeOnlineHistory && (observedOfflineHistory || !isSupportedOnlineAlias(identity))) || !before.lastModifiedTime().equals(after.lastModifiedTime())) return PlayerPresence.UNKNOWN; return joined.contains(identity) ? PlayerPresence.JOINED : PlayerPresence.NEVER_JOINED; } @@ -171,7 +183,7 @@ public PlayerPresence check(UUID identity) { } } - private boolean hasRecovery(UUID identity, BasicFileAttributes scope) throws IOException { + private boolean hasRecovery(UUID identity, BasicFileAttributes scope, boolean completeOnlineHistory) throws IOException { // Even a cached absence index needs currently readable directory scope. try (var ignored = Files.newDirectoryStream(historyDirectory)) { } synchronized (recoveryScan) { @@ -179,28 +191,34 @@ private boolean hasRecovery(UUID identity, BasicFileAttributes scope) throws IOE if (scopeIdentity == null) return true; RecoveryIndex cached = recoveryIndex.get(); if (cached == RETIRED) return true; - // Timestamps alone cannot certify that cached entries describe the - // current keyless directory; replacement or coarse times may collide. + // Cached retained evidence can preserve uncertainty. Cached absence + // cannot certify current entries even with a key: times may collide. if (scopeIdentity.fileKey() != null && cached != null && cached.scope().equals(scopeIdentity) - && cached.modified().equals(scope.lastModifiedTime())) - return cached.identities().contains(identity); + && cached.modified().equals(scope.lastModifiedTime()) + && (cached.identities().contains(identity) || (completeOnlineHistory && cached.offlineHistory()))) + return true; HashSet identities = new HashSet<>(); + boolean offlineHistory = false; int examined = 0; try (var entries = Files.newDirectoryStream(historyDirectory)) { for (Path entry : entries) { if (++examined > MAX_DIRECTORY_ENTRIES) return true; String name = entry.getFileName().toString(); - if (name.length() <= 40) continue; - String suffix = name.substring(36); + if (name.length() < 40) continue; + String suffix = name.substring(36).toLowerCase(java.util.Locale.ROOT); boolean temporary = suffix.startsWith("-") && suffix.endsWith(".dat") && suffix.length() > 5; boolean corrupted = suffix.startsWith("_corrupted_") && (suffix.endsWith(".dat") || suffix.endsWith(".dat_old")); - if (!temporary && !corrupted) continue; UUID record; try { record = UUID.fromString(name.substring(0, 36)); } catch (IllegalArgumentException unrelated) { continue; } - if (!record.toString().equals(name.substring(0, 36))) continue; + if (!record.toString().equalsIgnoreCase(name.substring(0, 36))) continue; + // Native offline UUIDs can retain history under an earlier name. + // Their ownership cannot be inferred from the current name cache. + if (!isOnlineIdentity(record) && (suffix.startsWith(".dat") || temporary || corrupted)) + offlineHistory = true; + if (!temporary && !corrupted) continue; identities.add(record); if (identities.size() > MAX_RECOVERY_IDENTITIES) return true; } @@ -208,10 +226,10 @@ private boolean hasRecovery(UUID identity, BasicFileAttributes scope) throws IOE BasicFileAttributes after = attributes(historyDirectory); if (!unlinkedScope() || !after.isDirectory() || after.isOther() || !scopeIdentity.equals(identity(after)) || !scope.lastModifiedTime().equals(after.lastModifiedTime())) return true; - RecoveryIndex scanned = new RecoveryIndex(scopeIdentity, scope.lastModifiedTime(), Set.copyOf(identities)); + RecoveryIndex scanned = new RecoveryIndex(scopeIdentity, scope.lastModifiedTime(), Set.copyOf(identities), offlineHistory); // close never waits for the scan and a retired scan cannot repopulate it. if (!recoveryIndex.compareAndSet(cached, scanned)) return true; - return identities.contains(identity); + return identities.contains(identity) || (completeOnlineHistory && offlineHistory); } } @@ -224,6 +242,14 @@ private static ScopeIdentity identity(BasicFileAttributes attributes) { return new ScopeIdentity(key, created); } + private static boolean isOnlineIdentity(UUID identity) { + return identity.version() == 4 && identity.variant() == 2; + } + + private static boolean isSupportedOnlineAlias(UUID identity) { + return identity.variant() == 2 && (identity.version() == 3 || identity.version() == 4); + } + private boolean unlinkedScope() { for (Path part = historyDirectory; part != null; part = part.getParent()) if (Files.isSymbolicLink(part)) return false; diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/PlayerPresenceLookup.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/PlayerPresenceLookup.java index 49e64e244..5aced2d9e 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/PlayerPresenceLookup.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/PlayerPresenceLookup.java @@ -10,4 +10,13 @@ @FunctionalInterface public interface PlayerPresenceLookup { PlayerPresence check(UUID identity) throws Exception; + + /** + * Additionally certify absence of unmapped offline history for an online identity. + * Older providers retain positive evidence but cannot certify the wider scope. + */ + default PlayerPresence checkOnlineHistory(UUID identity) throws Exception { + PlayerPresence evidence = check(identity); + return evidence == PlayerPresence.NEVER_JOINED ? PlayerPresence.UNKNOWN : evidence; + } } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/PlayerPresenceService.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/PlayerPresenceService.java index 1bb42f0a7..d4e7606ea 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/PlayerPresenceService.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/PlayerPresenceService.java @@ -44,6 +44,12 @@ public CompletionStage checkAsync(UUID identity) { /** Check up to eight verified current/historical UUIDs for the same identity. */ public CompletionStage checkIdentitiesAsync(Collection identities) { + return checkIdentitiesAsync(identities, false); + } + + /** Require retained offline-history scope proof when current names cannot enumerate old migrations. */ + public CompletionStage checkIdentitiesAsync(Collection identities, + boolean requireCompleteOnlineHistory) { LinkedHashSet distinct = new LinkedHashSet<>(); if (identities == null) return CompletableFuture.completedStage(PlayerPresence.UNKNOWN); try { @@ -85,26 +91,30 @@ public CompletionStage checkIdentitiesAsync(Collection ide finish(aggregate, unknown ? PlayerPresence.UNKNOWN : PlayerPresence.NEVER_JOINED); }); int index = 0; - for (UUID identity : distinct) submit(identity, results.get(index++)); + for (UUID identity : distinct) submit(identity, results.get(index++), requireCompleteOnlineHistory); return aggregate.minimalCompletionStage(); } private void submit(UUID identity, CompletableFuture result) { + submit(identity, result, false); + } + + private void submit(UUID identity, CompletableFuture result, boolean completeOnlineHistory) { try { - worker.execute(() -> completeLookup(identity, result)); + worker.execute(() -> completeLookup(identity, result, completeOnlineHistory)); } catch (RuntimeException rejected) { finish(result, PlayerPresence.UNKNOWN); } } - private void completeLookup(UUID identity, CompletableFuture result) { + private void completeLookup(UUID identity, CompletableFuture result, boolean completeOnlineHistory) { synchronized (admission) { // Queued retired work never touches the provider or replacement state. if (closed || !pending.containsKey(result)) return; } PlayerPresence evidence; try { - evidence = lookup.check(identity); + evidence = completeOnlineHistory ? lookup.checkOnlineHistory(identity) : lookup.check(identity); if (evidence == null) evidence = PlayerPresence.UNKNOWN; } catch (Exception failed) { if (failed instanceof InterruptedException) Thread.currentThread().interrupt(); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/user/presence/CompleteOnlinePresenceTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/user/presence/CompleteOnlinePresenceTest.java new file mode 100644 index 000000000..46a27eda2 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/user/presence/CompleteOnlinePresenceTest.java @@ -0,0 +1,67 @@ +package com.bencodez.advancedcore.api.user.presence; + +import static org.junit.jupiter.api.Assertions.*; +import java.util.ArrayDeque; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class CompleteOnlinePresenceTest { + private static final UUID USER = UUID.fromString("d4519fc4-a782-4d11-8235-617209238441"); + + @Test void legacyProviderKeepsPositiveAndLegacyNegativeButCannotCertifyOnlineScope() { + try (var legacy = new PlayerPresenceService(Runnable::run, id -> PlayerPresence.NEVER_JOINED)) { + assertEquals(PlayerPresence.NEVER_JOINED, legacy.checkAsync(USER).toCompletableFuture().join()); + assertEquals(PlayerPresence.UNKNOWN, legacy.checkIdentitiesAsync(List.of(USER), true).toCompletableFuture().join()); + } + try (var positive = new PlayerPresenceService(Runnable::run, id -> PlayerPresence.JOINED)) { + assertEquals(PlayerPresence.JOINED, positive.checkIdentitiesAsync(List.of(USER), true).toCompletableFuture().join()); + } + } + + @Test void completeScopeProviderRunsOnlyAfterWorkerAdmission() { + var tasks = new ArrayDeque(); var calls = new AtomicInteger(); + PlayerPresenceLookup provider = new PlayerPresenceLookup() { + public PlayerPresence check(UUID id) { fail("This operation requires the wider scope"); return null; } + public PlayerPresence checkOnlineHistory(UUID id) { calls.incrementAndGet(); return PlayerPresence.NEVER_JOINED; } + }; + try (var service = new PlayerPresenceService(tasks::add, provider)) { + var result = service.checkIdentitiesAsync(List.of(USER), true).toCompletableFuture(); + assertFalse(result.isDone()); assertEquals(0, calls.get()); + tasks.remove().run(); assertEquals(PlayerPresence.NEVER_JOINED, result.join()); assertEquals(1, calls.get()); + } + } + + @Test void verifiedHistoricalPositiveWinsOverUnmappedOnlineUncertainty() { + UUID former = UUID.nameUUIDFromBytes("OfflinePlayer:FormerSynthetic".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + PlayerPresenceLookup provider = new PlayerPresenceLookup() { + public PlayerPresence check(UUID id) { return id.equals(former) ? PlayerPresence.JOINED : PlayerPresence.NEVER_JOINED; } + public PlayerPresence checkOnlineHistory(UUID id) { return id.equals(former) ? PlayerPresence.JOINED : PlayerPresence.UNKNOWN; } + }; + try (var service = new PlayerPresenceService(Runnable::run, provider)) { + assertEquals(PlayerPresence.JOINED, service.checkIdentitiesAsync(List.of(USER, former), true).toCompletableFuture().join()); + } + } + + @Test void completeFamilyReservesAllChildrenAndRetiredQueueNeverTouchesProvider() { + var tasks = new ArrayDeque(); var calls = new AtomicInteger(); + PlayerPresenceLookup provider = new PlayerPresenceLookup() { + public PlayerPresence check(UUID id) { calls.incrementAndGet(); return PlayerPresence.NEVER_JOINED; } + public PlayerPresence checkOnlineHistory(UUID id) { calls.incrementAndGet(); return PlayerPresence.NEVER_JOINED; } + }; + try (var service = new PlayerPresenceService(tasks::add, provider)) { + for (int count = 0; count < 126; count++) service.checkAsync(USER); + var rejected = service.checkIdentitiesAsync(List.of(USER, UUID.randomUUID()), true).toCompletableFuture(); + assertEquals(PlayerPresence.UNKNOWN, rejected.join()); assertEquals(126, tasks.size()); + service.close(); while (!tasks.isEmpty()) tasks.remove().run(); assertEquals(0, calls.get()); + } + } + + @Test void rejectedCompleteScopeWorkerSettlesAggregateAsUnknown() { + try (var service = new PlayerPresenceService(task -> { throw new java.util.concurrent.RejectedExecutionException("synthetic"); }, + id -> PlayerPresence.NEVER_JOINED)) { + assertEquals(PlayerPresence.UNKNOWN, service.checkIdentitiesAsync(List.of(USER), true).toCompletableFuture().join()); + } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/user/presence/PlayerHistoryStabilityTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/user/presence/PlayerHistoryStabilityTest.java index a06582363..132cb9df7 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/user/presence/PlayerHistoryStabilityTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/user/presence/PlayerHistoryStabilityTest.java @@ -19,13 +19,17 @@ class PlayerHistoryStabilityTest { @TempDir Path directory; private byte[] history() throws Exception { + return history(USER); + } + + private byte[] history(UUID recordIdentity) throws Exception { var nbt = new ByteArrayOutputStream(); try (var out = new DataOutputStream(nbt)) { out.writeByte(10); out.writeUTF(""); out.writeByte(3); out.writeUTF("DataVersion"); out.writeInt(1); out.writeByte(11); out.writeUTF("UUID"); out.writeInt(4); - out.writeInt((int) (USER.getMostSignificantBits() >>> 32)); out.writeInt((int) USER.getMostSignificantBits()); - out.writeInt((int) (USER.getLeastSignificantBits() >>> 32)); out.writeInt((int) USER.getLeastSignificantBits()); + out.writeInt((int) (recordIdentity.getMostSignificantBits() >>> 32)); out.writeInt((int) recordIdentity.getMostSignificantBits()); + out.writeInt((int) (recordIdentity.getLeastSignificantBits() >>> 32)); out.writeInt((int) recordIdentity.getLeastSignificantBits()); out.writeByte(10); out.writeUTF("bukkit"); out.writeByte(8); out.writeUTF("lastKnownName"); out.writeUTF("Synthetic\r\n歴史"); out.writeByte(0); out.writeByte(0); @@ -55,6 +59,137 @@ private static BasicFileAttributes metadata(BasicFileAttributes source, Object k }; } + @Test void legacyUuidAbsenceAlsoRescansKeyedScopesWithCollidingTimes() throws Exception { + BasicFileAttributes attrs = actual(directory); + BasicFileAttributes fixed = metadata(attrs, new Object(), attrs.creationTime()); + try (var lookup = new BukkitPlayerPresenceLookup(directory, + path -> path.equals(directory) ? fixed : actual(path))) { + assertEquals(PlayerPresence.NEVER_JOINED, lookup.check(USER)); + Files.createTempFile(directory, USER + "-", ".dat"); + assertEquals(PlayerPresence.UNKNOWN, lookup.check(USER)); + } + } + + @Test void cachedRecoveryForOneUuidCannotCertifyAnotherUuidAbsentWithCollidingTimes() throws Exception { + BasicFileAttributes attrs = actual(directory); + BasicFileAttributes fixed = metadata(attrs, new Object(), attrs.creationTime()); + UUID other = UUID.fromString("b3f2bd99-5325-4a51-a642-12674e147381"); + Files.createTempFile(directory, USER + "-", ".dat"); + try (var lookup = new BukkitPlayerPresenceLookup(directory, + path -> path.equals(directory) ? fixed : actual(path))) { + assertEquals(PlayerPresence.UNKNOWN, lookup.check(USER)); + Files.createTempFile(directory, other + "-", ".dat"); + assertEquals(PlayerPresence.UNKNOWN, lookup.check(other)); + } + } + + @Test void keyedScopeWithCollidingDirectoryTimesMustDiscoverNewOfflineHistory() throws Exception { + BasicFileAttributes attrs = actual(directory); + BasicFileAttributes fixed = metadata(attrs, new Object(), attrs.creationTime()); + try (var lookup = new BukkitPlayerPresenceLookup(directory, + path -> path.equals(directory) ? fixed : actual(path))) { + assertEquals(PlayerPresence.NEVER_JOINED, lookup.checkOnlineHistory(USER)); + UUID former = UUID.nameUUIDFromBytes("OfflinePlayer:FormerSynthetic".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + Files.write(directory.resolve(former + ".dat"), history(former)); + assertEquals(PlayerPresence.UNKNOWN, lookup.checkOnlineHistory(USER)); + } + } + + @Test void versionFourWithNonOnlineVariantRecordCannotCertifyOnlineAbsence() throws Exception { + UUID legacy = UUID.fromString("00000000-0000-4000-0000-000000000001"); + assertEquals(4, legacy.version()); assertNotEquals(2, legacy.variant()); + Files.write(directory.resolve(legacy + ".dat"), history(legacy)); + try (var lookup = new BukkitPlayerPresenceLookup(directory)) { + assertEquals(PlayerPresence.UNKNOWN, lookup.checkOnlineHistory(USER)); + } + } + + @Test void versionFourNonOnlineVariantJoinCannotCertifyOnlineAbsenceBeforeSave() { + var player = org.mockito.Mockito.mock(org.bukkit.entity.Player.class); + org.mockito.Mockito.when(player.getUniqueId()).thenReturn(UUID.fromString("00000000-0000-4000-0000-000000000001")); + var event = org.mockito.Mockito.mock(org.bukkit.event.player.PlayerJoinEvent.class); + org.mockito.Mockito.when(event.getPlayer()).thenReturn(player); + try (var lookup = new BukkitPlayerPresenceLookup(directory)) { + lookup.onJoin(event); + assertEquals(PlayerPresence.UNKNOWN, lookup.checkOnlineHistory(USER)); + } + } + + @Test void unsupportedOnlineAliasRetainsPositiveButCannotCertifyAbsentHistory() throws Exception { + UUID legacy = UUID.fromString("00000000-0000-4000-0000-000000000001"); + try (var lookup = new BukkitPlayerPresenceLookup(directory)) { + assertEquals(PlayerPresence.UNKNOWN, lookup.checkOnlineHistory(legacy)); + Files.write(directory.resolve(legacy + ".dat"), history(legacy)); + assertEquals(PlayerPresence.JOINED, lookup.checkOnlineHistory(legacy)); + } + } + + @Test void completeOnlineOnlyScopeCanProveAbsenceWithoutChangingLegacyApi() throws Exception { + UUID anotherOnline = UUID.fromString("b3f2bd99-5325-4a51-a642-12674e147381"); + Files.write(directory.resolve(anotherOnline + ".dat"), history(anotherOnline)); + try (var lookup = new BukkitPlayerPresenceLookup(directory)) { + assertEquals(PlayerPresence.NEVER_JOINED, lookup.checkOnlineHistory(USER)); + assertEquals(PlayerPresence.NEVER_JOINED, lookup.check(USER)); + } + } + + @Test void formerOfflineNameBlocksOnlineNegativeWithoutGuessingOwnership() throws Exception { + UUID former = UUID.nameUUIDFromBytes("OfflinePlayer:FormerSynthetic".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + byte[] retained = history(former); Path record = Files.write(directory.resolve(former + ".dat"), retained); + try (var lookup = new BukkitPlayerPresenceLookup(directory)) { + assertEquals(PlayerPresence.NEVER_JOINED, lookup.check(USER)); + assertEquals(PlayerPresence.UNKNOWN, lookup.checkOnlineHistory(USER)); + assertArrayEquals(retained, Files.readAllBytes(record)); + Files.write(directory.resolve(USER + ".dat"), history()); + assertEquals(PlayerPresence.JOINED, lookup.checkOnlineHistory(USER)); + } + } + + @Test void unmappedCorruptBackupAndInterruptedOfflineSavesBlockOnlineNegatives() throws Exception { + UUID former = UUID.nameUUIDFromBytes("OfflinePlayer:FormerSynthetic".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + for (String suffix : new String[] { ".dat_old", ".dat.offline-read", "-temporary.dat", "_corrupted_1.dat_old" }) { + Path retained = Files.write(directory.resolve(former + suffix), new byte[] {0}); + try (var lookup = new BukkitPlayerPresenceLookup(directory)) { + assertEquals(PlayerPresence.UNKNOWN, lookup.checkOnlineHistory(USER), suffix); + assertArrayEquals(new byte[] {0}, Files.readAllBytes(retained)); + } finally { Files.delete(retained); } + } + } + + @Test void nonOnlineUuidAndCaseInsensitiveRecoveryScopeCannotBecomeOnlineAbsence() throws Exception { + UUID legacy = new UUID(0, 1); + Path retained = Files.write(directory.resolve(legacy.toString().toUpperCase(java.util.Locale.ROOT) + "-SAVE.DAT"), new byte[] {0}); + try (var lookup = new BukkitPlayerPresenceLookup(directory)) { + assertEquals(PlayerPresence.UNKNOWN, lookup.checkOnlineHistory(USER)); + assertEquals(PlayerPresence.UNKNOWN, lookup.check(legacy)); + assertArrayEquals(new byte[] {0}, Files.readAllBytes(retained)); + } + } + + @Test void keylessScopeWithUnchangedTimesRescansNewOfflineRecords() throws Exception { + BasicFileAttributes fixed = withoutKey(actual(directory)); + try (var lookup = new BukkitPlayerPresenceLookup(directory, + path -> path.equals(directory) ? fixed : withoutKey(actual(path)))) { + assertEquals(PlayerPresence.NEVER_JOINED, lookup.checkOnlineHistory(USER)); + UUID former = UUID.nameUUIDFromBytes("OfflinePlayer:FormerSynthetic".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + Files.write(directory.resolve(former + ".dat"), history(former)); + assertEquals(PlayerPresence.UNKNOWN, lookup.checkOnlineHistory(USER)); + lookup.close(); assertEquals(PlayerPresence.UNKNOWN, lookup.checkOnlineHistory(USER)); + } + } + + @Test void observedNonOnlineJoinPreventsUnmappedAbsenceBeforeFirstSave() { + var player = org.mockito.Mockito.mock(org.bukkit.entity.Player.class); + org.mockito.Mockito.when(player.getUniqueId()).thenReturn(new UUID(0, 1)); + var event = org.mockito.Mockito.mock(org.bukkit.event.player.PlayerJoinEvent.class); + org.mockito.Mockito.when(event.getPlayer()).thenReturn(player); + try (var lookup = new BukkitPlayerPresenceLookup(directory)) { + lookup.onJoin(event); + assertEquals(PlayerPresence.UNKNOWN, lookup.checkOnlineHistory(USER)); + assertEquals(PlayerPresence.JOINED, lookup.checkOnlineHistory(player.getUniqueId())); + } + } + @Test void completeKeylessRecordCanSupplyPositiveHistoryWithoutMutation() throws Exception { byte[] record = history(); Path file = Files.write(directory.resolve(USER + ".dat"), record); try (var lookup = new BukkitPlayerPresenceLookup(directory, path -> withoutKey(actual(path)))) { diff --git a/docs/checked-player-presence.md b/docs/checked-player-presence.md index 86eb8b9d8..45344af52 100644 --- a/docs/checked-player-presence.md +++ b/docs/checked-player-presence.md @@ -32,7 +32,8 @@ Input is bounded to 1 MiB compressed, 8 MiB expanded, depth 64 and 100,000 struc unknown. Negative evidence requires a readable unchanged directory with no record or recovery file for that UUID. Native interrupted-save files (`UUID-random.dat`) and corruption backups prevent negative answers even when no canonical record survives; their contents are never read as positive proof or changed. A worker-owned directory -index is shared across negative checks and refreshed when the directory identity or modification time changes. +index preserves retained recovery evidence across checks. Every absent result requires a fresh bounded +enumeration: directory identity and timestamps alone cannot certify unchanged entries. Scanning is bounded to 1,000,000 entries and 65,536 recovery identities; incomplete, failed or unstable scans remain unknown. Closing the lookup clears the index without waiting for an in-flight scan. File keys strengthen stability checks when the provider supplies them. Providers without file keys use @@ -43,6 +44,18 @@ is rechecked before using an absence index. A missing directory, symbolic link, scope remains unknown. No raw history or paths are returned or logged. +The additive `checkPlayerPresenceIdentitiesAsync(identities, true)` operation also checks whether the +actual history scope contains unmapped offline/non-online UUID records or observed joins. Current-name +cache mappings cannot enumerate all former offline names. Such retained evidence prevents a negative +online-family result unless a checked alias already supplies positive history. A stable readable scope +containing only online UUID records can still supply `NEVER_JOINED`; malformed offline records and +interrupted/corruption backups keep the result unknown. This uses the same bounded worker-owned index +and admission limits, with fresh bounded enumeration before each wider negative result because directory +timestamps cannot certify unchanged contents. Online UUIDs require version 4 and variant 2; unsupported +aliases retain positives but cannot certify wider absence. No profile lookup or history mutation is used. Existing UUID-only methods retain +their behavior. Providers that implement only the original lookup method preserve positives but +cannot certify the wider negative scope. + A generation-owned join listener and initial UUID-only online snapshot supply positive evidence for players whose first history file has not yet been saved. The requesting/worker threads never inspect player entity state. This evidence retains at most 65,536 joined UUIDs; failed snapshot capture or capacity exhaustion disables new From cc0c8e0be83b4aa7a3d3002bfdbb9d3b0d3aa34c Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 14 Oct 2026 12:28:26 -0600 Subject: [PATCH 5/7] Preserve unknown player presence when saving is unavailable --- .../advancedcore/AdvancedCorePlugin.java | 1 + .../presence/BukkitPlayerPresenceLookup.java | 16 +++++++ .../AdvancedCorePlayerPresenceTest.java | 35 +++++++++++++++ .../BukkitPlayerPresenceLookupTest.java | 43 +++++++++++++++++++ docs/checked-player-presence.md | 5 ++- 5 files changed, 99 insertions(+), 1 deletion(-) diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java index b51023a5b..4fda22913 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java @@ -1416,6 +1416,7 @@ public void onEnable() { var presenceDirectory = com.bencodez.advancedcore.api.user.presence.BukkitPlayerPresenceLookup .capturePrimaryHistoryDirectory(presenceServer); var presenceLookup = new com.bencodez.advancedcore.api.user.presence.BukkitPlayerPresenceLookup(presenceDirectory); + presenceLookup.capturePlayerSaving(presenceServer); playerPresenceLookup = presenceLookup; registerEvents(presenceLookup); presenceLookup.captureJoinedPlayers(presenceServer); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/BukkitPlayerPresenceLookup.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/BukkitPlayerPresenceLookup.java index 4c8d1812d..05f29ae81 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/BukkitPlayerPresenceLookup.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/BukkitPlayerPresenceLookup.java @@ -73,6 +73,22 @@ public static Path capturePrimaryHistoryDirectory(Server server) { } } + /** Capture in-memory platform saving policy on the owner before publishing the service. */ + public void capturePlayerSaving(Server server) { + boolean unavailable = true; + try { + var config = server == null || server.spigot() == null ? null : server.spigot().getConfig(); + if (config != null) { + Object disabled = config.get("players.disable-saving"); + // An absent key uses the platform's false default; malformed metadata is uncertain. + unavailable = disabled != null && !Boolean.FALSE.equals(disabled); + } + } catch (RuntimeException | LinkageError unsupported) { + // Unsupported/unreadable policy cannot establish that absent files imply absent history. + } + if (unavailable) synchronized (joined) { negativeUnavailable = true; } + } + /** Capture UUID-only online metadata on the platform owner after registering this listener. */ public void captureJoinedPlayers(Server server) { try { diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/presence/AdvancedCorePlayerPresenceTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/presence/AdvancedCorePlayerPresenceTest.java index 0093d35dc..ffff36342 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/presence/AdvancedCorePlayerPresenceTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/presence/AdvancedCorePlayerPresenceTest.java @@ -46,12 +46,20 @@ public static class PlayerListFixture { } private Server levelServer(Path level) throws Exception { + return levelServer(level, false); + } + + private Server levelServer(Path level, boolean savingDisabled) throws Exception { + var config = new org.bukkit.configuration.file.YamlConfiguration(); + config.set("players.disable-saving", savingDisabled); + var spigot = mock(Server.Spigot.class); when(spigot.getConfig()).thenReturn(config); return (Server) new ByteBuddy().subclass(Object.class).implement(Server.class) .name("org.bukkit.craftbukkit.CraftServer") .method(net.bytebuddy.matcher.ElementMatchers.any()).intercept(StubMethod.INSTANCE) .defineMethod("getLevelDirectory", Path.class, java.lang.reflect.Modifier.PUBLIC) .intercept(FixedValue.value(level)) .method(named("getOnlinePlayers")).intercept(FixedValue.value(java.util.List.of())) + .method(named("spigot")).intercept(FixedValue.value(spigot)) .make().load(getClass().getClassLoader(), ClassLoadingStrategy.Default.WRAPPER) .getLoaded().getDeclaredConstructor().newInstance(); } @@ -101,6 +109,33 @@ private Server levelServer(Path level) throws Exception { try (var files = Files.list(history)) { assertEquals(0, files.count()); } } + @Test void actualEnableWithPlayerSavingDisabledKeepsAbsentHistoryUnknownAndLiveJoinsPositive() throws Exception { + Path level = Files.createDirectory(temporary.resolve("saving-disabled")); + Path history = Files.createDirectory(level.resolve("playerdata")); + Server server = levelServer(level, true); + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, CALLS_REAL_METHODS); + doReturn(server).when(plugin).getServer(); + var worker = mock(java.util.concurrent.ScheduledExecutorService.class); var tasks = new ArrayDeque(); + doAnswer(call -> { tasks.add(call.getArgument(0)); return null; }).when(worker).execute(any(Runnable.class)); + var executors = mock(AdvancedCoreLifecycle.RuntimeExecutors.class); when(executors.getTimer()).thenReturn(worker); + var registered = new java.util.concurrent.atomic.AtomicReference(); + doAnswer(call -> { registered.set(call.getArgument(0)); return null; }) + .when(plugin).registerEvents(any(BukkitPlayerPresenceLookup.class)); + doThrow(new IllegalStateException("stop after presence initialization")).when(plugin).onPreLoad(); + try (var runtime = mockStatic(AdvancedCoreLifecycle.class)) { + runtime.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(plugin)).thenReturn(executors); + assertThrows(IllegalStateException.class, plugin::onEnable); + } + var result = plugin.checkPlayerPresenceAsync(USER); tasks.remove().run(); + assertEquals(PlayerPresence.UNKNOWN, value(result), "No saved record cannot prove absence when saving is disabled"); + var player = mock(org.bukkit.entity.Player.class); when(player.getUniqueId()).thenReturn(USER); + registered.get().onJoin(new org.bukkit.event.player.PlayerJoinEvent(player, null)); + result = plugin.checkPlayerPresenceAsync(USER); tasks.remove().run(); + assertEquals(PlayerPresence.JOINED, value(result)); + try (var files = Files.list(history)) { assertEquals(0, files.count()); } + try (var lifecycle = mockConstruction(AdvancedCoreLifecycle.class)) { plugin.onDisable(); } + } + private static void attach(AdvancedCorePlugin plugin, PlayerPresenceService service) throws Exception { Field field = AdvancedCorePlugin.class.getDeclaredField("playerPresenceService"); field.setAccessible(true); field.set(plugin, service); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/presence/BukkitPlayerPresenceLookupTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/presence/BukkitPlayerPresenceLookupTest.java index 8599f3de3..52e908eb4 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/presence/BukkitPlayerPresenceLookupTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/presence/BukkitPlayerPresenceLookupTest.java @@ -308,4 +308,47 @@ static byte[] completeHistory() throws Exception { assertEquals(PlayerPresence.UNKNOWN, lookup.check(new UUID(0, 65537))); assertEquals(PlayerPresence.UNKNOWN, lookup.check(USER)); } + @Test void savingPolicyDefaultsTypesAndFailuresOnlyRestrictNegativeEvidence() throws Exception { + for (Object disabled : new Object[] {null, false, true, "false", 0}) { + var config = new org.bukkit.configuration.file.YamlConfiguration(); config.set("players.disable-saving", disabled); + var server = mock(Server.class); var spigot = mock(Server.Spigot.class); + when(server.spigot()).thenReturn(spigot); when(spigot.getConfig()).thenReturn(config); + try (var lookup = new BukkitPlayerPresenceLookup(directory)) { + lookup.capturePlayerSaving(server); + PlayerPresence expected = disabled == null || Boolean.FALSE.equals(disabled) + ? PlayerPresence.NEVER_JOINED : PlayerPresence.UNKNOWN; + assertEquals(expected, lookup.check(USER)); assertEquals(expected, lookup.checkOnlineHistory(USER)); + } + } + for (Server server : new Server[] {null, mock(Server.class), failingSavingServer()}) { + try (var lookup = new BukkitPlayerPresenceLookup(directory)) { + lookup.capturePlayerSaving(server); + assertEquals(PlayerPresence.UNKNOWN, lookup.check(USER)); + assertEquals(PlayerPresence.UNKNOWN, lookup.checkOnlineHistory(USER)); + } + } + } + + private Server failingSavingServer() { + var server = mock(Server.class); var spigot = mock(Server.Spigot.class); + when(server.spigot()).thenReturn(spigot); + when(spigot.getConfig()).thenThrow(new UnsupportedOperationException("synthetic unavailable")); + return server; + } + + @Test void disabledOrUnknownSavingPreservesRetainedPositivesAndCannotReenableNegatives() throws Exception { + byte[] record = gzip(completeHistory()); Path file = Files.write(directory.resolve(USER + ".dat"), record); + var server = mock(Server.class); var spigot = mock(Server.Spigot.class); + var config = new org.bukkit.configuration.file.YamlConfiguration(); config.set("players.disable-saving", true); + when(server.spigot()).thenReturn(spigot); when(spigot.getConfig()).thenReturn(config); + try (var lookup = new BukkitPlayerPresenceLookup(directory)) { + lookup.capturePlayerSaving(server); + assertEquals(PlayerPresence.JOINED, lookup.check(USER)); + assertEquals(PlayerPresence.JOINED, lookup.checkOnlineHistory(USER)); + config.set("players.disable-saving", false); lookup.capturePlayerSaving(server); + assertEquals(PlayerPresence.UNKNOWN, lookup.check(new UUID(0, 2))); + assertArrayEquals(record, Files.readAllBytes(file)); + } + } + } diff --git a/docs/checked-player-presence.md b/docs/checked-player-presence.md index 45344af52..00cd76155 100644 --- a/docs/checked-player-presence.md +++ b/docs/checked-player-presence.md @@ -22,7 +22,10 @@ thread: use an asynchronous continuation or the correct platform scheduler for c blocking storage or player effects directly. A result is an inspection snapshot, not permission to mutate a player or proof that a later menu/configuration generation is still current. -The runtime captures server-wide player-storage directory metadata during enable on the platform owner. +The runtime captures server-wide player-storage directory metadata and in-memory player-saving policy during +enable on the platform owner. Disabled, malformed or unavailable saving policy disables negative answers +for that generation while retained records and observed joins can still establish positives. The absent +`players.disable-saving` key follows the platform false default. Workers never load platform configuration. Recognized CraftBukkit implementations provide either the level-directory API or the player's storage-directory metadata; a world/dimension folder is never assumed to own player data. Unsupported layouts remain `UNKNOWN`. The worker reads UUID-named history directly without calling the server's offline-player loader, profile lookup, From 78ef2b7387677160bfa7cb5642ab31d9dfa520ca Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 15 Oct 2026 10:46:47 -0600 Subject: [PATCH 6/7] Coalesce checked presence scans on the shared worker --- .../presence/BatchPlayerPresenceLookup.java | 10 + .../presence/BukkitPlayerPresenceLookup.java | 140 ++++++++++---- .../user/presence/PlayerPresenceService.java | 59 ++++++ .../presence/PlayerPresenceBatchScanTest.java | 171 ++++++++++++++++++ .../AdvancedCorePlayerPresenceTest.java | 8 +- docs/checked-player-presence.md | 7 + 6 files changed, 356 insertions(+), 39 deletions(-) create mode 100644 AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/BatchPlayerPresenceLookup.java create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/api/user/presence/PlayerPresenceBatchScanTest.java diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/BatchPlayerPresenceLookup.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/BatchPlayerPresenceLookup.java new file mode 100644 index 000000000..bd4b72c56 --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/BatchPlayerPresenceLookup.java @@ -0,0 +1,10 @@ +package com.bencodez.advancedcore.api.user.presence; + +import java.util.List; +import java.util.UUID; + +/** Internal cohort optimization; older/custom public lookup implementations remain compatible. */ +interface BatchPlayerPresenceLookup extends PlayerPresenceLookup { + record Query(UUID identity, boolean completeOnlineHistory) { } + List checkBatch(List queries) throws Exception; +} diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/BukkitPlayerPresenceLookup.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/BukkitPlayerPresenceLookup.java index 05f29ae81..56b33de82 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/BukkitPlayerPresenceLookup.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/BukkitPlayerPresenceLookup.java @@ -22,17 +22,24 @@ import org.bukkit.event.player.PlayerJoinEvent; /** Read-only retained server history, with owner-observed joins in this generation. */ -public final class BukkitPlayerPresenceLookup implements PlayerPresenceLookup, Listener, AutoCloseable { +public final class BukkitPlayerPresenceLookup implements BatchPlayerPresenceLookup, Listener, AutoCloseable { private static final int MAX_DIRECTORY_ENTRIES = 1_000_000; private static final int MAX_RECOVERY_IDENTITIES = 65536; + private static final Set CANONICAL_SUFFIXES = Set.of(".dat", ".dat_old", ".dat_new", ".dat.tmp", + ".dat.offline-read", ".dat_old.offline-read"); private record ScopeIdentity(Object fileKey, FileTime created) { } private record RecoveryIndex(ScopeIdentity scope, FileTime modified, Set identities, boolean offlineHistory) { } + private record RecoverySnapshot(RecoveryIndex index, Set retainedCandidates) { } private static final RecoveryIndex RETIRED = new RecoveryIndex(null, null, Set.of(), true); private final AtomicReference recoveryIndex = new AtomicReference<>(); private final Object recoveryScan = new Object(); @FunctionalInterface interface AttributeReader { BasicFileAttributes read(Path path) throws IOException; } + @FunctionalInterface interface DirectoryReader { + java.nio.file.DirectoryStream open(Path path) throws IOException; + } + private final DirectoryReader directoryReader; private final AttributeReader attributeReader; private final Path historyDirectory; private final HashSet joined = new HashSet<>(); @@ -46,7 +53,12 @@ public BukkitPlayerPresenceLookup(Path historyDirectory) { } BukkitPlayerPresenceLookup(Path historyDirectory, AttributeReader attributeReader) { + this(historyDirectory, attributeReader, Files::newDirectoryStream); + } + + BukkitPlayerPresenceLookup(Path historyDirectory, AttributeReader attributeReader, DirectoryReader directoryReader) { this.attributeReader = java.util.Objects.requireNonNull(attributeReader); + this.directoryReader = java.util.Objects.requireNonNull(directoryReader); Path candidate = historyDirectory == null ? null : historyDirectory.toAbsolutePath(); if (candidate != null) for (Path part : candidate) { if (part.toString().equals("..")) { candidate = null; break; } @@ -139,6 +151,10 @@ public PlayerPresence checkOnlineHistory(UUID identity) { } private PlayerPresence check(UUID identity, boolean completeOnlineHistory) { + return check(identity, completeOnlineHistory, true); + } + + private PlayerPresence check(UUID identity, boolean completeOnlineHistory, boolean scanRecovery) { if (identity == null) return PlayerPresence.UNKNOWN; PlayerPresence live = liveEvidence(identity); if (live != null) return live; @@ -181,7 +197,7 @@ private PlayerPresence check(UUID identity, boolean completeOnlineHistory) { } // Native saves use UUID-random.dat; their existence prevents absence // proof even if a crash left no canonical record. Never load or alter them. - boolean recovery = !valid && !present && !failed && hasRecovery(identity, before, completeOnlineHistory); + boolean recovery = scanRecovery && !valid && !present && !failed && hasRecovery(identity, before, completeOnlineHistory); BasicFileAttributes after = attributes(historyDirectory); if (!unlinkedScope() || !after.isDirectory() || after.isOther() || !scope.equals(identity(after))) return PlayerPresence.UNKNOWN; @@ -201,7 +217,7 @@ private PlayerPresence check(UUID identity, boolean completeOnlineHistory) { private boolean hasRecovery(UUID identity, BasicFileAttributes scope, boolean completeOnlineHistory) throws IOException { // Even a cached absence index needs currently readable directory scope. - try (var ignored = Files.newDirectoryStream(historyDirectory)) { } + try (var ignored = directoryReader.open(historyDirectory)) { } synchronized (recoveryScan) { ScopeIdentity scopeIdentity = identity(scope); if (scopeIdentity == null) return true; @@ -213,40 +229,94 @@ private boolean hasRecovery(UUID identity, BasicFileAttributes scope, boolean co && cached.modified().equals(scope.lastModifiedTime()) && (cached.identities().contains(identity) || (completeOnlineHistory && cached.offlineHistory()))) return true; - HashSet identities = new HashSet<>(); - boolean offlineHistory = false; - int examined = 0; - try (var entries = Files.newDirectoryStream(historyDirectory)) { - for (Path entry : entries) { - if (++examined > MAX_DIRECTORY_ENTRIES) return true; - String name = entry.getFileName().toString(); - if (name.length() < 40) continue; - String suffix = name.substring(36).toLowerCase(java.util.Locale.ROOT); - boolean temporary = suffix.startsWith("-") && suffix.endsWith(".dat") - && suffix.length() > 5; - boolean corrupted = suffix.startsWith("_corrupted_") - && (suffix.endsWith(".dat") || suffix.endsWith(".dat_old")); - UUID record; - try { record = UUID.fromString(name.substring(0, 36)); } - catch (IllegalArgumentException unrelated) { continue; } - if (!record.toString().equalsIgnoreCase(name.substring(0, 36))) continue; - // Native offline UUIDs can retain history under an earlier name. - // Their ownership cannot be inferred from the current name cache. - if (!isOnlineIdentity(record) && (suffix.startsWith(".dat") || temporary || corrupted)) - offlineHistory = true; - if (!temporary && !corrupted) continue; - identities.add(record); - if (identities.size() > MAX_RECOVERY_IDENTITIES) return true; - } + RecoverySnapshot scanned = scanRecovery(scope, Set.of()); + return scanned == null || scanned.index().identities().contains(identity) + || (completeOnlineHistory && scanned.index().offlineHistory()); + } + } + + /** Called under recoveryScan; no negative result is retained for a later cohort. */ + private RecoverySnapshot scanRecovery(BasicFileAttributes scope, Set candidates) throws IOException { + RecoveryIndex cached = recoveryIndex.get(); + if (cached == RETIRED) return null; + ScopeIdentity scopeIdentity = identity(scope); + if (scopeIdentity == null) return null; + HashSet identities = new HashSet<>(); + HashSet retainedCandidates = new HashSet<>(); + boolean offlineHistory = false; + int examined = 0; + try (var entries = directoryReader.open(historyDirectory)) { + for (Path entry : entries) { + if (++examined > MAX_DIRECTORY_ENTRIES) return null; + String name = entry.getFileName().toString(); + if (name.length() < 40) continue; + String suffix = name.substring(36).toLowerCase(java.util.Locale.ROOT); + boolean temporary = suffix.startsWith("-") && suffix.endsWith(".dat") + && suffix.length() > 5; + boolean corrupted = suffix.startsWith("_corrupted_") + && (suffix.endsWith(".dat") || suffix.endsWith(".dat_old")); + UUID record; + try { record = UUID.fromString(name.substring(0, 36)); } + catch (IllegalArgumentException unrelated) { continue; } + if (!record.toString().equalsIgnoreCase(name.substring(0, 36))) continue; + // Native offline UUIDs can retain history under an earlier name. + // Their ownership cannot be inferred from the current name cache. + if (!isOnlineIdentity(record) && (suffix.startsWith(".dat") || temporary || corrupted)) + offlineHistory = true; + boolean canonical = CANONICAL_SUFFIXES.contains(suffix); + if (candidates.contains(record) && (canonical || temporary || corrupted)) retainedCandidates.add(record); + if (!temporary && !corrupted) continue; + identities.add(record); + if (identities.size() > MAX_RECOVERY_IDENTITIES) return null; + } + } + BasicFileAttributes after = attributes(historyDirectory); + if (!unlinkedScope() || !after.isDirectory() || after.isOther() || !scopeIdentity.equals(identity(after)) + || !scope.lastModifiedTime().equals(after.lastModifiedTime())) return null; + RecoveryIndex scanned = new RecoveryIndex(scopeIdentity, scope.lastModifiedTime(), Set.copyOf(identities), offlineHistory); + // close never waits for the scan and a retired scan cannot repopulate it. + if (!recoveryIndex.compareAndSet(cached, scanned)) return null; + return new RecoverySnapshot(scanned, Set.copyOf(retainedCandidates)); + } + + @Override + public java.util.List checkBatch(java.util.List queries) { + synchronized (joined) { + if (closed) return java.util.Collections.nCopies(queries.size(), PlayerPresence.UNKNOWN); + } + var results = new java.util.ArrayList(queries.size()); + var candidates = new HashSet(); + BasicFileAttributes before = null; + try { + if (historyDirectory != null && unlinkedScope()) before = attributes(historyDirectory); + } catch (IOException | RuntimeException unavailable) { /* Positives may still be available. */ } + for (var query : queries) { + PlayerPresence evidence = check(query.identity(), query.completeOnlineHistory(), false); + results.add(evidence); + if (evidence == PlayerPresence.NEVER_JOINED) candidates.add(query.identity()); + } + if (candidates.isEmpty()) return results; + RecoverySnapshot snapshot = null; + try { + if (before != null && before.isDirectory() && !before.isOther()) { + synchronized (recoveryScan) { snapshot = scanRecovery(before, candidates); } + } + } catch (IOException | RuntimeException unavailable) { /* All provisional negatives stay unknown. */ } + for (int index = 0; index < queries.size(); index++) { + if (results.get(index) != PlayerPresence.NEVER_JOINED) continue; + var query = queries.get(index); + PlayerPresence live = liveEvidence(query.identity()); + if (live != null) { results.set(index, live); continue; } + synchronized (joined) { + if (!closed && joined.contains(query.identity())) results.set(index, PlayerPresence.JOINED); + else if (closed || negativeUnavailable || snapshot == null + || snapshot.retainedCandidates().contains(query.identity()) + || snapshot.index().identities().contains(query.identity()) + || (query.completeOnlineHistory() && (observedOfflineHistory || snapshot.index().offlineHistory()))) + results.set(index, PlayerPresence.UNKNOWN); } - BasicFileAttributes after = attributes(historyDirectory); - if (!unlinkedScope() || !after.isDirectory() || after.isOther() || !scopeIdentity.equals(identity(after)) - || !scope.lastModifiedTime().equals(after.lastModifiedTime())) return true; - RecoveryIndex scanned = new RecoveryIndex(scopeIdentity, scope.lastModifiedTime(), Set.copyOf(identities), offlineHistory); - // close never waits for the scan and a retired scan cannot repopulate it. - if (!recoveryIndex.compareAndSet(cached, scanned)) return true; - return identities.contains(identity) || (completeOnlineHistory && offlineHistory); } + return results; } private static ScopeIdentity identity(BasicFileAttributes attributes) { diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/PlayerPresenceService.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/PlayerPresenceService.java index d4e7606ea..38e74c730 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/PlayerPresenceService.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/PlayerPresenceService.java @@ -1,6 +1,8 @@ package com.bencodez.advancedcore.api.user.presence; import java.util.ArrayList; +import java.util.ArrayDeque; +import java.util.List; import java.util.Collection; import java.util.LinkedHashSet; import java.util.IdentityHashMap; @@ -22,6 +24,9 @@ public final class PlayerPresenceService implements AutoCloseable { private final PlayerPresenceLookup lookup; private final Object admission = new Object(); private final IdentityHashMap, Boolean> pending = new IdentityHashMap<>(); + private record BatchRequest(BatchPlayerPresenceLookup.Query query, CompletableFuture result) { } + private final ArrayDeque queuedBatch = new ArrayDeque<>(); + private boolean batchScheduled; private boolean closed; public PlayerPresenceService(Executor worker, PlayerPresenceLookup lookup) { @@ -100,6 +105,17 @@ private void submit(UUID identity, CompletableFuture result) { } private void submit(UUID identity, CompletableFuture result, boolean completeOnlineHistory) { + if (lookup instanceof BatchPlayerPresenceLookup batch) { + var request = new BatchRequest(new BatchPlayerPresenceLookup.Query(identity, completeOnlineHistory), result); + try { + // Registration must reach its own place on the shared worker. + // Joining a previously queued read must never overtake persistence. + worker.execute(() -> registerBatch(request, batch)); + } catch (RuntimeException rejected) { + finish(result, PlayerPresence.UNKNOWN); + } + return; + } try { worker.execute(() -> completeLookup(identity, result, completeOnlineHistory)); } catch (RuntimeException rejected) { @@ -107,6 +123,48 @@ private void submit(UUID identity, CompletableFuture result, boo } } + private void registerBatch(BatchRequest request, BatchPlayerPresenceLookup batch) { + synchronized (admission) { + if (closed || !pending.containsKey(request.result())) return; + queuedBatch.add(request); + if (batchScheduled) return; + batchScheduled = true; + } + try { + // Yield behind already queued storage and registrations before reading. + worker.execute(() -> completeBatch(batch)); + } catch (RuntimeException rejected) { + ArrayList retiring; + synchronized (admission) { + retiring = new ArrayList<>(queuedBatch); + queuedBatch.clear(); + batchScheduled = false; + } + for (var entry : retiring) finish(entry.result(), PlayerPresence.UNKNOWN); + } + } + + private void completeBatch(BatchPlayerPresenceLookup batch) { + ArrayList cohort; + synchronized (admission) { + if (closed) return; + cohort = new ArrayList<>(queuedBatch); + queuedBatch.clear(); + batchScheduled = false; + } + List evidence = null; + try { + evidence = batch.checkBatch(cohort.stream().map(BatchRequest::query).toList()); + if (evidence == null || evidence.size() != cohort.size()) evidence = null; + } catch (Exception failed) { + if (failed instanceof InterruptedException) Thread.currentThread().interrupt(); + } + for (int index = 0; index < cohort.size(); index++) { + PlayerPresence result = evidence == null ? null : evidence.get(index); + finish(cohort.get(index).result(), result == null ? PlayerPresence.UNKNOWN : result); + } + } + private void completeLookup(UUID identity, CompletableFuture result, boolean completeOnlineHistory) { synchronized (admission) { // Queued retired work never touches the provider or replacement state. @@ -142,6 +200,7 @@ public void close() { closed = true; retiring = new ArrayList<>(pending.keySet()); pending.clear(); + queuedBatch.clear(); } for (CompletableFuture result : retiring) result.complete(PlayerPresence.UNKNOWN); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/user/presence/PlayerPresenceBatchScanTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/user/presence/PlayerPresenceBatchScanTest.java new file mode 100644 index 000000000..100614da4 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/user/presence/PlayerPresenceBatchScanTest.java @@ -0,0 +1,171 @@ +package com.bencodez.advancedcore.api.user.presence; + +import static org.junit.jupiter.api.Assertions.*; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.UUID; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class PlayerPresenceBatchScanTest { + @TempDir Path directory; + private static BasicFileAttributes attributes(Path path) throws java.io.IOException { + return Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + } + @Test void admittedMissingIdentityBurstSharesOneFreshDirectoryEnumeration() throws Exception { + var scans = new AtomicInteger(); + var queued = new ArrayDeque(); + var results = new ArrayList>(); + try (var lookup = new BukkitPlayerPresenceLookup(directory, PlayerPresenceBatchScanTest::attributes, + path -> { scans.incrementAndGet(); return Files.newDirectoryStream(path); }); + var service = new PlayerPresenceService(queued::add, lookup)) { + for (int i = 0; i < 128; i++) + results.add(service.checkAsync(new UUID(0x1234400080000000L, i))); + assertEquals(0, scans.get(), "Caller never reads history"); + while (!queued.isEmpty()) queued.remove().run(); + for (var result : results) assertEquals(PlayerPresence.NEVER_JOINED, result.toCompletableFuture().join()); + assertEquals(1, scans.get(), "One pending cohort must not enumerate once per missing player"); + } + } + @Test void aLaterRequestCannotOvertakeEarlierSharedWorkerMutation() throws Exception { + UUID first = UUID.fromString("d4519fc4-a782-4d11-8235-617209238441"); + UUID second = UUID.fromString("b3f2bd99-5325-4a51-a642-12674e147381"); + var queued = new ArrayDeque(); + try (var lookup = new BukkitPlayerPresenceLookup(directory); + var service = new PlayerPresenceService(queued::add, lookup)) { + var earlier = service.checkAsync(first); + queued.add(() -> { + try { Files.createTempFile(directory, second + "-", ".dat"); } + catch (java.io.IOException failure) { throw new AssertionError(failure); } + }); + var later = service.checkAsync(second); + while (!queued.isEmpty()) queued.remove().run(); + assertNotNull(earlier.toCompletableFuture().join()); + assertEquals(PlayerPresence.UNKNOWN, later.toCompletableFuture().join(), + "A read submitted after queued persistence must not use an earlier cohort's absence"); + } + } + + private static final UUID FIRST = UUID.fromString("d4519fc4-a782-4d11-8235-617209238441"); + private static final UUID SECOND = UUID.fromString("b3f2bd99-5325-4a51-a642-12674e147381"); + private void drain(ArrayDeque worker) { + int turns = 0; + while (!worker.isEmpty()) { assertTrue(++turns < 1024); worker.remove().run(); } + } + private BasicFileAttributes fixedDirectory() throws Exception { + BasicFileAttributes snapshot = attributes(directory); + var fixed = org.mockito.Mockito.mock(BasicFileAttributes.class, + org.mockito.AdditionalAnswers.delegatesTo(snapshot)); + org.mockito.Mockito.when(fixed.fileKey()).thenReturn(null); + return fixed; + } + @Test void laterCohortRescansKeylessDirectoryEvenWhenTimesCollide() throws Exception { + BasicFileAttributes fixed = fixedDirectory(); + var worker = new ArrayDeque(); var opens = new AtomicInteger(); + try (var lookup = new BukkitPlayerPresenceLookup(directory, + path -> path.equals(directory) ? fixed : attributes(path), + path -> { opens.incrementAndGet(); return Files.newDirectoryStream(path); }); + var service = new PlayerPresenceService(worker::add, lookup)) { + var first = service.checkAsync(FIRST); drain(worker); + assertEquals(PlayerPresence.NEVER_JOINED, first.toCompletableFuture().join()); + Path recovery = Files.createTempFile(directory, FIRST + "-", ".dat"); + var later = service.checkAsync(FIRST); drain(worker); + assertEquals(PlayerPresence.UNKNOWN, later.toCompletableFuture().join()); + assertEquals(2, opens.get()); assertTrue(Files.exists(recovery)); + } + } + @Test void canonicalRecordAppearingAfterPointChecksCannotBecomeNegative() throws Exception { + BasicFileAttributes fixed = fixedDirectory(); var worker = new ArrayDeque(); + Path retained = directory.resolve(FIRST + ".dat"); + try (var lookup = new BukkitPlayerPresenceLookup(directory, + path -> path.equals(directory) ? fixed : attributes(path), path -> { + Files.write(retained, new byte[] {0}); return Files.newDirectoryStream(path); + }); var service = new PlayerPresenceService(worker::add, lookup)) { + var result = service.checkAsync(FIRST); drain(worker); + assertEquals(PlayerPresence.UNKNOWN, result.toCompletableFuture().join()); + assertArrayEquals(new byte[] {0}, Files.readAllBytes(retained)); + } + } + @Test void sharedScanPreservesIndividualAndCompleteOnlineScope() throws Exception { + UUID former = UUID.nameUUIDFromBytes("OfflinePlayer:FormerSyntheticBatch".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + Files.write(directory.resolve(former + ".dat"), new byte[] {0}); + var worker = new ArrayDeque(); var opens = new AtomicInteger(); + try (var lookup = new BukkitPlayerPresenceLookup(directory, PlayerPresenceBatchScanTest::attributes, + path -> { opens.incrementAndGet(); return Files.newDirectoryStream(path); }); + var service = new PlayerPresenceService(worker::add, lookup)) { + var individual = service.checkAsync(FIRST); + var complete = service.checkIdentitiesAsync(java.util.List.of(SECOND), true); + drain(worker); + assertEquals(PlayerPresence.NEVER_JOINED, individual.toCompletableFuture().join()); + assertEquals(PlayerPresence.UNKNOWN, complete.toCompletableFuture().join()); + assertEquals(1, opens.get()); + } + } + @Test void eightAliasFamilySharesOneScanAndStillReservesAggregate() throws Exception { + var worker = new ArrayDeque(); var opens = new AtomicInteger(); + var aliases = new ArrayList(); + for (int i = 0; i < 8; i++) aliases.add(new UUID(0x1234400080000000L, i)); + try (var lookup = new BukkitPlayerPresenceLookup(directory, PlayerPresenceBatchScanTest::attributes, + path -> { opens.incrementAndGet(); return Files.newDirectoryStream(path); }); + var service = new PlayerPresenceService(worker::add, lookup)) { + var result = service.checkIdentitiesAsync(aliases); + assertFalse(result.toCompletableFuture().isDone()); drain(worker); + assertEquals(PlayerPresence.NEVER_JOINED, result.toCompletableFuture().join()); + assertEquals(1, opens.get()); + } + } + @Test void failedSharedScanCannotEraseObservedPositiveEvidence() throws Exception { + var worker = new ArrayDeque(); + try (var lookup = new BukkitPlayerPresenceLookup(directory, PlayerPresenceBatchScanTest::attributes, + path -> { throw new java.nio.file.AccessDeniedException("synthetic-history"); }); + var service = new PlayerPresenceService(worker::add, lookup)) { + var player = org.mockito.Mockito.mock(org.bukkit.entity.Player.class); + org.mockito.Mockito.when(player.getUniqueId()).thenReturn(FIRST); + lookup.onJoin(new org.bukkit.event.player.PlayerJoinEvent(player, null)); + var positive = service.checkAsync(FIRST); var absent = service.checkAsync(SECOND); drain(worker); + assertEquals(PlayerPresence.JOINED, positive.toCompletableFuture().join()); + assertEquals(PlayerPresence.UNKNOWN, absent.toCompletableFuture().join()); + } + } + @Test void retiredProviderDoesNotReadDirectoryForQueuedCohort() throws Exception { + var worker = new ArrayDeque(); var reads = new AtomicInteger(); + try (var lookup = new BukkitPlayerPresenceLookup(directory, + path -> { reads.incrementAndGet(); return attributes(path); }); + var service = new PlayerPresenceService(worker::add, lookup)) { + var result = service.checkAsync(FIRST); lookup.close(); drain(worker); + assertEquals(PlayerPresence.UNKNOWN, result.toCompletableFuture().join()); + assertEquals(0, reads.get()); + } + } + @Test void closeDuringScanCannotPublishNegativeOrRepopulateRetiredLookup() throws Exception { + var worker = new ArrayDeque(); var holder = new BukkitPlayerPresenceLookup[1]; + holder[0] = new BukkitPlayerPresenceLookup(directory, PlayerPresenceBatchScanTest::attributes, + path -> { holder[0].close(); return Files.newDirectoryStream(path); }); + try (var lookup = holder[0]; var service = new PlayerPresenceService(worker::add, lookup)) { + var result = service.checkAsync(FIRST); drain(worker); + assertEquals(PlayerPresence.UNKNOWN, result.toCompletableFuture().join()); + assertEquals(PlayerPresence.UNKNOWN, lookup.check(FIRST)); + } + } + @Test void rejectedFlushSettlesRequestsAndNextCohortCanProceed() throws Exception { + var worker = new ArrayDeque(); var submissions = new AtomicInteger(); + java.util.concurrent.Executor executor = task -> { + if (submissions.incrementAndGet() == 2) throw new java.util.concurrent.RejectedExecutionException(); + worker.add(task); + }; + try (var lookup = new BukkitPlayerPresenceLookup(directory); + var service = new PlayerPresenceService(executor, lookup)) { + var rejected = service.checkAsync(FIRST); drain(worker); + assertEquals(PlayerPresence.UNKNOWN, rejected.toCompletableFuture().join()); + var later = service.checkAsync(SECOND); drain(worker); + assertEquals(PlayerPresence.NEVER_JOINED, later.toCompletableFuture().join()); + } + } + +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/presence/AdvancedCorePlayerPresenceTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/presence/AdvancedCorePlayerPresenceTest.java index ffff36342..4e6bebbf5 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/presence/AdvancedCorePlayerPresenceTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/presence/AdvancedCorePlayerPresenceTest.java @@ -101,11 +101,11 @@ private Server levelServer(Path level, boolean savingDisabled) throws Exception } assertNotNull(registered.get()); var result = plugin.checkPlayerPresenceAsync(USER); - assertFalse(result.toCompletableFuture().isDone()); tasks.remove().run(); + assertFalse(result.toCompletableFuture().isDone()); while (!tasks.isEmpty()) tasks.remove().run(); assertEquals(PlayerPresence.NEVER_JOINED, value(result)); try (var lifecycle = mockConstruction(AdvancedCoreLifecycle.class)) { plugin.onDisable(); } assertEquals(PlayerPresence.UNKNOWN, registered.get().check(USER)); - verify(worker).execute(any(Runnable.class)); verifyNoMoreInteractions(worker); + verify(worker, times(2)).execute(any(Runnable.class)); verifyNoMoreInteractions(worker); try (var files = Files.list(history)) { assertEquals(0, files.count()); } } @@ -126,11 +126,11 @@ private Server levelServer(Path level, boolean savingDisabled) throws Exception runtime.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(plugin)).thenReturn(executors); assertThrows(IllegalStateException.class, plugin::onEnable); } - var result = plugin.checkPlayerPresenceAsync(USER); tasks.remove().run(); + var result = plugin.checkPlayerPresenceAsync(USER); while (!tasks.isEmpty()) tasks.remove().run(); assertEquals(PlayerPresence.UNKNOWN, value(result), "No saved record cannot prove absence when saving is disabled"); var player = mock(org.bukkit.entity.Player.class); when(player.getUniqueId()).thenReturn(USER); registered.get().onJoin(new org.bukkit.event.player.PlayerJoinEvent(player, null)); - result = plugin.checkPlayerPresenceAsync(USER); tasks.remove().run(); + result = plugin.checkPlayerPresenceAsync(USER); while (!tasks.isEmpty()) tasks.remove().run(); assertEquals(PlayerPresence.JOINED, value(result)); try (var files = Files.list(history)) { assertEquals(0, files.count()); } try (var lifecycle = mockConstruction(AdvancedCoreLifecycle.class)) { plugin.onDisable(); } diff --git a/docs/checked-player-presence.md b/docs/checked-player-presence.md index 00cd76155..983203817 100644 --- a/docs/checked-player-presence.md +++ b/docs/checked-player-presence.md @@ -37,6 +37,13 @@ Native interrupted-save files (`UUID-random.dat`) and corruption backups prevent canonical record survives; their contents are never read as positive proof or changed. A worker-owned directory index preserves retained recovery evidence across checks. Every absent result requires a fresh bounded enumeration: directory identity and timestamps alone cannot certify unchanged entries. +For the built-in provider, requests register at their own position on the existing storage worker, +then one bounded cohort shares a fresh scan. Registration turns cannot move a later request ahead +of previously queued persistence. Each cohort includes at most the existing 128 admitted entries; +custom providers retain their original individual scheduling. Point-record checks run before the +common enumeration, and a canonical/recovery record discovered there prevents a false negative. +Subsequent cohorts always scan afresh, including on keyless filesystems with colliding timestamps; +no cached negative result certifies a later request. Scanning is bounded to 1,000,000 entries and 65,536 recovery identities; incomplete, failed or unstable scans remain unknown. Closing the lookup clears the index without waiting for an in-flight scan. File keys strengthen stability checks when the provider supplies them. Providers without file keys use From e4b38ac3ea1afa357f1664c8d081ffae2a7380ab Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 15 Oct 2026 11:05:04 -0600 Subject: [PATCH 7/7] Include direct presence queries in canonical recovery scans --- .../presence/BukkitPlayerPresenceLookup.java | 5 +-- .../presence/PlayerPresenceBatchScanTest.java | 33 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/BukkitPlayerPresenceLookup.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/BukkitPlayerPresenceLookup.java index 56b33de82..8b7b34b49 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/BukkitPlayerPresenceLookup.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/BukkitPlayerPresenceLookup.java @@ -229,8 +229,9 @@ private boolean hasRecovery(UUID identity, BasicFileAttributes scope, boolean co && cached.modified().equals(scope.lastModifiedTime()) && (cached.identities().contains(identity) || (completeOnlineHistory && cached.offlineHistory()))) return true; - RecoverySnapshot scanned = scanRecovery(scope, Set.of()); - return scanned == null || scanned.index().identities().contains(identity) + RecoverySnapshot scanned = scanRecovery(scope, Set.of(identity)); + return scanned == null || scanned.retainedCandidates().contains(identity) + || scanned.index().identities().contains(identity) || (completeOnlineHistory && scanned.index().offlineHistory()); } } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/user/presence/PlayerPresenceBatchScanTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/user/presence/PlayerPresenceBatchScanTest.java index 100614da4..861f7b6dc 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/user/presence/PlayerPresenceBatchScanTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/user/presence/PlayerPresenceBatchScanTest.java @@ -80,6 +80,39 @@ private BasicFileAttributes fixedDirectory() throws Exception { assertEquals(2, opens.get()); assertTrue(Files.exists(recovery)); } } + @Test void directQuerySeesCanonicalRecordAppearingDuringRecoveryEnumeration() throws Exception { + assertDirectRecoveryCandidates(false); + } + @Test void directOnlineHistorySeesCanonicalRecordAppearingDuringRecoveryEnumeration() throws Exception { + assertDirectRecoveryCandidates(true); + } + private void assertDirectRecoveryCandidates(boolean complete) throws Exception { + for (String suffix : java.util.List.of(".dat", ".dat_old", ".dat_new", ".dat.tmp", + ".dat.offline-read", ".dat_old.offline-read", ".DAT")) { + BasicFileAttributes fixed = fixedDirectory(); + Path retained = directory.resolve(FIRST + suffix); + try (var lookup = new BukkitPlayerPresenceLookup(directory, + path -> path.equals(directory) ? fixed : attributes(path), path -> { + Files.write(retained, new byte[] {0}); return Files.newDirectoryStream(path); + })) { + assertEquals(PlayerPresence.UNKNOWN, + complete ? lookup.checkOnlineHistory(FIRST) : lookup.check(FIRST), suffix); + assertArrayEquals(new byte[] {0}, Files.readAllBytes(retained)); + } finally { Files.deleteIfExists(retained); } + } + } + @Test void unrelatedCanonicalRecordDoesNotInvalidateDirectAbsence() throws Exception { + BasicFileAttributes fixed = fixedDirectory(); + try (var lookup = new BukkitPlayerPresenceLookup(directory, + path -> path.equals(directory) ? fixed : attributes(path), path -> { + Files.write(directory.resolve(SECOND + ".dat"), new byte[] {0}); + return Files.newDirectoryStream(path); + })) { + assertEquals(PlayerPresence.NEVER_JOINED, lookup.check(FIRST)); + assertEquals(PlayerPresence.NEVER_JOINED, lookup.checkOnlineHistory(FIRST)); + } + } + @Test void canonicalRecordAppearingAfterPointChecksCannotBecomeNegative() throws Exception { BasicFileAttributes fixed = fixedDirectory(); var worker = new ArrayDeque(); Path retained = directory.resolve(FIRST + ".dat");