Skip to content

Native image + Jetty 12 WebSocket: all session events silently dropped (JettyWebSocketHandlerAdapter has no reflection hints)Native im #37324

Description

@passedbylove

Describe the bug

When a Spring Boot application registers a WebSocketHandler via @EnableWebSocket / WebSocketConfigurer on the embedded Jetty 12 server and is packaged as a GraalVM native image, the WebSocket handshake completes but no handler callback ever firesafterConnectionEstablished, handleTextMessage, afterConnectionClosed, nothing. The connection looks "open" from the client side, the server is silent forever. The same jar runs perfectly on the JVM.

Root cause: Jetty 12's JettyWebSocketFrameHandlerFactory.createListenerMetadata(Class) resolves the org.eclipse.jetty.websocket.api.Session$Listener callbacks of the endpoint object by reflective lookup (ReflectUtils.findMethodClass.getMethod(name, paramTypes)), then converts them to method handles via MethodHandles.Lookup.unreflect. Class.getMethod on a class that is not registered for reflection in a native image throws NoSuchMethodException, which Jetty swallows (ReflectUtils.findMethod catches it and returns null); a null handle simply means "skip dispatching this event".

Spring's adapter that implements Session$Listener
org.springframework.web.socket.adapter.jetty.JettyWebSocketHandlerAdapter — is not registered anywhere for reflection: not by spring-websocket's own hints, not by spring-boot's AOT-generated hints, and not by the GraalVM reachability metadata repository (its WebSocket entries only cover Jetty 9.4). So in a native image every handle resolves to null and the entire WebSocket event pipeline is a no-op.

This is platform-independent (Linux/Windows/macOS native images all affected).

Steps to reproduce

@EnableWebSocket
@RestController
public class Demo implements WebSocketConfigurer {

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(new TextWebSocketHandler() {
            @Override
            protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
                session.sendMessage(new TextMessage("echo:" + message.getPayload()));
            }
        }, "/ws");
    }

    public static void main(String[] args) {
        new SpringApplicationBuilder(Demo.class).run(args);
    }
}
  1. mvn -Pnative native:compile
  2. ./target/demo
  3. Connect with any WS client (wscat -c ws://localhost:8080/ws), send hi.

Expected vs actual

  • JVM run (mvn spring-boot:run): receives echo:hi. ✅
  • Native image: handshake succeeds, hi never answered, no log output, no close frame. Even a malformed JSON/error path that the handler would normally signal is silent. ❌

A diagnostic RuntimeHintsRegistrar logging inside afterConnectionEstablished proves the callback is never invoked in the native image (the frames do reach WebSocketCoreSession — instrumenting Jetty shows onFrame succeeding — but the listener handles are null).

Verified workaround

Registering the adapter + Jetty's dynamically constructed message sinks fixes it completely (black-box suite then passes 100%):

public final class JettyWebSocketHints implements RuntimeHintsRegistrar {
    @Override
    public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
        hints.reflection().registerType(
            TypeReference.of("org.springframework.web.socket.adapter.jetty.JettyWebSocketHandlerAdapter"),
            MemberCategory.INTROSPECT_PUBLIC_METHODS, MemberCategory.INVOKE_PUBLIC_METHODS);
        for (String pkg : new String[] {"org.eclipse.jetty.websocket.core.messages.",
                                        "org.eclipse.jetty.websocket.common.internal."}) {
            for (String sink : new String[] {"StringMessageSink", "PartialStringMessageSink",
                    "ByteBufferMessageSink", "PartialByteBufferMessageSink", "ByteArrayMessageSink",
                    "PartialByteArrayMessageSink", "ReaderMessageSink", "InputStreamMessageSink",
                    "DispatchedMessageSink"}) {
                hints.reflection().registerType(TypeReference.of(pkg + sink),
                    MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS);
            }
        }
    }
}

(The sink classes are needed because of a second reflective gap inside Jetty itself — see the related report I am filing against jetty/jetty.project and oracle/graalvm-reachability-metadata: once the listener dispatch works, JettyWebSocketFrameHandler.createMessageSink fails with Lookup.findConstructor NoSuchMethodError.)

Suggested fix on the Spring side

spring-websocket should ship hints for its own Jetty listener adapter, e.g. @Reflectable/@ReflectiveHierarchy on JettyWebSocketHandlerAdapter (methods on the Session$Listener interface it implements), or register it from the Boot WebSocket/Jetty autoconfiguration hints. As it stands, every Boot 4 app using WebFlux-less @EnableWebSocket on Jetty produces a silently broken native image, and the failure mode (no error at all) is extremely hard to diagnose.

Context

  • Spring Framework: 7.0.9 (Spring Boot 4.1.1, spring-boot-starter-websocket + spring-boot-starter-jetty, Tomcat excluded)
  • Jetty: 12.1.12 (jetty-websocket-jetty-common / jetty-websocket-core-common)
  • GraalVM: Oracle GraalVM 25.0.3+9.1, native-maven-plugin (buildtools)
  • OS: Rocky Linux 9.8 x86_64 (glibc) — but the defect is platform-independent

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    in: webIssues in web modules (web, webmvc, webflux, websocket)type: bugA general bug

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions