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);
}
}
mvn -Pnative native:compile
./target/demo
- 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
Describe the bug
When a Spring Boot application registers a
WebSocketHandlervia@EnableWebSocket/WebSocketConfigureron 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 theorg.eclipse.jetty.websocket.api.Session$Listenercallbacks of the endpoint object by reflective lookup (ReflectUtils.findMethod→Class.getMethod(name, paramTypes)), then converts them to method handles viaMethodHandles.Lookup.unreflect.Class.getMethodon a class that is not registered for reflection in a native image throwsNoSuchMethodException, which Jetty swallows (ReflectUtils.findMethodcatches it and returnsnull); anullhandle 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 tonulland the entire WebSocket event pipeline is a no-op.This is platform-independent (Linux/Windows/macOS native images all affected).
Steps to reproduce
mvn -Pnative native:compile./target/demowscat -c ws://localhost:8080/ws), sendhi.Expected vs actual
mvn spring-boot:run): receivesecho:hi. ✅hinever answered, no log output, no close frame. Even a malformed JSON/error path that the handler would normally signal is silent. ❌A diagnostic
RuntimeHintsRegistrarlogging insideafterConnectionEstablishedproves the callback is never invoked in the native image (the frames do reachWebSocketCoreSession— instrumenting Jetty showsonFramesucceeding — but the listener handles arenull).Verified workaround
Registering the adapter + Jetty's dynamically constructed message sinks fixes it completely (black-box suite then passes 100%):
(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.createMessageSinkfails withLookup.findConstructorNoSuchMethodError.)Suggested fix on the Spring side
spring-websocketshould ship hints for its own Jetty listener adapter, e.g.@Reflectable/@ReflectiveHierarchyonJettyWebSocketHandlerAdapter(methods on theSession$Listenerinterface it implements), or register it from the Boot WebSocket/Jetty autoconfiguration hints. As it stands, every Boot 4 app using WebFlux-less@EnableWebSocketon Jetty produces a silently broken native image, and the failure mode (no error at all) is extremely hard to diagnose.Context
spring-boot-starter-websocket+spring-boot-starter-jetty, Tomcat excluded)