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.

Reply via email to