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 fires — afterConnectionEstablished, 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.findMethod → Class.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)theme: aotAn issue related to Ahead-of-time processingtype: bugA general bug

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions