Skip to content

fix(connect): recover closed admission coordinator on plugin reload - #146

Merged
minekube-ai-engineer[bot] merged 1 commit into
mainfrom
fix/coordinator-reload-recovery
Aug 19, 2026
Merged

fix(connect): recover closed admission coordinator on plugin reload#146
minekube-ai-engineer[bot] merged 1 commit into
mainfrom
fix/coordinator-reload-recovery

Conversation

@minekube-ai-engineer

Copy link
Copy Markdown

Fixes: after a plugin/server reload, EVERY join attempt logs [connect] Connection error with WatchService: java.lang.IllegalStateException: Bedroom admission coordinator is closed and the watch keeps reconnecting. Full restart fixes it.

Root cause

  • BedrockAdmissionCoordinator is @Singleton and resolved from the PARENT injector (SpigotPlatform takes it via @Inject in construction, onLoad). It is a parent-scoped singleton shared by every enable() cycle.
  • WatcherRegister + WatchClient are bound per-enable in the CHILD injector (WatcherModule, asEagerSingleton).
  • ConnectPlatform.disable() calls admissionCoordinator.close() (parent scope) — permanent.
  • On disable→enable reuse (plugin reload, PlugMan-style reload, /reload on servers that reuse the plugin instance), the NEW child-scoped watcher connects but routes proposals into the CLOSED parent coordinator: WatchClient.onMessageadmissionCoordinator.proposal()ensureOpen() throws ISE → OkHttp processNextFrame() catches Exception → failWebSocketlistener.onFailurewatcher.onError → the exact log line → retry()/reconnect. Repeats on every proposal until JVM restart.

Fix

  • ConnectPlatform.enable() now calls admissionCoordinator.reset() before the new cycle's watcher binds: pending admissions are dropped (stale across cycles — old session players can never match), the VerifiedBedrockIdentityRegistry is reopened, and the cleanup executor that close() shut down is recreated.
  • Defense-in-depth in WatchClient.onMessage: a proposal into a closed coordinator is now rejected over the wire (SessionRejection, INTERNAL: Bedrock admission coordinator is closed) instead of letting the ISE escape and fail the WebSocket — the watch stream stays up and recovers on the next enable, rather than entering an endless reconnect loop.

Tests (RED on unfixed main, GREEN with fix)

  • ConnectPlatformReloadRecoveryTest.enableRecoversCoordinatorClosedByPreviousDisable — enable() after disable() makes the shared coordinator accept proposals again.
  • ConnectPlatformReloadRecoveryTest.reloadCycleDeliversSessionProposalThroughRecoveredCoordinator — full reload cycle (enable → proposal → disable → enable on same parent injector → proposal) delivers the second proposal instead of throwing the ISE.
  • WatchClientTest.closedCoordinatorRejectsProposalInsteadOfFailingTheWatch — a proposal into a closed coordinator is rejected on the wire; the watch stream does not fail.

Verified locally: all three tests fail with IllegalStateException: Bedrock admission coordinator is closed on unfixed main and pass with the fix; ./gradlew build green.

A plugin reload (disable -> enable on the same plugin instance) left the
parent-scoped BedrockAdmissionCoordinator permanently closed: disable()
closes it, while WatcherRegister/WatchClient are recreated per enable in a
child injector, so the new watch routed every session proposal into the
closed coordinator, throwing IllegalStateException 'Bedroom admission
coordinator is closed' which failed the WebSocket and put the watch into an
endless reconnect loop until JVM restart.

- ConnectPlatform.enable() now resets the shared coordinator before the new
  cycle's watcher binds: pending admissions are dropped (stale across
  cycles), the identity registry is reopened, and the cleanup executor that
  close() shut down is recreated.
- WatchClient now rejects a proposal over the wire (SessionRejection) when
  the coordinator is closed instead of letting the ISE escape and kill the
  WebSocket stream.

Regression tests (RED on unfixed main, GREEN with fix):
- ConnectPlatformReloadRecoveryTest: enable() after disable() recovers the
  coordinator; full enable -> proposal -> disable -> enable -> proposal
  reload cycle delivers the second proposal instead of the ISE.
- WatchClientTest.closedCoordinatorRejectsProposalInsteadOfFailingTheWatch:
  a proposal into a closed coordinator is rejected on the wire, the watch
  stream stays up.
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.

0 participants