Skip to content

fix: remove the silent failure paths around island level - #203

Merged
minoneer merged 2 commits into
masterfrom
fix/no-silent-command-paths
Aug 18, 2026
Merged

fix: remove the silent failure paths around island level#203
minoneer merged 2 commits into
masterfrom
fix/no-silent-command-paths

Conversation

@minoneer

@minoneer minoneer commented Aug 18, 2026

Copy link
Copy Markdown
Member

Callback has no failure channel, so a caller could not tell that its callback would
never run. Each of these sites returned quietly and left whatever it had scheduled
inside the callback permanently pending — with nothing in the console either.

What was silent

ChunkSnapshotLevelLogic.calculateScoreAsync returned without invoking the callback when
WorldGuardHandler.getIslandRegionAt(l) returned null, and logged nothing. Four consumers:

consumer consequence
/is info prints nothing, and leaks the 30s patience cooldown so the retry answers "be patient"
/is level prints nothing when a recalculation is due
IslandInfoEvent API callback never fires; consumers wait forever
RecalculateTopTen advances its queue only from inside the callback

uSkyBlock.calculateScoreAsync additionally dereferenced a null IslandInfo.

AbstractPlayerInfoCommand.execute returned false without a word when no player
argument was supplied, and AbstractIslandInfoCommand.execute did the same at its final
return — which is why /usb is info with no argument, off-island, produced nothing.

Approach

LevelLogic.calculateScoreAsync now returns whether the callback will run, and
implementations must log before refusing. Every caller handles the refusal: the commands
tell the player and clear the cooldown, RecalculateTopTen skips the island and keeps
draining, InternalEvents logs.

No branch is left doing nothing. Where a state is unreachable by construction it throws
rather than returning quietly — the sealed doExecute(CommandSender, PlayerInfo) hook
throws UnsupportedOperationException, and a null playerInfo after a successful
super.execute() throws IllegalStateException.

Also repairs a pre-existing regression

RecalculateTopTen extends BukkitRunnable, so scheduler.async(RecalculateTopTen.this)
bound to Scheduler.async(BukkitRunnable)runTaskAsynchronously
checkNotYetScheduled(), which throws once the instance has been scheduled — which it has,
by RecalculateRunnable. The success path has thrown since a0404c3e ("Move all
scheduling to Scheduler", v3.2.0)
, which rebound a plain-Runnable submission to the
BukkitRunnable overload. Both sites now cast to Runnable.

Scope is narrower than it first looks. The auto path is the only affected one:

path behaviour affected
/is top, GenTopTenCommandIslandLogic.generateTopTen reads persisted islandInfo.getLevel() off disk, sorts, fires RANK_UPDATED no
island.autoRefreshScoreRecalculateRunnableRecalculateTopTen recomputes levels via calculateScoreAsync yes

So the leaderboard itself was never broken — only the background refreshing of the levels
it sorts, and those still updated whenever a player ran /is level or /is info (the
wrapper calls islandInfo.setLevel(...)). The shipped default autoRefreshScore is 0m,
so on default configuration the affected code never ran at all.

Verified that RecalculateTopTen is the only self-scheduling BukkitRunnable in Core —
the other nine subclasses never reschedule themselves, and IncrementalRunnable (the engine
behind chunk snapshots, generation and purge) implements Runnable, so its
scheduler.sync(this, …) binds to the safe overload.

The failure mode is not merely inferred from checkNotYetScheduled(): it is present once in
production history, from /is reset on v3.2.0-SNAPSHOT in Dec 2025 —
IllegalStateException: Already scheduled as 552. That path has been reworked across three
releases since and static analysis finds nothing on master that could reproduce it, so it is
evidence the mechanism is real rather than a second open bug.

Scope note on IslandInfoEvent

For the other consumers the fix is real. Here the consumer's Callback still never runs
and still has no way to learn that — the event exposes only getIslandLocation() and
getCallback(). The change makes it diagnosable, not fixed. A proper failure channel
needs an APIv2 addition; follow-up worth filing.

Behaviour change worth review

The standing-on-an-island fallback no longer runs when a player name was supplied but
did not resolve. That case already reported an invalid player, and silently retargeting to
wherever the sender happens to stand is surprising.

In practice this was already dead code: the default bukkit PlayerDB resolves any string
through Bukkit.getOfflinePlayer(String), which never returns null, so super.execute()
could not fail with arguments present. Only MemoryPlayerDB could. The one real
consequence is that /usb island get|set require a player name — which they always did.

Compatibility

voidboolean on uSkyBlock.calculateScoreAsync is binary-incompatible for anything
compiled against ovh.uskyblock:uSkyBlock-Core ≤ 3.6.1, which would see
NoSuchMethodError. Accepted deliberately: the stability guarantee covers
us.talabrek.ultimateskyblock.api (uSkyBlock-API), which this PR does not touch, and Core
3.4.2 → 3.5.0 already removed public classes on a minor bump. LevelLogic is Guice-bound
with no third-party registration hook. Worth a line in the 3.7.0 notes.

Notes

AweLevelLogic takes the signature change only; its body is commented out and it throws.

Translation templates regenerated (./gradlew translation) — three new msgids, no other
churn; extractTranslation is idempotent against the committed .pot files, so
build.yml's git diff --exit-code passes.

No new tests. The missing-region failure needs a live island whose WorldGuard region has
been removed, which the harness has no fixture for, and asserting that the boolean is
checked
would pin the mechanism rather than the behaviour. The InternalEventsTest stub
change from doNothing() to doReturn(true) is load-bearing, not cosmetic: Mockito's
DoesNothing rejects a non-void method at stubbing time, so doNothing() would fail
@BeforeEach for all six tests.

Coverage gap worth a follow-up: no ittest scenario touches level logic,
calculateScoreAsync, IslandInfoEvent or the top-ten refresh. A scenario that sets
autoRefreshScore, occupies two islands and asserts both levels move would have caught
the a0404c3e regression in 2025, and unlike the missing-region case it is a fixture the
harness can support. A unit-level RecalculateTopTenTest is also possible, but note a
mocked Scheduler accepts the same BukkitRunnable twice, so a naive test passes while
production throws.

Callback carries no failure channel, so a caller could not tell that its
callback would never run. Every one of these sites returned quietly and left
whatever it had scheduled inside the callback permanently pending.

ChunkSnapshotLevelLogic returned without invoking the callback when a WorldGuard
island region was missing, and logged nothing. Four consumers were affected:
/is info and /is level printed nothing at all, IslandInfoEvent never fired its
callback so API consumers waited forever, and RecalculateTopTen - which advances
its queue only from inside the callback - stopped draining for good on a single
unscoreable island. uSkyBlock.calculateScoreAsync additionally dereferenced a
null IslandInfo for an unknown island name.

LevelLogic.calculateScoreAsync now returns whether the callback will run, and
implementations must log before refusing. Callers handle the refusal: the two
commands tell the player and clear the patience cooldown they would otherwise
leak, RecalculateTopTen skips the island and keeps draining, and InternalEvents
logs on behalf of the API consumer that cannot be told.

AbstractPlayerInfoCommand returned false without a word when no player argument
was given. It now reports through an overridable hook, because
AbstractIslandInfoCommand relies on that false to fall back to the island the
sender is standing on; it overrides the hook and reports once its own fallback
has failed too. Its final return false is no longer silent either.

Two behaviour fixes fall out of that rewrite: the "Player X has no island"
error was previously also emitted when the island existed and only the argument
count was wrong, and the standing-on-an-island fallback no longer runs when a
player name was supplied but did not resolve - that case already reported an
invalid player and should not silently retarget.
…chedule

Addresses review of #203.

Translation templates were not regenerated, so `extractTranslation` followed by
`git diff --exit-code` in build.yml would have failed the PR before it built,
and Crowdin would never have seen the three new strings.

RecalculateTopTen's self-reschedule did not work. The class extends
BukkitRunnable, so `scheduler.async(RecalculateTopTen.this)` bound to
Scheduler.async(BukkitRunnable), which delegates to runTaskAsynchronously and
throws IllegalStateException once the instance has been scheduled - which it has,
by RecalculateRunnable. Both the pre-existing success path and the skip path
added here threw. Casting to Runnable submits it as a fresh task.

That success path has been broken since a0404c3 ("Move all scheduling to
Scheduler", v3.2.0), which rebound a plain-Runnable submission to the
BukkitRunnable overload, so top-ten recalculation has been scoring one island per
cycle rather than draining its queue. Only servers with a non-zero
`island.autoRefreshScore` are affected; the shipped default is 0m.

Non-player senders with no arguments were still unreported: the location fallback
is Player-only, and the no-op onMissingPlayerArgument override suppressed the
parent's message. execute() now handles the empty-argument case up front and
routes non-players to the inherited message, so the override is gone and the
"super already said so" comment is true for the case it describes.

No branch is left doing nothing. The sealed parent hook
doExecute(CommandSender, PlayerInfo) threw nothing and returned nothing; it now
throws UnsupportedOperationException. The playerInfo-null case after a successful
super.execute() is unreachable by construction and now throws IllegalStateException
rather than being dropped or NPEing later.

Message wording: "Ask an administrator to check the console" told a player to
perform a step they cannot; it now reads "Please contact a server admin", matching
four existing msgids. The level-logic warning no longer asserts a missing
WorldGuard region, since getIslandRegionAt also returns null when the world has no
RegionManager. The unresolved-island warning no longer claims the name is unknown,
which cannot happen - a null name or maintenance mode is what it actually means.

Also: guard the scheduled refusal message in LevelCommand with isOnline(), as the
sibling callback fifteen lines up already does, and log the already-computed island
name in InternalEvents instead of re-deriving it from a Location whose World is a
WeakReference that throws once unloaded.
@minoneer
minoneer merged commit 18bf0f2 into master Aug 18, 2026
6 checks passed
@minoneer
minoneer deleted the fix/no-silent-command-paths branch August 18, 2026 17:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant