This is an automated email from the ASF dual-hosted git repository. dashorst pushed a commit to branch wicket-8.x in repository https://gitbox.apache.org/repos/asf/wicket.git
commit 7551a439802c328fac9cd01b7334e9088d61fa41 Author: Emond Papegaaij <[email protected]> AuthorDate: Thu Sep 24 20:43:26 2026 +0200 Resolve a WebSocket message against the live HTTP session, not a copy of it ServletRequestCopy copies the upgrade request because the container recycles it once the handshake is over, which is what its javadoc has said since 6.0. It copied the session along with it, into an HttpSessionCopy holding the attributes as they stood at that moment. A session is not a request: it is not discarded at the end of one, it lives for as long as the client keeps it, so there was nothing to copy it for - and the copy is what every message of a connection resolves through, in both directions. Reading, that froze the connection at the handshake. HttpSessionStore#lookup() reads the Wicket Session out of the attribute map of whatever getSession() hands back, so a connection went on resolving the Session instance, the attributes and the signed-in state of a session that had since ended - by an idle timeout, by HttpSession#invalidate() from a filter, by a container-managed logout, or by being replaced. Writing, it went nowhere: HttpSessionStore#invalidate() resolved to HttpSessionCopy#invalidate(), which clears a private map, so a sign out issued from inside a callback left the HTTP session of the client valid, and Session#setAttribute landed in the copy and was lost. Hand back the live session instead. Wicket already has it: JavaxUpgradeHttpRequest holds the HttpSession that WicketServerEndpointConfig#modifyHandshake stashed in the user properties, and that is the object ServletRequestCopy was throwing away. The one thing the copy has to add is what a request says once its session has ended. The servlet API answers that by throwing IllegalStateException, so getSession() returns null from then on - which is what an HttpServletRequest is supposed to say, and what makes the outcome the same on a container that throws and one that merely empties the session. That makes the guard that was already there do its work. broadcastMessage() has a single session-dependent gate, if (session == null), and the copy guaranteed it never fired; it is now reached the first time a message arrives on a connection whose session has gone. It is also the only gate a shared WebSocketResource passes through, because a resource is served against a throwaway page and never consults the page manager - a connection to one used to outlive not just the container's invalidation but Session#invalidateNow() too. A connection that reaches that gate is now closed with 1008, the policy violation code of RFC 6455 that a rejected connection already uses, rather than left open and silently delivering nothing. A ClosedMessage, ErrorMessage or AbortedMessage is still dropped without closing, because closing a connection that is already going away says nothing. The tests drive the transport rather than the tester, and need two things to do it. They install HttpSessionStore, because MockApplication installs MockSessionStore, whose lookup() ignores the request and hands back the one Session it holds. And they detach the ThreadContext before sending, because a frame arrives on a container thread carrying none of Wicket's thread locals, while the tester runs on the thread that served the last HTTP request and broadcastMessage() reuses a Session it finds there. Without both, nothing about the session of a connection is exercised at all. HttpSessionCopy is deprecated rather than removed. Nothing constructs it any more - ServletRequestCopy was its only caller anywhere - but it is public API. The WebSocketResource javadoc no longer gives the copy as a reason its authorization is decided once per connection. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> --- .../ws/api/AbstractWebSocketProcessor.java | 21 ++ .../wicket/protocol/ws/api/HttpSessionCopy.java | 7 + .../wicket/protocol/ws/api/ServletRequestCopy.java | 37 +- .../wicket/protocol/ws/api/WebSocketResource.java | 8 +- .../ws/api/ServletRequestCopySessionTest.java | 94 ++++++ .../util/tester/WebSocketSessionLifetimeTest.java | 375 +++++++++++++++++++++ .../nativewebsockets/nativewebsockets_5.adoc | 3 + 7 files changed, 536 insertions(+), 9 deletions(-) diff --git a/wicket-native-websocket/wicket-native-websocket-core/src/main/java/org/apache/wicket/protocol/ws/api/AbstractWebSocketProcessor.java b/wicket-native-websocket/wicket-native-websocket-core/src/main/java/org/apache/wicket/protocol/ws/api/AbstractWebSocketProcessor.java index eca72803a2..9fedff4092 100644 --- a/wicket-native-websocket/wicket-native-websocket-core/src/main/java/org/apache/wicket/protocol/ws/api/AbstractWebSocketProcessor.java +++ b/wicket-native-websocket/wicket-native-websocket-core/src/main/java/org/apache/wicket/protocol/ws/api/AbstractWebSocketProcessor.java @@ -94,6 +94,13 @@ public abstract class AbstractWebSocketProcessor implements IWebSocketProcessor */ static final String NOT_AUTHORIZED = "Not authorized"; + /** + * The reason given to a client whose HTTP session has ended. It is sent with close code + * {@value WebSocketConnectionOriginFilter#POLICY_VIOLATION_ERROR_CODE}, the policy violation + * code of RFC 6455, which is the one a rejected connection already uses. + */ + static final String SESSION_ENDED = "Session ended"; + private final WebRequest webRequest; private final int pageId; private final String resourceName; @@ -239,6 +246,16 @@ public abstract class AbstractWebSocketProcessor implements IWebSocketProcessor * the connection is no longer in the registry to be found. The decision is taken once, per * connection; it is not repeated for the messages that follow on an accepted one. * </p> + * <p> + * The session is resolved again for every message, out of the HTTP session the connection + * was opened on. When that session has ended - invalidated by the application, by a filter, + * by a container-managed logout, or by an idle timeout - there is nothing left to resolve, + * and the connection is removed from the registry and closed with + * {@value WebSocketConnectionOriginFilter#POLICY_VIOLATION_ERROR_CODE} rather than carrying + * the identity it was opened with any further. A ClosedMessage, ErrorMessage or + * AbortedMessage is still dropped silently in that case, because closing a connection that + * is already going away says nothing. + * </p> * * @param message * the message to broadcast @@ -279,6 +296,10 @@ public abstract class AbstractWebSocketProcessor implements IWebSocketProcessor { connectionRegistry.removeConnection(application, sessionId, key); LOG.debug("No Session could be found for session id '{}' and key '{}'!", sessionId, key); + if (isSpecialMessage(message) == false) + { + connection.close(WebSocketConnectionOriginFilter.POLICY_VIOLATION_ERROR_CODE, SESSION_ENDED); + } return; } diff --git a/wicket-native-websocket/wicket-native-websocket-core/src/main/java/org/apache/wicket/protocol/ws/api/HttpSessionCopy.java b/wicket-native-websocket/wicket-native-websocket-core/src/main/java/org/apache/wicket/protocol/ws/api/HttpSessionCopy.java index 1b9da35ebb..c0755d7313 100644 --- a/wicket-native-websocket/wicket-native-websocket-core/src/main/java/org/apache/wicket/protocol/ws/api/HttpSessionCopy.java +++ b/wicket-native-websocket/wicket-native-websocket-core/src/main/java/org/apache/wicket/protocol/ws/api/HttpSessionCopy.java @@ -28,7 +28,14 @@ import javax.servlet.http.HttpSessionContext; * A copy of the HttpSession used at the WebSocket connection creation time * * @since 6.0 + * @deprecated Nothing copies the session any more. A request is discarded at the end of the + * request and {@link ServletRequestCopy} therefore copies it; a session is not, and + * a copy of one only detaches the connection from the session it was opened on - it + * does not see that session end, and what it writes never reaches it. + * {@link ServletRequestCopy#getSession(boolean)} hands back the live + * {@link HttpSession} instead. */ +@Deprecated public class HttpSessionCopy implements HttpSession { private final long creationTime; diff --git a/wicket-native-websocket/wicket-native-websocket-core/src/main/java/org/apache/wicket/protocol/ws/api/ServletRequestCopy.java b/wicket-native-websocket/wicket-native-websocket-core/src/main/java/org/apache/wicket/protocol/ws/api/ServletRequestCopy.java index f301c435ad..8d3ae9a863 100644 --- a/wicket-native-websocket/wicket-native-websocket-core/src/main/java/org/apache/wicket/protocol/ws/api/ServletRequestCopy.java +++ b/wicket-native-websocket/wicket-native-websocket-core/src/main/java/org/apache/wicket/protocol/ws/api/ServletRequestCopy.java @@ -47,6 +47,13 @@ import javax.servlet.http.Part; * Copy its details because they are discarded at the end of the request (even if we * use AsyncContext from Servlet 3 spec). This copy is used later for following requests * through the WebSocket connection to construct WebSocketRequest. + * <p> + * The session is the one thing that is <em>not</em> copied. A request is discarded at the + * end of the request; a session is not, it lives for as long as the client keeps it, and + * copying it would detach the connection from it. {@link #getSession(boolean)} hands back + * the live session instead, and {@code null} once that session has ended - so a connection + * stops resolving a {@link org.apache.wicket.Session} the moment the HTTP session it was + * opened on is invalidated, whether by Wicket or by the container. * * @since 6.0 */ @@ -56,7 +63,7 @@ public class ServletRequestCopy implements HttpServletRequest private final String servletPath; private final String pathInfo; private final String requestUri; - private final HttpSessionCopy httpSession; + private final HttpSession httpSession; private final StringBuffer requestURL; private final Map<String, Object> attributes = new HashMap<>(); private final Map<String, Enumeration<String>> headers = new HashMap<>(); @@ -110,8 +117,7 @@ public class ServletRequestCopy implements HttpServletRequest this.requestedSessionId = request.getRequestedSessionId(); this.principal = request.getUserPrincipal(); - HttpSession session = request.getSession(true); - httpSession = new HttpSessionCopy(session); + httpSession = request.getSession(true); String s; Enumeration<String> e = request.getHeaderNames(); @@ -162,9 +168,30 @@ public class ServletRequestCopy implements HttpServletRequest return remoteHost; } + /** + * The session this connection was opened on, or {@code null} once it has ended. + * <p> + * {@code create} is ignored. A WebSocket frame has no response to carry a session cookie + * on, so a session cannot be started from one; the session of a connection is the one that + * existed when it was opened, for as long as that one lasts. + * + * @param create + * ignored + * @return the live {@link HttpSession}, or {@code null} when it has been invalidated + */ @Override public HttpSession getSession(boolean create) { - return httpSession; + try + { + // throws once the session has been invalidated, which is the answer wanted here: + // there is no session on this connection any more + httpSession.getCreationTime(); + return httpSession; + } + catch (IllegalStateException ended) + { + return null; + } } @Override @@ -478,7 +505,7 @@ public class ServletRequestCopy implements HttpServletRequest @Override public HttpSession getSession() { - return httpSession; + return getSession(true); } @Override diff --git a/wicket-native-websocket/wicket-native-websocket-core/src/main/java/org/apache/wicket/protocol/ws/api/WebSocketResource.java b/wicket-native-websocket/wicket-native-websocket-core/src/main/java/org/apache/wicket/protocol/ws/api/WebSocketResource.java index ad3a387c80..99900a6b34 100644 --- a/wicket-native-websocket/wicket-native-websocket-core/src/main/java/org/apache/wicket/protocol/ws/api/WebSocketResource.java +++ b/wicket-native-websocket/wicket-native-websocket-core/src/main/java/org/apache/wicket/protocol/ws/api/WebSocketResource.java @@ -52,10 +52,10 @@ import org.apache.wicket.request.resource.IResource; * The question is put once, when the connection is opened, and is not repeated for the messages * that follow: a connection that was allowed stays allowed for as long as it is open, even if the * privileges of the session change meanwhile. That is deliberate - this resource exists to carry - * many messages cheaply, without the page lock a {@link WebSocketBehavior} takes - and it is what - * this transport can honestly offer, since it resolves its session from a copy made during the - * handshake and does not see a session that was replaced. Close the connection from the - * application when the privileges it was opened with are withdrawn. + * many messages cheaply, without the page lock a {@link WebSocketBehavior} takes, and a registry + * lookup and a strategy call in front of every message would tax exactly that. The connection + * does end with the HTTP session it was opened on, but privileges withdrawn from a session that + * lives on are not noticed: close the connection from the application when that happens. */ public abstract class WebSocketResource implements IResource { diff --git a/wicket-native-websocket/wicket-native-websocket-core/src/test/java/org/apache/wicket/protocol/ws/api/ServletRequestCopySessionTest.java b/wicket-native-websocket/wicket-native-websocket-core/src/test/java/org/apache/wicket/protocol/ws/api/ServletRequestCopySessionTest.java new file mode 100644 index 0000000000..9ebe2cda83 --- /dev/null +++ b/wicket-native-websocket/wicket-native-websocket-core/src/test/java/org/apache/wicket/protocol/ws/api/ServletRequestCopySessionTest.java @@ -0,0 +1,94 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.wicket.protocol.ws.api; + +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; + +import org.apache.wicket.protocol.http.mock.MockHttpServletRequest; +import org.apache.wicket.protocol.http.mock.MockHttpSession; +import org.junit.Test; + +/** + * A container reports an ended session by throwing {@link IllegalStateException}, which + * {@link MockHttpSession} does not do - it only empties itself. Both halves have to lead to the + * same answer, because the copy of the request is what every message of a connection resolves its + * session through. + */ +public class ServletRequestCopySessionTest +{ + /** + * A session that behaves the way the servlet API specifies: once invalidated, the methods that + * are documented to throw, throw. + */ + private static class ContainerLikeHttpSession extends MockHttpSession + { + private static final long serialVersionUID = 1L; + + private boolean valid = true; + + ContainerLikeHttpSession() + { + super(null); + } + + @Override + public void invalidate() + { + super.invalidate(); + valid = false; + } + + @Override + public long getCreationTime() + { + if (valid == false) + { + throw new IllegalStateException("The session has been invalidated"); + } + return super.getCreationTime(); + } + } + + @Test + public void theLiveSessionIsHandedBackWhileItLasts() + { + ContainerLikeHttpSession session = new ContainerLikeHttpSession(); + ServletRequestCopy copy = new ServletRequestCopy( + new MockHttpServletRequest(null, session, null)); + + assertSame(session, copy.getSession(false)); + assertSame(session, copy.getSession(true)); + assertSame(session, copy.getSession()); + } + + @Test + public void thereIsNoSessionOnceTheContainerHasEndedIt() + { + ContainerLikeHttpSession session = new ContainerLikeHttpSession(); + ServletRequestCopy copy = new ServletRequestCopy( + new MockHttpServletRequest(null, session, null)); + + session.invalidate(); + + assertNull("an ended session was handed back to the connection", + copy.getSession(false)); + assertNull("a connection cannot start a session, it has no response", + copy.getSession(true)); + assertNull(copy.getSession()); + } +} diff --git a/wicket-native-websocket/wicket-native-websocket-core/src/test/java/org/apache/wicket/protocol/ws/util/tester/WebSocketSessionLifetimeTest.java b/wicket-native-websocket/wicket-native-websocket-core/src/test/java/org/apache/wicket/protocol/ws/util/tester/WebSocketSessionLifetimeTest.java new file mode 100644 index 0000000000..dc862ee66b --- /dev/null +++ b/wicket-native-websocket/wicket-native-websocket-core/src/test/java/org/apache/wicket/protocol/ws/util/tester/WebSocketSessionLifetimeTest.java @@ -0,0 +1,375 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.wicket.protocol.ws.util.tester; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.util.Collection; +import java.util.Collections; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.wicket.MarkupContainer; +import org.apache.wicket.Session; +import org.apache.wicket.ThreadContext; +import org.apache.wicket.markup.IMarkupResourceStreamProvider; +import org.apache.wicket.markup.html.WebPage; +import org.apache.wicket.mock.MockApplication; +import org.apache.wicket.protocol.http.WebApplication; +import org.apache.wicket.protocol.ws.WebSocketSettings; +import org.apache.wicket.protocol.ws.api.IWebSocketConnection; +import org.apache.wicket.protocol.ws.api.WebSocketBehavior; +import org.apache.wicket.protocol.ws.api.WebSocketRequestHandler; +import org.apache.wicket.protocol.ws.api.WebSocketResource; +import org.apache.wicket.protocol.ws.api.message.TextMessage; +import org.apache.wicket.session.HttpSessionStore; +import org.apache.wicket.util.resource.IResourceStream; +import org.apache.wicket.util.resource.StringResourceStream; +import org.apache.wicket.util.tester.WicketTester; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +/** + * A connection resolves its {@link Session} out of the HTTP session it was opened on, for every + * message, and must stop working when that HTTP session ends - whoever ended it. What it writes to + * the session must reach that same HTTP session and not a copy of it. + */ +public class WebSocketSessionLifetimeTest +{ + private static final String RESOURCE_NAME = "sessionLifetimeResource"; + + private static final String ATTRIBUTE_NAME = "setOverTheConnection"; + + private static final AtomicInteger BEHAVIOR_MESSAGES = new AtomicInteger(); + + private static final AtomicInteger RESOURCE_MESSAGES = new AtomicInteger(); + + /** what the callback of the page or of the resource should do, if anything */ + private static final AtomicReference<Runnable> IN_CALLBACK = new AtomicReference<>(); + + private WicketTester tester; + + /** + * The page the connection is opened for. + */ + public static class WebSocketPage extends WebPage implements IMarkupResourceStreamProvider + { + public WebSocketPage() + { + add(new WebSocketBehavior() + { + @Override + protected void onMessage(WebSocketRequestHandler handler, TextMessage message) + { + BEHAVIOR_MESSAGES.incrementAndGet(); + runInCallback(); + } + }); + } + + @Override + public IResourceStream getMarkupResourceStream(MarkupContainer container, + Class<?> containerClass) + { + return new StringResourceStream("<html><head></head><body></body></html>"); + } + } + + /** + * The shared resource a connection can name instead of a page. Unlike a page it never consults + * the page manager, so the session is the only thing standing between a client and its + * callbacks. + */ + static class CountingWebSocketResource extends WebSocketResource + { + @Override + protected void onMessage(WebSocketRequestHandler handler, TextMessage message) + { + RESOURCE_MESSAGES.incrementAndGet(); + runInCallback(); + } + } + + private static void runInCallback() + { + Runnable runnable = IN_CALLBACK.getAndSet(null); + if (runnable != null) + { + runnable.run(); + } + } + + @Before + public void before() + { + BEHAVIOR_MESSAGES.set(0); + RESOURCE_MESSAGES.set(0); + IN_CALLBACK.set(null); + + tester = new WicketTester(new MockApplication() + { + @Override + protected void init() + { + super.init(); + + // MockApplication installs MockSessionStore, whose lookup() ignores the request + // and hands back the one Session it holds. Nothing would then read the session + // out of the connection's request, which is the whole subject here. + setSessionStoreProvider(HttpSessionStore::new); + + getSharedResources().add(RESOURCE_NAME, new CountingWebSocketResource()); + } + }); + } + + @After + public void after() + { + tester.destroy(); + } + + /** + * Sends a message the way the transport does, rather than the way the tester does. + * <p> + * A frame arrives on a container thread that carries none of Wicket's thread locals, so + * {@code AbstractWebSocketProcessor#broadcastMessage} resolves the session from the request of + * the connection. The tester runs on the thread that served the last HTTP request, which still + * has a {@link Session} in its {@link ThreadContext} that the processor would reuse instead - + * and then nothing about the session of the connection is exercised at all. + */ + private static void sendFromTheContainer(WebSocketTester webSocketTester, String message) + { + ThreadContext threadContext = ThreadContext.detach(); + try + { + webSocketTester.sendMessage(message); + } + finally + { + ThreadContext.restore(threadContext); + } + } + + private Collection<IWebSocketConnection> connectionsOf(String sessionId) + { + WebApplication application = tester.getApplication(); + return WebSocketSettings.Holder.get(application) + .getConnectionRegistry() + .getConnections(application, sessionId); + } + + /** + * @return the name {@link #ATTRIBUTE_NAME} has in the HTTP session, which carries the prefix of + * the application, or {@code null} when it is not there + */ + private String attributeOfTheHttpSession() + { + return Collections.list(tester.getHttpSession().getAttributeNames()) + .stream() + .filter(name -> name.endsWith(ATTRIBUTE_NAME)) + .findFirst() + .orElse(null); + } + + @Test + public void aBehaviorStopsWhenTheContainerEndsTheSession() + { + WebSocketPage page = tester.startPage(WebSocketPage.class); + tester.getSession().bind(); + + WebSocketTester webSocketTester = new WebSocketTester(tester, page); + try + { + sendFromTheContainer(webSocketTester, "while the session lives"); + assertEquals("the behavior must be reachable to start with", + 1, BEHAVIOR_MESSAGES.get()); + + // the container ends the session: an idle timeout, a filter, a container-managed logout + tester.getHttpSession().invalidate(); + + sendFromTheContainer(webSocketTester, "after the session ended"); + assertEquals("the behavior ran on a session that had already been invalidated", + 1, BEHAVIOR_MESSAGES.get()); + } + finally + { + webSocketTester.destroy(); + } + } + + @Test + public void aResourceStopsWhenTheContainerEndsTheSession() + { + tester.startPage(WebSocketPage.class); + tester.getSession().bind(); + + WebSocketTester webSocketTester = new WebSocketTester(tester, RESOURCE_NAME); + try + { + sendFromTheContainer(webSocketTester, "while the session lives"); + assertEquals("the resource must be reachable to start with", + 1, RESOURCE_MESSAGES.get()); + + tester.getHttpSession().invalidate(); + + sendFromTheContainer(webSocketTester, "after the session ended"); + assertEquals("the resource ran on a session that had already been invalidated", + 1, RESOURCE_MESSAGES.get()); + } + finally + { + webSocketTester.destroy(); + } + } + + /** + * A resource never asks the page manager for anything, so clearing it - which is what + * {@link Session#invalidateNow()} does besides ending the session - cannot stop one. Ending the + * session it was opened on is what stops it. + */ + @Test + public void aResourceStopsWhenWicketEndsTheSession() + { + tester.startPage(WebSocketPage.class); + tester.getSession().bind(); + + WebSocketTester webSocketTester = new WebSocketTester(tester, RESOURCE_NAME); + try + { + sendFromTheContainer(webSocketTester, "while the session lives"); + assertEquals("the resource must be reachable to start with", + 1, RESOURCE_MESSAGES.get()); + + tester.getSession().invalidateNow(); + + sendFromTheContainer(webSocketTester, "after the sign out"); + assertEquals("the resource ran after Session#invalidateNow()", + 1, RESOURCE_MESSAGES.get()); + } + finally + { + webSocketTester.destroy(); + } + } + + /** + * A connection whose session has ended is not merely ignored, it is closed - otherwise it stays + * open for as long as the client keeps it, delivering nothing. + */ + @Test + public void theConnectionIsClosedWhenTheSessionHasEnded() + { + WebSocketPage page = tester.startPage(WebSocketPage.class); + tester.getSession().bind(); + + String sessionId = tester.getHttpSession().getId(); + + WebSocketTester webSocketTester = new WebSocketTester(tester, page); + try + { + Collection<IWebSocketConnection> connections = connectionsOf(sessionId); + assertEquals("the connection must be registered to start with", + 1, connections.size()); + IWebSocketConnection connection = connections.iterator().next(); + assertTrue(connection.isOpen()); + + tester.getHttpSession().invalidate(); + sendFromTheContainer(webSocketTester, "after the session ended"); + + assertFalse("the connection was left open", + connection.isOpen()); + assertTrue("the connection was left in the registry", + connectionsOf(sessionId).isEmpty()); + } + finally + { + webSocketTester.destroy(); + } + } + + /** + * The write direction: a sign out issued from inside a callback must end the HTTP session of + * the client, not a copy of it that nobody else can see. + */ + @Test + public void endingTheSessionOverTheConnectionEndsTheHttpSession() + { + WebSocketPage page = tester.startPage(WebSocketPage.class); + tester.getSession().bind(); + + tester.getSession().setAttribute(ATTRIBUTE_NAME, "set before the connection was opened"); + String sessionIdBefore = tester.getHttpSession().getId(); + assertNotNull("the attribute must be in the HTTP session to start with", + attributeOfTheHttpSession()); + + WebSocketTester webSocketTester = new WebSocketTester(tester, page); + try + { + IN_CALLBACK.set(() -> Session.get().invalidateNow()); + sendFromTheContainer(webSocketTester, "sign me out"); + assertEquals(1, BEHAVIOR_MESSAGES.get()); + + // MockHttpSession#invalidate() clears the attributes and gives itself a new id; a copy + // of the session could do neither of those to the session the client still holds + assertNotEquals("the HTTP session of the client survived a sign out made over the connection", + sessionIdBefore, tester.getHttpSession().getId()); + assertNull("an attribute set before the connection was opened survived a sign out made over it", + attributeOfTheHttpSession()); + } + finally + { + webSocketTester.destroy(); + } + } + + /** + * The same write direction, one step smaller: a session attribute set over a connection has to + * be readable by every other request of that session. + */ + @Test + public void aSessionAttributeSetOverTheConnectionReachesTheHttpSession() + { + WebSocketPage page = tester.startPage(WebSocketPage.class); + tester.getSession().bind(); + + WebSocketTester webSocketTester = new WebSocketTester(tester, page); + try + { + IN_CALLBACK.set(() -> Session.get().setAttribute(ATTRIBUTE_NAME, "written over the connection")); + sendFromTheContainer(webSocketTester, "remember this"); + assertEquals(1, BEHAVIOR_MESSAGES.get()); + + String attributeName = attributeOfTheHttpSession(); + + assertNotNull("the attribute was written to a copy of the HTTP session instead of to the session", + attributeName); + assertEquals("written over the connection", + tester.getHttpSession().getAttribute(attributeName)); + } + finally + { + webSocketTester.destroy(); + } + } +} diff --git a/wicket-user-guide/src/main/asciidoc/nativewebsockets/nativewebsockets_5.adoc b/wicket-user-guide/src/main/asciidoc/nativewebsockets/nativewebsockets_5.adoc index 5a1396afd2..36bed439c5 100644 --- a/wicket-user-guide/src/main/asciidoc/nativewebsockets/nativewebsockets_5.adoc +++ b/wicket-user-guide/src/main/asciidoc/nativewebsockets/nativewebsockets_5.adoc @@ -1,3 +1,6 @@ 1. Request and session scoped beans do not work. The Web Socket communication is not processed by Servlet Filters and Listeners and thus the Dependency Injection libraries have no chance to export the request and session bean proxies. + +2. A connection lasts no longer than the HTTP session it was opened on. +Every message resolves its Wicket `Session` out of that HTTP session, so once the session ends - whether the application invalidated it, a filter or a container-managed logout did, or it simply timed out - the connection is closed with code 1008 and no further message reaches the page or the resource. What a callback writes to the session goes to that same HTTP session, so a sign out performed over a connection ends the session of the client as one performed over HTTP does.
