fix(login): F1+F2+F4 — void-death guard, retry recovery, AuthMe pre-empt

Closes the YOU500 inventory-loss path documented in AUDIT-2026-05-07.md.
Bumps to 1.1.0 per CHANGELOG.

F1 — void-damage guard: new EntityDamageEvent listener at HIGHEST,
ignoreCancelled=true. While a UUID sits in pendingTransit, DamageCause.VOID
is cancelled, the player is healed to 20.0, fall/fire reset, sync-TP back
to limbo spawn. Console gets a WARN with intended TP target. This single
guard would have saved YOU500's inventory.

F2 — recovery on teleportAsync false / exception: replaces the previous
log-only branch in doTeleport. On failure: sync-TP to limbo spawn, bump
per-UUID retry counter, schedule retry after 30 ticks. After MAX_RETRIES=3
failures: leave at limbo spawn in GameMode.SPECTATOR, log SEVERE with full
saved coords + reason, alert console for manual `/authlimbo tp` action.
Player stays in pendingTransit across retries so F1 keeps protecting them.

F4 — pre-empt AuthMe's broken teleport: new LoginEvent handler at
EventPriority.LOWEST that adds the UUID to pendingTransit and sync-TPs
the player to limbo spawn BEFORE AuthMe-ReReloaded's own internal
post-login teleport runs. AuthMe's teleport then operates against an
irrelevant location; the existing MONITOR handler still wins last with
the authoritative restore. Net effect: closes the void-death window even
when the saved chunk is far out and slow to load.

Internal: ConcurrentHashMap-backed pendingTransit set + retryCounts map,
both watchdog-timed-out after 5 s so we never leak entries on edge cases.
No new Maven dependencies.

Privacy invariant unchanged — F4 actually strengthens limbo-on-join by
guaranteeing the limbo position is reasserted at LOGIN-LOWEST.

Build: mvn -B clean package (in maven:3.9-eclipse-temurin-21 container) ->
BUILD SUCCESS, AuthLimbo-1.1.0.jar produced.
This commit is contained in:
s8n 2026-05-07 17:50:00 +01:00
parent 1f9d4bb198
commit afea34badc
3 changed files with 339 additions and 13 deletions

View file

@ -4,6 +4,65 @@ All notable changes to AuthLimbo are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.1.0] - 2026-05-07
Data-loss bug fix release. Triggered by the YOU500 incident on
`racked.ru` at 2026-05-07 17:13:39 UTC — full inventory void-death during
AuthMe's post-login teleport. See `AUDIT-2026-05-07.md` for the full
forensic trace and `ROADMAP.md` for tracking.
### Added
- **F1 — VOID-damage guard during post-login restore.** New
`EntityDamageEvent` listener at `EventPriority.HIGHEST,
ignoreCancelled=true`. While a player UUID sits in `pendingTransit`
(post-`LoginEvent`, pre-restore-success), `DamageCause.VOID` events are
cancelled, the player is healed to full, and sync-teleported back to
limbo spawn. Console gets a WARN with the player name + intended TP
target. This single guard would have saved YOU500's inventory.
- **F2 — Recovery on `teleportAsync` future == false.** The previous
log-and-abandon branch in `LoginListener.doTeleport` is replaced with a
retry loop. On any failure (false future, exceptional future, or
chunk-load throw): synchronously snap the player back to limbo spawn,
increment a per-UUID retry counter, schedule another `doTeleport` after
30 ticks (~1.5 s). After 3 failures: leave the player at limbo spawn
in `GameMode.SPECTATOR`, log SEVERE with full saved coords + retry
count, and alert console with manual-intervention instructions
(`/authlimbo tp <player>`). Players stay in `pendingTransit` across
retries so F1 keeps protecting them.
- **F4 — Pre-empt AuthMe's broken teleport.** New `LoginEvent` listener
at `EventPriority.LOWEST` (runs BEFORE AuthMe-ReReloaded's own internal
post-login teleport). Action: synchronously TP the player back to
limbo spawn and add their UUID to `pendingTransit`. AuthMe's
subsequent teleport then operates against an irrelevant location, and
our existing MONITOR handler still wins last with the authoritative
restore. Net effect: closes the void-death window even when the saved
chunk is far out and slow to load.
### Internal
- New `Set<UUID> pendingTransit` (ConcurrentHashMap.newKeySet) and
`Map<UUID, Integer> retryCounts` on `LoginListener`. Both are
watchdog-timed-out after 5 s so we never leak entries on edge cases.
- Constants: `MAX_RETRIES=3`, `RETRY_DELAY_TICKS=30`,
`PENDING_TIMEOUT_TICKS=100`.
- No new Maven dependencies. No new public API.
### Privacy
- Limbo-on-join invariant unchanged. F4 actually *strengthens* it by
guaranteeing the limbo position is reasserted at LOGIN-LOWEST.
### Test plan (reproduces YOU500 in dev Paper 1.21.x + AuthMe-ReReloaded fork b49)
- Set saved coord far out (e.g. X=10000, Z=10000) in `authme.db` for a
test account so the chunk is unloaded at login. Restart server. Login.
Expect: F4 sync-TPs to limbo spawn first; F2 retries on false future;
F1 catches any VOID damage during transit; player ends up at saved
coords with full inventory.
- Set saved Y above world build limit (e.g. 5000). Login. Expect: F2
recovery branch retries up to 3 times, then drops the player into
spectator at limbo spawn with admin alert.
- Trigger a synthetic VOID damage during transit (debug command).
Expect: F1 cancels the damage, snaps player back to limbo spawn at
full health, restore continues.
## [1.0.0] - 2026-04-30 ## [1.0.0] - 2026-04-30
Initial public release. Initial public release.

View file

@ -6,7 +6,7 @@
<groupId>ru.authlimbo</groupId> <groupId>ru.authlimbo</groupId>
<artifactId>AuthLimbo</artifactId> <artifactId>AuthLimbo</artifactId>
<version>1.0.0</version> <version>1.1.0</version>
<packaging>jar</packaging> <packaging>jar</packaging>
<name>AuthLimbo</name> <name>AuthLimbo</name>

View file

@ -20,29 +20,45 @@ import fr.xephi.authme.events.AuthMeAsyncPreLoginEvent;
import fr.xephi.authme.events.LoginEvent; import fr.xephi.authme.events.LoginEvent;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.Chunk; import org.bukkit.Chunk;
import org.bukkit.GameMode;
import org.bukkit.Location; import org.bukkit.Location;
import org.bukkit.World; import org.bukkit.World;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler; import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority; import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener; import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.EntityDamageEvent.DamageCause;
import org.bukkit.event.player.PlayerTeleportEvent; import org.bukkit.event.player.PlayerTeleportEvent;
import java.util.HashSet; import java.util.HashSet;
import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/** /**
* Listens for AuthMe's two relevant events: * Listens for AuthMe's two relevant events plus a damage guard:
* *
* 1. {@link AuthMeAsyncPreLoginEvent} fired before AuthMe authenticates * 1. {@link AuthMeAsyncPreLoginEvent} fired before AuthMe authenticates
* the password. We pin the destination chunk via a plugin chunk-ticket * the password. We pin the destination chunk via a plugin chunk-ticket
* so it's fully loaded by the time the actual teleport runs. * so it's fully loaded by the time the actual teleport runs.
* *
* 2. {@link LoginEvent} fired AFTER AuthMe successfully authenticates * 2. {@link LoginEvent} at {@link EventPriority#LOWEST} (F4) fired BEFORE
* and runs its own (often broken) post-login teleport. We listen at * AuthMe-ReReloaded's own internal post-login teleport. We immediately
* MONITOR priority so we are LAST in the chain, then fire an * sync-TP the player back to limbo spawn so AuthMe's broken teleport
* authoritative teleport that overrides whatever AuthMe / Paper safety * operates against an irrelevant location. The MONITOR handler then
* checks did to the player's location. * runs the authoritative restore.
*
* 3. {@link LoginEvent} at {@link EventPriority#MONITOR} fires LAST in
* the chain, schedules the authoritative teleport to the saved
* quit-location after a configurable tick delay.
*
* 4. {@link EntityDamageEvent} at {@link EventPriority#HIGHEST} (F1)
* while a player is in {@code pendingTransit}, cancels {@code VOID}
* damage and snaps them back to limbo spawn at full health. This is
* the YOU500-incident fix: even if AuthMe's broken teleport drops the
* player into an unloaded section, the void death is intercepted.
* *
* Threading: * Threading:
* - AuthMeAsyncPreLoginEvent fires async (AuthMe worker thread). * - AuthMeAsyncPreLoginEvent fires async (AuthMe worker thread).
@ -62,12 +78,32 @@ import java.util.Set;
*/ */
public final class LoginListener implements Listener { public final class LoginListener implements Listener {
/** Hard cap on teleportAsync retries before falling back to spectator at limbo spawn (F2). */
private static final int MAX_RETRIES = 3;
/** Tick delay between a failed teleport attempt and the next retry (F2). 1500 ms. */
private static final long RETRY_DELAY_TICKS = 30L;
/** Safety timeout before pendingTransit entries expire even if no callback fires (F1). 5 s. */
private static final long PENDING_TIMEOUT_TICKS = 20L * 5L;
private final AuthLimbo plugin; private final AuthLimbo plugin;
private final AuthMeDatabase db; private final AuthMeDatabase db;
/** Tracks active plugin-chunk-tickets so we don't double-add or fail to release. */ /** Tracks active plugin-chunk-tickets so we don't double-add or fail to release. */
private final Set<String> activeTickets = new HashSet<>(); private final Set<String> activeTickets = new HashSet<>();
/**
* F1 UUIDs of players currently in the post-login restore window.
* While present, the player is protected from VOID damage by
* {@link #onEntityDamage}. Removed on successful TP, on final retry
* give-up, or by the {@link #PENDING_TIMEOUT_TICKS} watchdog.
*/
private final Set<UUID> pendingTransit = ConcurrentHashMap.newKeySet();
/** F2 — per-UUID retry counter for failed teleportAsync attempts. */
private final Map<UUID, Integer> retryCounts = new ConcurrentHashMap<>();
public LoginListener(AuthLimbo plugin, AuthMeDatabase db) { public LoginListener(AuthLimbo plugin, AuthMeDatabase db) {
this.plugin = plugin; this.plugin = plugin;
this.db = db; this.db = db;
@ -108,6 +144,50 @@ public final class LoginListener implements Listener {
}); });
} }
/* ---------------- F4: pre-empt AuthMe's broken teleport ---------------- */
/**
* Runs at LOWEST priority BEFORE AuthMe-ReReloaded's own LoginEvent
* handler runs its broken teleport. We immediately sync-TP the player
* back to limbo spawn so AuthMe's subsequent teleport runs against an
* irrelevant location. Combined with F1, this closes the void-death
* window even when the saved chunk is far out and slow to load.
*
* Adds the player to {@code pendingTransit} here too F1 must protect
* the player across the entire LOWESTMONITOR window.
*/
@EventHandler(priority = EventPriority.LOWEST)
public void onLoginPreEmpt(LoginEvent event) {
Player player = event.getPlayer();
if (player == null) return;
final UUID id = player.getUniqueId();
// Mark in-transit BEFORE AuthMe's teleport runs so F1 catches any
// void damage from that teleport.
pendingTransit.add(id);
scheduleTransitTimeout(id);
Location limbo = plugin.limbo().spawn();
if (limbo == null) {
plugin.getLogger().warning("[AuthLimbo] limbo spawn is null at LOWEST pre-empt for "
+ player.getName() + " — cannot pre-empt AuthMe teleport.");
return;
}
// Synchronous teleport so we land BEFORE AuthMe's own MONITOR/HIGH
// handler fires its own teleport. teleportAsync would be racy here.
try {
player.teleport(limbo, PlayerTeleportEvent.TeleportCause.PLUGIN);
if (plugin.debug()) {
plugin.getLogger().info("[AuthLimbo][debug] Pre-empted AuthMe TP for "
+ player.getName() + " — pinned at limbo spawn.");
}
} catch (Throwable t) {
plugin.getLogger().warning("[AuthLimbo] pre-empt teleport failed for "
+ player.getName() + ": " + t.getMessage());
}
}
/* ---------------- Post-login: authoritative teleport ---------------- */ /* ---------------- Post-login: authoritative teleport ---------------- */
@EventHandler(priority = EventPriority.MONITOR) @EventHandler(priority = EventPriority.MONITOR)
@ -115,31 +195,93 @@ public final class LoginListener implements Listener {
Player player = event.getPlayer(); Player player = event.getPlayer();
if (player == null) return; if (player == null) return;
final String name = player.getName(); final String name = player.getName();
final UUID id = player.getUniqueId();
final Location saved = db.getQuitLocation(name); final Location saved = db.getQuitLocation(name);
if (saved == null) { if (saved == null) {
plugin.getLogger().info("[AuthLimbo] No saved location for " plugin.getLogger().info("[AuthLimbo] No saved location for "
+ name + " — leaving where AuthMe put them."); + name + " — leaving where AuthMe put them.");
// No restore needed clear transit guard.
pendingTransit.remove(id);
retryCounts.remove(id);
return; return;
} }
// Defensive: pre-empt handler should have added this already, but ensure.
pendingTransit.add(id);
retryCounts.putIfAbsent(id, 0);
long delay = Math.max(0, plugin.getConfig().getLong("authme.teleport-delay-ticks", 10L)); long delay = Math.max(0, plugin.getConfig().getLong("authme.teleport-delay-ticks", 10L));
Bukkit.getScheduler().runTaskLater(plugin, () -> doTeleport(player, name, saved), delay); Bukkit.getScheduler().runTaskLater(plugin, () -> doTeleport(player, name, saved), delay);
} }
/* ---------------- F1: void-damage guard during transit ---------------- */
/**
* F1 while a player is mid-restore, intercept VOID damage and snap
* them back to limbo spawn at full health. This is the primary fix for
* the YOU500 incident: even if AuthMe's own broken teleport drops the
* player into an unloaded section that triggers "left the confines of
* this world", we cancel the damage and recover the player.
*
* HIGHEST + ignoreCancelled=true so we run last among damage handlers
* and don't double-process events already cancelled by other plugins.
*/
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onEntityDamage(EntityDamageEvent event) {
if (!(event.getEntity() instanceof Player player)) return;
final UUID id = player.getUniqueId();
if (!pendingTransit.contains(id)) return;
if (event.getCause() != DamageCause.VOID) return;
event.setCancelled(true);
Location limbo = plugin.limbo().spawn();
plugin.getLogger().warning(String.format(
"[AuthLimbo] VOID damage intercepted for %s during post-login restore"
+ " (intended TP target: %s) — relocating to limbo spawn at %s.",
player.getName(),
describeIntendedTarget(player),
limbo == null ? "<null>" : describe(limbo)));
Bukkit.getConsoleSender().sendMessage(
"[AuthLimbo] WARN: void-damage guard saved " + player.getName()
+ " from inventory loss during AuthMe restore.");
// Heal first so the next-tick snap doesn't carry residual damage state.
try {
player.setHealth(20.0);
player.setFireTicks(0);
player.setFallDistance(0f);
} catch (Throwable t) {
// best-effort
}
if (limbo != null) {
try {
player.teleport(limbo, PlayerTeleportEvent.TeleportCause.PLUGIN);
} catch (Throwable t) {
plugin.getLogger().warning("[AuthLimbo] limbo recovery teleport failed for "
+ player.getName() + ": " + t.getMessage());
}
}
}
/* ---------------- Core teleport with chunk-prep ---------------- */ /* ---------------- Core teleport with chunk-prep ---------------- */
private void doTeleport(Player player, String name, Location saved) { private void doTeleport(Player player, String name, Location saved) {
if (!player.isOnline()) { if (!player.isOnline()) {
plugin.getLogger().info("[AuthLimbo] " + name plugin.getLogger().info("[AuthLimbo] " + name
+ " went offline before restore — skipping."); + " went offline before restore — skipping.");
clearTransit(player.getUniqueId());
return; return;
} }
World world = saved.getWorld(); World world = saved.getWorld();
if (world == null) { if (world == null) {
plugin.getLogger().warning("[AuthLimbo] Saved world for " plugin.getLogger().warning("[AuthLimbo] Saved world for "
+ name + " is no longer loaded."); + name + " is no longer loaded.");
clearTransit(player.getUniqueId());
return; return;
} }
@ -169,28 +311,118 @@ public final class LoginListener implements Listener {
if (plugin.debug()) { if (plugin.debug()) {
plugin.getLogger().info("[AuthLimbo][debug] Teleport ok for " + name); plugin.getLogger().info("[AuthLimbo][debug] Teleport ok for " + name);
} }
// F1/F2: restore complete clear transit guard.
clearTransit(player.getUniqueId());
} else { } else {
plugin.getLogger().warning("[AuthLimbo] teleportAsync returned false for " handleTeleportFailure(player, name, saved,
+ name + " — Paper may have rejected the location."); "teleportAsync returned false");
} }
// Release the ticket 5s later gives the client time to // Release the ticket 5s later gives the client time to
// download the chunk before we let it unload. // download the chunk before we let it unload.
scheduleTicketRelease(world, cx, cz, key); scheduleTicketRelease(world, cx, cz, key);
}) })
.exceptionally(ex -> { .exceptionally(ex -> {
plugin.getLogger().warning("[AuthLimbo] teleportAsync threw for " handleTeleportFailure(player, name, saved,
+ name + ": " + ex.getMessage()); "teleportAsync threw: " + ex.getMessage());
scheduleTicketRelease(world, cx, cz, key); scheduleTicketRelease(world, cx, cz, key);
return null; return null;
}); });
}).exceptionally(ex -> { }).exceptionally(ex -> {
plugin.getLogger().warning("[AuthLimbo] getChunkAtAsyncUrgently threw for " handleTeleportFailure(player, name, saved,
+ name + ": " + ex.getMessage()); "getChunkAtAsyncUrgently threw: " + ex.getMessage());
scheduleTicketRelease(world, cx, cz, key); scheduleTicketRelease(world, cx, cz, key);
return null; return null;
}); });
} }
/* ---------------- F2: failure recovery + retry ---------------- */
/**
* F2 on any failed teleport (false future, exceptional future, or
* chunk-load throw), do not abandon the player.
*
* Steps:
* 1. Synchronously TP back to limbo spawn so they aren't sitting in
* an unloaded chunk that may void-kill them next tick.
* 2. Increment retry counter. Up to {@link #MAX_RETRIES} retries
* schedule another doTeleport after {@link #RETRY_DELAY_TICKS}.
* 3. After MAX_RETRIES failures: leave player at limbo spawn in
* spectator gamemode, log ERROR with full coords + retry count,
* alert console for manual `/authlimbo tp` intervention.
*
* The player remains in {@code pendingTransit} across all retries so
* F1 still protects them from any void damage during the retry window.
*/
private void handleTeleportFailure(Player player, String name, Location saved, String reason) {
final UUID id = player.getUniqueId();
final int attempt = retryCounts.merge(id, 1, Integer::sum);
plugin.getLogger().warning(String.format(
"[AuthLimbo] Restore attempt %d/%d failed for %s — %s. Recovering to limbo spawn.",
attempt, MAX_RETRIES, name, reason));
// Step 1: snap back to limbo spawn synchronously so they don't
// continue free-falling in an unloaded section.
Location limbo = plugin.limbo().spawn();
if (limbo != null && player.isOnline()) {
// Run on main thread teleportAsync future callbacks may not be there.
Bukkit.getScheduler().runTask(plugin, () -> {
try {
player.teleport(limbo, PlayerTeleportEvent.TeleportCause.PLUGIN);
player.setHealth(20.0);
player.setFireTicks(0);
player.setFallDistance(0f);
} catch (Throwable t) {
plugin.getLogger().warning("[AuthLimbo] sync recovery TP failed for "
+ name + ": " + t.getMessage());
}
});
}
if (attempt >= MAX_RETRIES) {
// Step 3: give up gracefully.
plugin.getLogger().severe(String.format(
"[AuthLimbo] Failed to restore %s after %d retries — manual intervention needed."
+ " Saved coords: %s(%.1f, %.1f, %.1f). Last reason: %s.",
name, MAX_RETRIES,
saved.getWorld() == null ? "<null>" : saved.getWorld().getName(),
saved.getX(), saved.getY(), saved.getZ(), reason));
Bukkit.getConsoleSender().sendMessage(
"[AuthLimbo] ERROR: failed to restore " + name + " after "
+ MAX_RETRIES + " retries — manual intervention needed."
+ " Run `/authlimbo tp " + name + "` after investigating.");
Bukkit.getScheduler().runTask(plugin, () -> {
if (player.isOnline()) {
try {
player.setGameMode(GameMode.SPECTATOR);
player.sendMessage("§c[AuthLimbo] Could not restore your saved location."
+ " Staff have been notified — please ping an admin for"
+ " a manual /authlimbo tp.");
} catch (Throwable t) {
// best-effort
}
}
});
clearTransit(id);
return;
}
// Step 2: schedule a retry. Player stays in pendingTransit so F1
// continues to protect them.
Bukkit.getScheduler().runTaskLater(plugin, () -> {
if (!player.isOnline()) {
clearTransit(id);
return;
}
doTeleport(player, name, saved);
}, RETRY_DELAY_TICKS);
}
/* ---------------- Helpers ---------------- */
private void scheduleTicketRelease(World world, int cx, int cz, String key) { private void scheduleTicketRelease(World world, int cx, int cz, String key) {
if (!activeTickets.contains(key)) return; if (!activeTickets.contains(key)) return;
Bukkit.getScheduler().runTaskLater(plugin, () -> { Bukkit.getScheduler().runTaskLater(plugin, () -> {
@ -206,4 +438,39 @@ public final class LoginListener implements Listener {
} }
}, 20L * 5L); }, 20L * 5L);
} }
/**
* Removes a player from the transit set + retry counter. After this,
* F1's void-damage guard no longer protects them.
*/
private void clearTransit(UUID id) {
pendingTransit.remove(id);
retryCounts.remove(id);
}
/**
* Watchdog: if no callback fires within {@link #PENDING_TIMEOUT_TICKS},
* remove the UUID from pendingTransit so we don't leak entries.
*/
private void scheduleTransitTimeout(UUID id) {
Bukkit.getScheduler().runTaskLater(plugin, () -> {
if (pendingTransit.remove(id)) {
retryCounts.remove(id);
if (plugin.debug()) {
plugin.getLogger().info("[AuthLimbo][debug] pendingTransit timeout for " + id);
}
}
}, PENDING_TIMEOUT_TICKS);
}
private static String describeIntendedTarget(Player player) {
Location loc = player.getLocation();
return describe(loc);
}
private static String describe(Location loc) {
if (loc == null || loc.getWorld() == null) return "<unknown>";
return String.format("%s(%.1f, %.1f, %.1f)",
loc.getWorld().getName(), loc.getX(), loc.getY(), loc.getZ());
}
} }