Skip to content

fix(spigot): attribute a tunneled login whose LOGIN_START a packet plugin consumed - #175

Merged
minekube-ai-engineer[bot] merged 1 commit into
mainfrom
fix/spigot-login-stall-attribution
Sep 28, 2026
Merged

minekube-ai-engineer[bot] merged 1 commit into
mainfrom
fix/spigot-login-stall-attribution

Conversation

@minekube-ai-engineer

Copy link
Copy Markdown

What this fixes

A packet-level login plugin (a PacketEvents listener — e.g. an auth plugin's premium autologin) sits upstream of connect-spigot's pipeline position and clears the frame buffer (PacketEventsImplHelper.handleServerBoundPacket) when it cancels LOGIN_START. The connector's own data handler then never sees the packet, so its fake offline-login cycle — whose only invocation site is the LOGIN_START branch — never runs. The session is left pending: the client receives nothing and the only line anywhere is the server's cause-blind Took too long to log in (~30 s).

Verified live on Paper 26.3-49 + connect-spigot 0.15.15 + packetevents 2.14.0 (pipeline dump: plugin decoder at index 4, the connector's position at 10). Ordering claim, pipeline dumps and the rejected alternatives (a fabricated-login floor = auth bypass; re-assert = nothing to restore) are recorded on the internal verification card that produced this change, and the integrator-facing docs half shipped separately as minekube/connect#175.

The change

connect_login_stall_watchdog, installed upstream of connect_data_handler. The position is the point of the handler: SpigotDataHandler consumes LOGIN_START (it does not forward it), so a watchdog installed downstream of it would miss every healthy login and report a stall per session. It anchors on the data handler's name (SpigotDataHandler.HANDLER_NAME), so the order does not depend on the order of the surrounding pipeline edits, and the ordering itself is pinned by a test.

It arms a bounded window when the handshake is observed and, if no LOGIN_START follows, emits exactly one default-verbosity warning:

Connect tunneled login stalled: no LOGIN_START reached the connector within 10000 ms player=coconut session=8b1f2c34-5d6e-4f70-891a-b2c3d4e5f607 endpoint=hce2e1535984129 packets=1 - probable cause: a packet-level login plugin in the pipeline consumed the login start before connect_data_handler, so Connect's offline-login cycle never ran and the client is left without a login response; note: connect-player never fires for passthrough sessions, so a connect-player exemption is not a fix on an offline-mode endpoint

(the rendered line above is produced by the test run through the production format string and the production message formatter, player=/session=/endpoint= mirroring the correlators LocalChannelInboundHandler already logs).

Also in this PR:

  • SessionProposal#getSessionId() — protobuf is an implementation dependency of core, so platform modules cannot resolve the generated types at compile time (the first version of this change did not compile in spigot for exactly that reason). Anything a platform module has to log or correlate now goes through an accessor.
  • SpigotInjector.injectsAddons() — the passthrough gate extracted so it is unit-tested rather than inline in an anonymous handler.
  • Test stubs for ClientIntentionPacket / ServerboundHelloPacket (the convention already used for the chat packets: the handler identifies packets by class name, so the tests need no running server).

Evidence — RED first, in a disposable pod (never the Hermes pod)

Clean base origin/main (dbe4c77d) + JDK 17, ./gradlew --no-daemon build:

BUILD SUCCESSFUL in 1m 58s   (EXIT=0)

RED-A — the raw unfixed tree (production source at origin/main, new tests present):

> Task :spigot:compileTestJava FAILED
SpigotLoginStallWatchdogTest.java:45: error: cannot find symbol
SpigotInjectorTest.java:47: error: cannot find symbol

i.e. the released tree has no stall detector at all.

RED-B — the unfixed behaviour (handler installed at its production position, observing the same packets, but with no bounded window and no line):

SpigotLoginStallWatchdogTest > namesTheSessionWhenNoLoginStartReachesTheConnector() FAILED
    org.opentest4j.AssertionFailedError: [] ==> expected: <1> but was: <0>
SpigotLoginStallWatchdogTest > namesAStalledSessionOnlyOnce() FAILED
    org.opentest4j.AssertionFailedError: [] ==> expected: <1> but was: <0>
14 tests completed, 2 failed

Failing for the right reason: no line at all — exactly the silence the released connector has.

GREEN — the same tests at the PR head:

:spigot:test  BUILD SUCCESSFUL
TEST-...SpigotLoginStallWatchdogTest.xml  tests="6" failures="0" errors="0"
  watchesEveryLoginStartTheDataHandlerConsumes, namesTheSessionWhenNoLoginStartReachesTheConnector,
  namesAStalledSessionOnlyOnce, staysQuietWhenTheLoginStartArrives, staysQuietWithoutAHandshake,
  staysQuietWhenTheSessionEndsBeforeTheWindow
TEST-...SpigotInjectorTest.xml            tests="3" failures="0" errors="0"
TEST-...SpigotDataHandlerTest.xml         tests="5" failures="0" errors="0"

GREEN — the CI command on the PR head: ./gradlew --no-daemon build → BUILD SUCCESSFUL in 2m 40s (EXIT=0, 73 tasks) on JDK 17 (eclipse-temurin:17-jdk, 6Gi/2 CPU sandbox pod in hermes-minekube, the pod the whole run happened in; jq had to be installed for ReleaseAssetVerificationTest, which is environmental and present on CI's runners).

The reverse case is a real assertion, not a mirror of the positive one: the fixture puts a handler at connect_data_handler that consumes LOGIN_START exactly like the real one, and the test asserts the watchdog still saw it and that the packet_handler sink did not — the ordering that makes a healthy login invisible to a downstream installer.

Compatibility notes

  • No behaviour change for a healthy session. The handler never consumes, replaces, delays or fabricates a packet and never closes a channel; it schedules one task per session (10 s) that is cancelled by LOGIN_START, by channel close or by handler removal.
  • Log-first only. A kick that names the cause would change user-visible behaviour; it is proposed as a follow-up below, not implemented here.
  • Level: warn, default verbosity (no debug flag needed, no per-packet line).
  • Privacy: no raw IPs, no error text; the player is rendered <bedrock-principal-v2> when the session carries a signed Bedrock principal (same mapping as the existing data-handler/backend-channel lines).
  • Window vs the server's own timeout: 10 s, deliberately inside the server's ~30 s (600 ticks) login timeout so this line is the first cause-naming one an operator reads; a healthy handshake→LOGIN_START gap is milliseconds.
  • Old servers: packet identity is matched by class name, covering both Mojang-mapped names (1.20.5+) and the legacy obfuscated ones (PacketHandshakingInSetProtocol / PacketLoginInStart) that ClassNames falls back to. Keep the two lists in sync if a Minecraft version moves the packets again — the handler documents the link to ClassNames.
  • Passthrough (offline-mode) endpoints are not covered and cannot be: Connect injects no addons there (SpigotInjector.injectsAddons, now tested), so nothing connector-side can observe or attribute such a session — which is exactly the caveat the emitted line states.
  • No new configuration, no version bump, no dependency added.

Follow-up (deliberately not in this change)

  1. A cause-named kick. Closing the session with the cause instead of waiting out the server's generic timeout is a user-visible behaviour change and belongs to a human decision.
  2. A plugin that swallows the handshake too would remove even the server's own bound and would not arm this window. Measured case here is LOGIN_START; the handshake variant is flagged, not asserted.

Out of scope

  • Edge-side attribution for a session that ends (gate/moxy decode-close + tunnel terminal summary) is a different component and a different event.
  • Re-assert/floor: rejected in the parent card — fabricating a login would bypass a plugin that gates cracked players behind /login and cannot answer that plugin's client-side handshake.

…ugin consumed

A packet-level login plugin (a PacketEvents listener, e.g. an auth plugin's premium
autologin) sits upstream of connect-spigot's pipeline position and clears the frame buffer
when it cancels LOGIN_START. The connector's own data handler then never sees the packet,
its fake offline-login cycle - whose only invocation site is the LOGIN_START branch - never
runs, and the session is left pending: the client receives nothing and the only line
anywhere is the server's cause-blind "Took too long to log in".

Add connect_login_stall_watchdog, installed upstream of connect_data_handler. That position
is the point of the handler: connect_data_handler consumes LOGIN_START, so a watchdog
installed downstream of it would miss every healthy login and report a stall per session.
The watchdog arms a bounded 10 s window when the handshake is observed (well inside the
server's own ~30 s login timeout, so its line is the first one the operator reads) and, if
no LOGIN_START follows, emits exactly one default-verbosity warning that names the player
(or <bedrock-principal-v2>), the session and endpoint correlator, the missing login start,
the probable cause and the passthrough caveat: connect-player never fires for passthrough
sessions, so a connect-player exemption is not a fix on an offline-mode endpoint.

Log-first by design: no packet is consumed, replaced, delayed or fabricated and no channel
is closed, so healthy sessions are unaffected; a kick that names the cause changes
user-visible behaviour and is deliberately left to a separate decision. Packets are
identified by class name (the convention of SpigotChatSessionPacketFilter), not through the
server-internals reflection in ClassNames, which keeps the handler testable without a
running server.

Also expose SessionProposal#getSessionId() - protobuf is an implementation dependency of
core, so platform modules cannot resolve the generated types - and extract
SpigotInjector.injectsAddons() so the passthrough gate that keeps the watchdog out of
offline-mode endpoints is covered by a unit test.

RED-first: the new tests fail on the unfixed tree for the right reason (no line at all:
"expected: <1> but was: <0>"), then pass; ./gradlew --no-daemon build is green.
@minekube-ai-engineer
minekube-ai-engineer Bot merged commit c031768 into main Sep 28, 2026
2 checks passed
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