This is an automated email from the ASF dual-hosted git repository.

lukaszlenart pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/struts.git


The following commit(s) were added to refs/heads/main by this push:
     new 432ed91a6 WW-3576 Keep SessionMap in step with the HttpSession (#2042)
432ed91a6 is described below

commit 432ed91a6e09d82facf3d5cf70df38a23795a538
Author: Lukasz Lenart <[email protected]>
AuthorDate: Thu Oct 22 09:22:25 2026 +0200

    WW-3576 Keep SessionMap in step with the HttpSession (#2042)
    
    SessionMap reads its pinned session once per call, treats an invalidated
    session as absent, drops the entries cache and the interned-id lock, and
    every request's maps are detached at request end.
    StrutsBackgroundProcess.done is volatile.
---
 .../struts2/dispatcher/PrepareOperations.java      |  15 ++
 .../org/apache/struts2/dispatcher/SessionMap.java  | 194 ++++++++++++++------
 .../interceptor/exec/StrutsBackgroundProcess.java  |   2 +-
 .../struts2/dispatcher/PrepareOperationsTest.java  | 119 +++++++++++++
 .../apache/struts2/dispatcher/SessionMapTest.java  | 190 ++++++++++++++++++++
 .../interceptor/CreateSessionInterceptorTest.java  |   3 +
 .../debugging/DebuggingInterceptorTest.java        |  12 +-
 .../2026-10-18-WW-3576-session-map-staleness.md    | 195 +++++++++++++++++++++
 ...6-10-18-WW-3576-session-map-staleness-design.md | 107 +++++++++++
 9 files changed, 777 insertions(+), 60 deletions(-)

diff --git 
a/core/src/main/java/org/apache/struts2/dispatcher/PrepareOperations.java 
b/core/src/main/java/org/apache/struts2/dispatcher/PrepareOperations.java
index eb37b4812..2cc8871a1 100644
--- a/core/src/main/java/org/apache/struts2/dispatcher/PrepareOperations.java
+++ b/core/src/main/java/org/apache/struts2/dispatcher/PrepareOperations.java
@@ -75,6 +75,7 @@ public class PrepareOperations {
             try {
                 dispatcher.cleanUpRequest(request);
             } finally {
+                detachSessionMaps(request);
                 ActionContext.clear();
                 Dispatcher.clearInstance();
                 ContainerHolder.clear();
@@ -83,6 +84,20 @@ public class PrepareOperations {
         });
     }
 
+    private static void detachSessionMaps(HttpServletRequest request) {
+        if (!request.isAsyncStarted()) {
+            SessionMap.detachAll(request);
+            detachSessionMap();
+        }
+    }
+
+    private static void detachSessionMap() {
+        ActionContext context = ActionContext.getContext();
+        if (context != null && context.getSession() instanceof SessionMap 
sessionMap) {
+            sessionMap.detach();
+        }
+    }
+
     /**
      * Creates the action context and initializes the thread local
      *
diff --git a/core/src/main/java/org/apache/struts2/dispatcher/SessionMap.java 
b/core/src/main/java/org/apache/struts2/dispatcher/SessionMap.java
index 76af042be..72f767287 100644
--- a/core/src/main/java/org/apache/struts2/dispatcher/SessionMap.java
+++ b/core/src/main/java/org/apache/struts2/dispatcher/SessionMap.java
@@ -28,6 +28,8 @@ import java.util.Collections;
 import java.util.Enumeration;
 import java.util.HashSet;
 import java.util.Set;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.Queue;
 
 /**
  * A simple implementation of the {@link java.util.Map} interface to handle a 
collection of HTTP session
@@ -39,10 +41,19 @@ public class SessionMap extends AbstractMap<String, Object> 
implements Serializa
     @Serial
     private static final long serialVersionUID = 4678843241638046854L;
 
-    protected HttpSession session;
+    private static final String REQUEST_MAPS = SessionMap.class.getName() + 
".maps";
+
+    // volatile only publishes the pinned reference (HttpSession is 
thread-safe per the Servlet spec);
+    // non-transient as before, so subclass serialization is unchanged
+    @SuppressWarnings({"java:S3077", "java:S1948"})
+    protected volatile HttpSession session;
+    /**
+     * @deprecated no longer read or written; {@link #entrySet()} builds a 
fresh set on every call.
+     */
+    @Deprecated(since = "7.5.0", forRemoval = true)
     protected Set<Entry<String, Object>> entries;
     protected HttpServletRequest request;
-
+    private volatile boolean detached;
 
     /**
      * Creates a new session map given a http servlet request. Note, the 
enumeration of request
@@ -56,76 +67,109 @@ public class SessionMap extends AbstractMap<String, 
Object> implements Serializa
         // "execAndWait" interceptor
         this.request = request;
         this.session = request.getSession(false);
+        register(request);
+    }
+
+    void detach() {
+        detached = true;
+    }
+
+    private void register(HttpServletRequest request) {
+        RequestMaps registered = request.getAttribute(REQUEST_MAPS) instanceof 
RequestMaps existing ? existing : null;
+        if (registered == null) {
+            registered = new RequestMaps();
+            request.setAttribute(REQUEST_MAPS, registered);
+        }
+        registered.maps.add(this);
+    }
+
+    static void detachAll(HttpServletRequest request) {
+        if (request.getAttribute(REQUEST_MAPS) instanceof RequestMaps 
registered) {
+            registered.maps.forEach(SessionMap::detach);
+            request.removeAttribute(REQUEST_MAPS);
+        }
+    }
+
+    private static final class RequestMaps {
+        private final Queue<SessionMap> maps = new ConcurrentLinkedQueue<>();
+
+        @Override
+        public String toString() {
+            return "RequestMaps";
+        }
     }
 
     /**
      * Invalidate the http session.
      */
     public void invalidate() {
-        if (session == null) {
+        HttpSession current = session;
+        if (current == null) {
             return;
         }
 
-        synchronized (session.getId().intern()) {
-            session.invalidate();
-            session = null;
-            entries = null;
+        try {
+            current.invalidate();
+        } catch (IllegalStateException e) {
+            // already invalidated elsewhere
         }
+        unpin(current);
     }
 
     /**
-     * Removes all attributes from the session as well as clears entries in 
this
-     * map.
+     * Removes all attributes from the session.
      */
     @Override
     public void clear() {
-        if (session == null) {
+        HttpSession current = session;
+        if (current == null) {
             return;
         }
 
-        synchronized (session.getId().intern()) {
-            entries = null;
-            final Enumeration<String> attributeNamesEnum = 
session.getAttributeNames();
+        try {
+            final Enumeration<String> attributeNamesEnum = 
current.getAttributeNames();
             while (attributeNamesEnum.hasMoreElements()) {
-                session.removeAttribute(attributeNamesEnum.nextElement());
+                current.removeAttribute(attributeNamesEnum.nextElement());
             }
+        } catch (IllegalStateException e) {
+            unpin(current);
         }
-
     }
 
     /**
-     * Returns a Set of attributes from the http session.
+     * Returns a Set of attributes from the http session, built fresh on every 
call.
      *
      * @return a Set of attributes from the http session.
      */
     @Override
     public Set<Entry<String, Object>> entrySet() {
-        if (session == null) {
+        HttpSession current = session;
+        if (current == null) {
             return Collections.emptySet();
         }
 
-        synchronized (session.getId().intern()) {
-            if (entries == null) {
-                entries = new HashSet<>();
+        final Set<Entry<String, Object>> result = new HashSet<>();
+        try {
+            final Enumeration<String> enumeration = 
current.getAttributeNames();
 
-                final Enumeration<String> enumeration = 
session.getAttributeNames();
+            while (enumeration.hasMoreElements()) {
+                final String key = enumeration.nextElement();
+                final Object value = current.getAttribute(key);
+                result.add(new StringObjectEntry(key, value) {
+                    @Override
+                    public Object setValue(final Object obj) {
+                        current.setAttribute(key, obj);
 
-                while (enumeration.hasMoreElements()) {
-                    final String key = enumeration.nextElement();
-                    final Object value = session.getAttribute(key);
-                    entries.add(new StringObjectEntry(key, value) {
-                        @Override
-                        public Object setValue(final Object obj) {
-                            session.setAttribute(key, obj);
-
-                            return value;
-                        }
-                    });
-                }
+                        return value;
+                    }
+                });
             }
+        } catch (IllegalStateException e) {
+            unpin(current);
+            return Collections.emptySet();
         }
 
-        return entries;
+        return result;
     }
 
     /**
@@ -139,12 +183,16 @@ public class SessionMap extends AbstractMap<String, 
Object> implements Serializa
      */
     @Override
     public Object get(final Object key) {
-        if (session == null) {
+        HttpSession current = session;
+        if (current == null) {
             return null;
         }
 
-        synchronized (session.getId().intern()) {
-            return session.getAttribute(key != null ? key.toString() : null);
+        try {
+            return current.getAttribute(key != null ? key.toString() : null);
+        } catch (IllegalStateException e) {
+            unpin(current);
+            return null;
         }
     }
 
@@ -157,16 +205,42 @@ public class SessionMap extends AbstractMap<String, 
Object> implements Serializa
      */
     @Override
     public Object put(final String key, final Object value) {
+        HttpSession current = session;
+        if (current == null) {
+            current = pin();
+        }
+
+        final Object oldValue = get(key);
+        try {
+            current.setAttribute(key, value);
+        } catch (IllegalStateException e) {
+            unpin(current);
+            pin().setAttribute(key, value);
+            return null;
+        }
+        return oldValue;
+    }
+
+    private HttpSession pin() {
         synchronized (this) {
-            if (session == null) {
-                session = request.getSession(true);
+            HttpSession current = session;
+            if (current == null) {
+                if (detached) {
+                    throw new IllegalStateException("The request of this 
session map has ended and its session is gone; "
+                        + "it cannot resolve a new session");
+                }
+                current = request.getSession(true);
+                session = current;
             }
+            return current;
         }
-        synchronized (session.getId().intern()) {
-            final Object oldValue = get(key);
-            entries = null;
-            session.setAttribute(key, value);
-            return oldValue;
+    }
+
+    private void unpin(final HttpSession stale) {
+        synchronized (this) {
+            if (session == stale) {
+                session = null;
+            }
         }
     }
 
@@ -181,19 +255,21 @@ public class SessionMap extends AbstractMap<String, 
Object> implements Serializa
      */
     @Override
     public Object remove(final Object key) {
-        if (session == null) {
+        HttpSession current = session;
+        if (current == null) {
             return null;
         }
 
-        synchronized (session.getId().intern()) {
-            entries = null;
-
-            final String keyAsString = (key != null ? key.toString() : null);
-            final Object value = get(keyAsString);
-            session.removeAttribute(keyAsString);
-
-            return value;
+        final String keyAsString = (key != null ? key.toString() : null);
+        final Object value = get(keyAsString);
+        try {
+            current.removeAttribute(keyAsString);
+        } catch (IllegalStateException e) {
+            unpin(current);
+            return null;
         }
+
+        return value;
     }
 
 
@@ -208,13 +284,17 @@ public class SessionMap extends AbstractMap<String, 
Object> implements Serializa
      */
     @Override
     public boolean containsKey(final Object key) {
-        if (session == null) {
+        HttpSession current = session;
+        if (current == null) {
             return false;
         }
 
-        synchronized (session.getId().intern()) {
-            final String keyAsString = (key != null ? key.toString() : null);
-            return (session.getAttribute(keyAsString) != null);
+        final String keyAsString = (key != null ? key.toString() : null);
+        try {
+            return current.getAttribute(keyAsString) != null;
+        } catch (IllegalStateException e) {
+            unpin(current);
+            return false;
         }
     }
 }
diff --git 
a/core/src/main/java/org/apache/struts2/interceptor/exec/StrutsBackgroundProcess.java
 
b/core/src/main/java/org/apache/struts2/interceptor/exec/StrutsBackgroundProcess.java
index adab43dc6..f83a67de4 100644
--- 
a/core/src/main/java/org/apache/struts2/interceptor/exec/StrutsBackgroundProcess.java
+++ 
b/core/src/main/java/org/apache/struts2/interceptor/exec/StrutsBackgroundProcess.java
@@ -45,7 +45,7 @@ public class StrutsBackgroundProcess implements 
BackgroundProcess, Serializable
     protected transient Exception exception;
 
     protected String result;
-    protected boolean done;
+    protected volatile boolean done;
 
     /**
      * Constructs a background process
diff --git 
a/core/src/test/java/org/apache/struts2/dispatcher/PrepareOperationsTest.java 
b/core/src/test/java/org/apache/struts2/dispatcher/PrepareOperationsTest.java
index abc060c4c..2b320cbe0 100644
--- 
a/core/src/test/java/org/apache/struts2/dispatcher/PrepareOperationsTest.java
+++ 
b/core/src/test/java/org/apache/struts2/dispatcher/PrepareOperationsTest.java
@@ -28,6 +28,8 @@ import jakarta.servlet.ServletException;
 import jakarta.servlet.http.HttpServletRequest;
 import java.util.stream.IntStream;
 
+import static org.junit.Assert.assertThrows;
+
 public class PrepareOperationsTest extends StrutsInternalTestCase {
     public void testCreateActionContextWhenRequestHasOne() {
         HttpServletRequest req = new MockHttpServletRequest();
@@ -60,6 +62,123 @@ public class PrepareOperationsTest extends 
StrutsInternalTestCase {
         assertNull(ContainerHolder.get());
     }
 
+    public void testRequestCleanupDetachesTheContextSessionMap() {
+        MockHttpServletRequest req = new MockHttpServletRequest();
+        SessionMap sessionMap = new SessionMap(req);
+        ActionContext.getContext().withSession(sessionMap);
+        PrepareOperations prepare = new PrepareOperations(dispatcher);
+        prepare.trackRecursion(req);
+
+        prepare.cleanupRequest(req);
+
+        assertThrows(IllegalStateException.class, () -> sessionMap.put("key", 
"value"));
+        assertNull(req.getSession(false));
+    }
+
+    public void 
testRequestCleanupDetachesTheHelperSessionMapAfterCreateSession() {
+        MockHttpServletRequest req = new MockHttpServletRequest();
+        SessionMap original = new SessionMap(req);
+        ActionContext context = ActionContext.getContext()
+            .withSession(original)
+            .with(DispatcherConstants.SESSION, original)
+            .withSession(new SessionMap(req));
+        PrepareOperations prepare = new PrepareOperations(dispatcher);
+        prepare.trackRecursion(req);
+
+        prepare.cleanupRequest(req);
+
+        SessionMap helper = (SessionMap) 
context.get(DispatcherConstants.SESSION);
+        assertThrows(IllegalStateException.class, () -> helper.put("key", 
"value"));
+        assertNull(req.getSession(false));
+    }
+
+    public void testRequestCleanupDetachesEveryRequestSessionMap() {
+        MockHttpServletRequest req = new MockHttpServletRequest();
+        SessionMap first = new SessionMap(req);
+        SessionMap second = new SessionMap(req);
+        PrepareOperations prepare = new PrepareOperations(dispatcher);
+        prepare.trackRecursion(req);
+
+        prepare.cleanupRequest(req);
+
+        assertThrows(IllegalStateException.class, () -> first.put("key", 
"value"));
+        assertThrows(IllegalStateException.class, () -> second.put("key", 
"value"));
+        assertNull(req.getSession(false));
+    }
+
+    public void testRequestCleanupKeepsSessionMapsAttachedWhileAsyncStarted() {
+        MockHttpServletRequest req = new MockHttpServletRequest();
+        req.setAsyncStarted(true);
+        SessionMap sessionMap = new SessionMap(req);
+        PrepareOperations prepare = new PrepareOperations(dispatcher);
+        prepare.trackRecursion(req);
+
+        prepare.cleanupRequest(req);
+
+        sessionMap.put("key", "value");
+        assertNotNull(req.getSession(false));
+    }
+
+    public void testAsyncDispatchCleanupDetachesTheSessionMapsKeptAttached() {
+        MockHttpServletRequest req = new MockHttpServletRequest();
+        req.setAsyncStarted(true);
+        SessionMap sessionMap = new SessionMap(req);
+        PrepareOperations prepare = new PrepareOperations(dispatcher);
+        prepare.trackRecursion(req);
+        prepare.cleanupRequest(req);
+        req.setAsyncStarted(false);
+        prepare.trackRecursion(req);
+
+        prepare.cleanupRequest(req);
+
+        assertThrows(IllegalStateException.class, () -> sessionMap.put("key", 
"value"));
+        assertNull(req.getSession(false));
+    }
+
+    public void 
testRequestCleanupKeepsTheBoundSessionMapAttachedWhileAsyncStarted() {
+        MockHttpServletRequest req = new MockHttpServletRequest();
+        req.setAsyncStarted(true);
+        SessionMap sessionMap = new SessionMap(req);
+        ActionContext.getContext().withSession(sessionMap);
+        PrepareOperations prepare = new PrepareOperations(dispatcher);
+        prepare.trackRecursion(req);
+
+        prepare.cleanupRequest(req);
+
+        sessionMap.put("key", "value");
+        assertNotNull(req.getSession(false));
+    }
+
+    public void testBoundSessionMapIsDetachedByTheAsyncDispatchCleanup() {
+        MockHttpServletRequest req = new MockHttpServletRequest();
+        req.setAsyncStarted(true);
+        SessionMap sessionMap = new SessionMap(req);
+        ActionContext.getContext().withSession(sessionMap);
+        PrepareOperations prepare = new PrepareOperations(dispatcher);
+        prepare.trackRecursion(req);
+        prepare.cleanupRequest(req);
+        req.setAsyncStarted(false);
+        ActionContext.of().withSession(sessionMap).bind();
+        prepare.trackRecursion(req);
+
+        prepare.cleanupRequest(req);
+
+        assertThrows(IllegalStateException.class, () -> sessionMap.put("key", 
"value"));
+        assertNull(req.getSession(false));
+    }
+
+    public void testRequestCleanupWithoutActionContext() {
+        HttpServletRequest req = new MockHttpServletRequest();
+        PrepareOperations prepare = new PrepareOperations(dispatcher);
+        prepare.trackRecursion(req);
+        ActionContext.clear();
+
+        prepare.cleanupRequest(req);
+
+        assertNull(Dispatcher.getInstance());
+        assertNull(ContainerHolder.get());
+    }
+
     public void testWrappedRequestCleanup() {
         HttpServletRequest req = new MockHttpServletRequest();
         PrepareOperations prepare = new PrepareOperations(dispatcher);
diff --git 
a/core/src/test/java/org/apache/struts2/dispatcher/SessionMapTest.java 
b/core/src/test/java/org/apache/struts2/dispatcher/SessionMapTest.java
index b15f8a308..a925ca74d 100644
--- a/core/src/test/java/org/apache/struts2/dispatcher/SessionMapTest.java
+++ b/core/src/test/java/org/apache/struts2/dispatcher/SessionMapTest.java
@@ -24,11 +24,15 @@ import java.util.Enumeration;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
 
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpSession;
 
 import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpSession;
 
 import junit.framework.TestCase;
 
@@ -38,6 +42,8 @@ import com.mockobjects.constraint.IsEqual;
 import com.mockobjects.dynamic.Mock;
 import java.util.AbstractMap;
 
+import static org.awaitility.Awaitility.await;
+
 
 /**
  */
@@ -277,12 +283,196 @@ public class SessionMapTest extends TestCase {
         sessionMock.verify();
     }
 
+    public void testGetConcurrentWithInvalidateNeverThrows() throws Exception {
+        CountDownLatch invalidating = new CountDownLatch(1);
+        CountDownLatch release = new CountDownLatch(1);
+        MockHttpSession session = new MockHttpSession() {
+            @Override
+            public void invalidate() {
+                invalidating.countDown();
+                try { release.await(); } catch (InterruptedException e) { 
Thread.currentThread().interrupt(); }
+                super.invalidate();
+            }
+        };
+        session.setAttribute("key", "value");
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setSession(session);
+        SessionMap sessionMap = new SessionMap(request);
+        AtomicReference<Throwable> failure = new AtomicReference<>();
+        AtomicReference<Object> read = new AtomicReference<>();
+
+        Thread invalidator = new Thread(sessionMap::invalidate);
+        invalidator.start();
+        invalidating.await();
+        Thread reader = new Thread(() -> {
+            try { read.set(sessionMap.get("key")); } catch (Throwable t) { 
failure.set(t); }
+        });
+        reader.start();
+        await().atMost(5, TimeUnit.SECONDS)
+                .until(() -> reader.getState() == Thread.State.BLOCKED || 
reader.getState() == Thread.State.TERMINATED);
+        release.countDown();
+        invalidator.join(5000);
+        reader.join(5000);
+
+        assertFalse("reader did not finish", reader.isAlive());
+        assertNull("get() concurrent with invalidate() threw: " + 
failure.get(), failure.get());
+        assertEquals("value", read.get());
+    }
+
+    private MockHttpSession invalidatedSession;
+    private MockHttpServletRequest invalidatedRequest;
+
+    private SessionMap sessionMapOverInvalidatedSession() {
+        invalidatedRequest = new MockHttpServletRequest();
+        invalidatedSession = new MockHttpSession();
+        invalidatedSession.setAttribute("key", "value");
+        invalidatedRequest.setSession(invalidatedSession);
+        SessionMap sessionMap = new SessionMap(invalidatedRequest);
+        invalidatedSession.invalidate();
+        return sessionMap;
+    }
+
+    private void assertPutLandsInNewSession(SessionMap sessionMap) {
+        sessionMap.put("other", "fresh");
+
+        HttpSession current = invalidatedRequest.getSession(false);
+        assertNotNull(current);
+        assertNotSame(invalidatedSession, current);
+        assertEquals("fresh", current.getAttribute("other"));
+    }
+
+    public void testGetOnSessionInvalidatedElsewhere() {
+        SessionMap sessionMap = sessionMapOverInvalidatedSession();
+        assertNull(sessionMap.get("key"));
+        assertPutLandsInNewSession(sessionMap);
+    }
+
+    public void testContainsKeyOnSessionInvalidatedElsewhere() {
+        SessionMap sessionMap = sessionMapOverInvalidatedSession();
+        assertFalse(sessionMap.containsKey("key"));
+        assertPutLandsInNewSession(sessionMap);
+    }
+
+    public void testEntrySetOnSessionInvalidatedElsewhere() {
+        SessionMap sessionMap = sessionMapOverInvalidatedSession();
+        assertTrue(sessionMap.entrySet().isEmpty());
+        assertPutLandsInNewSession(sessionMap);
+    }
+
+    public void testRemoveOnSessionInvalidatedElsewhere() {
+        SessionMap sessionMap = sessionMapOverInvalidatedSession();
+        assertNull(sessionMap.remove("key"));
+        assertPutLandsInNewSession(sessionMap);
+    }
+
+    public void testClearOnSessionInvalidatedElsewhere() {
+        SessionMap sessionMap = sessionMapOverInvalidatedSession();
+        sessionMap.clear();
+        assertPutLandsInNewSession(sessionMap);
+    }
+
+    public void testInvalidateOnSessionInvalidatedElsewhere() {
+        SessionMap sessionMap = sessionMapOverInvalidatedSession();
+        sessionMap.invalidate();
+        assertPutLandsInNewSession(sessionMap);
+    }
+
+    public void testPutAfterInvalidationElsewhereUsesANewSession() {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        MockHttpSession session = new MockHttpSession();
+        request.setSession(session);
+        SessionMap sessionMap = new SessionMap(request);
+
+        session.invalidate();
+        sessionMap.put("key", "value");
+
+        HttpSession current = request.getSession(false);
+        assertNotNull(current);
+        assertNotSame(session, current);
+        assertEquals("value", current.getAttribute("key"));
+    }
+
+    public void testAttributeSetDirectlyIsVisibleThroughEntrySet() {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        MockHttpSession session = new MockHttpSession();
+        request.setSession(session);
+        SessionMap sessionMap = new SessionMap(request);
+        assertTrue(sessionMap.entrySet().isEmpty());
+
+        session.setAttribute("key", "value");
+
+        assertEquals(1, sessionMap.entrySet().size());
+    }
+
+    public void testPinnedSessionNeverTouchesTheRequestAgain() {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setSession(new MockHttpSession());
+        SessionMap sessionMap = new SessionMap(request);
+        request.close();
+
+        sessionMap.put("key", "value");
+        assertEquals("value", sessionMap.get("key"));
+        assertEquals(1, sessionMap.entrySet().size());
+        assertEquals("value", sessionMap.remove("key"));
+    }
+
+    public void 
testDetachedPutAfterInvalidationNeverReachesTheRecycledRequest() {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        MockHttpSession pinned = new MockHttpSession();
+        request.setSession(pinned);
+        SessionMap sessionMap = new SessionMap(request);
+        pinned.invalidate();
+        MockHttpSession otherUsers = new MockHttpSession();
+        request.setSession(otherUsers);
+        sessionMap.detach();
+
+        try {
+            sessionMap.put("key", "value");
+            fail("a detached map with no live session must not resolve a 
session through its request");
+        } catch (IllegalStateException expected) {
+            assertFalse(expected.getMessage().contains("value"));
+        }
+        assertFalse(otherUsers.getAttributeNames().hasMoreElements());
+        assertSame(otherUsers, request.getSession(false));
+    }
+
+    public void testDetachedNeverPinnedPutThrows() {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        SessionMap sessionMap = new SessionMap(request);
+        sessionMap.detach();
+
+        try {
+            sessionMap.put("key", "value");
+            fail("a detached map with no pinned session must not create one 
through its request");
+        } catch (IllegalStateException expected) {
+            assertNull(request.getSession(false));
+        }
+    }
+
+    public void testDetachedMapKeepsWorkingOnItsLivePinnedSession() {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        MockHttpSession pinned = new MockHttpSession();
+        request.setSession(pinned);
+        SessionMap sessionMap = new SessionMap(request);
+        sessionMap.detach();
+
+        sessionMap.put("key", "value");
+
+        assertEquals("value", pinned.getAttribute("key"));
+        assertEquals("value", sessionMap.get("key"));
+        assertTrue(sessionMap.containsKey("key"));
+        assertEquals(1, sessionMap.entrySet().size());
+        assertEquals("value", sessionMap.remove("key"));
+    }
+
     @Override
     protected void setUp() throws Exception {
         sessionMock = new Mock(HttpSession.class);
         sessionMock.matchAndReturn("getId", "1");
         requestMock = new Mock(HttpServletRequest.class);
         requestMock.matchAndReturn("getSession", new Constraint[]{new 
IsEqual(Boolean.FALSE)}, sessionMock.proxy());
+        requestMock.matchAndReturn("getAttribute", new Constraint[]{new 
IsAnything()}, null);
+        requestMock.matchAndReturn("setAttribute", new Constraint[]{new 
IsAnything(), new IsAnything()}, null);
     }
 
 
diff --git 
a/core/src/test/java/org/apache/struts2/interceptor/CreateSessionInterceptorTest.java
 
b/core/src/test/java/org/apache/struts2/interceptor/CreateSessionInterceptorTest.java
index 43a098a0d..4e1deae85 100644
--- 
a/core/src/test/java/org/apache/struts2/interceptor/CreateSessionInterceptorTest.java
+++ 
b/core/src/test/java/org/apache/struts2/interceptor/CreateSessionInterceptorTest.java
@@ -25,6 +25,7 @@ import org.apache.struts2.StrutsInternalTestCase;
 import org.jmock.Mock;
 import org.jmock.core.constraint.IsEqual;
 import org.jmock.core.matcher.InvokeOnceMatcher;
+import org.jmock.core.stub.ReturnStub;
 
 import jakarta.servlet.http.HttpServletRequest;
 
@@ -48,6 +49,8 @@ public class CreateSessionInterceptorTest extends 
StrutsInternalTestCase {
         httpServletRequestMock.expects(new 
InvokeOnceMatcher()).method("getSession").with(new IsEqual(Boolean.FALSE));
         httpServletRequestMock.expects(new 
InvokeOnceMatcher()).method("getSession").with(new IsEqual(Boolean.TRUE));
         httpServletRequestMock.expects(new 
InvokeOnceMatcher()).method("getSession").with(new IsEqual(Boolean.FALSE));
+        httpServletRequestMock.stubs().method("getAttribute").will(new 
ReturnStub(null));
+        httpServletRequestMock.stubs().method("setAttribute");
         HttpServletRequest request = (HttpServletRequest) 
httpServletRequestMock.proxy();
 
         ServletActionContext.setRequest(request);
diff --git 
a/core/src/test/java/org/apache/struts2/interceptor/debugging/DebuggingInterceptorTest.java
 
b/core/src/test/java/org/apache/struts2/interceptor/debugging/DebuggingInterceptorTest.java
index 01977f1ce..b9caaa6a5 100644
--- 
a/core/src/test/java/org/apache/struts2/interceptor/debugging/DebuggingInterceptorTest.java
+++ 
b/core/src/test/java/org/apache/struts2/interceptor/debugging/DebuggingInterceptorTest.java
@@ -68,7 +68,11 @@ public class DebuggingInterceptorTest extends 
StrutsJUnit4InternalTestCase {
                 <debug>
                   <parameters/>
                   <context/>
-                  <request/>
+                  <request>
+                    <org.apache.struts2.dispatcher.SessionMap.maps>
+                      <class>class 
org.apache.struts2.dispatcher.SessionMap$RequestMaps</class>
+                    </org.apache.struts2.dispatcher.SessionMap.maps>
+                  </request>
                   <session/>
                   <valueStack>
                     <value>
@@ -171,7 +175,11 @@ public class DebuggingInterceptorTest extends 
StrutsJUnit4InternalTestCase {
                     &amp;lt;debug&amp;gt;
                   &amp;lt;parameters/&amp;gt;
                   &amp;lt;context/&amp;gt;
-                  &amp;lt;request/&amp;gt;
+                  &amp;lt;request&amp;gt;
+                    
&amp;lt;org.apache.struts2.dispatcher.SessionMap.maps&amp;gt;
+                      &amp;lt;class&amp;gt;class 
org.apache.struts2.dispatcher.SessionMap$RequestMaps&amp;lt;/class&amp;gt;
+                    
&amp;lt;/org.apache.struts2.dispatcher.SessionMap.maps&amp;gt;
+                  &amp;lt;/request&amp;gt;
                   &amp;lt;session/&amp;gt;
                   &amp;lt;valueStack&amp;gt;
                     &amp;lt;value&amp;gt;
diff --git a/docs/superpowers/plans/2026-10-18-WW-3576-session-map-staleness.md 
b/docs/superpowers/plans/2026-10-18-WW-3576-session-map-staleness.md
new file mode 100644
index 000000000..ac855a53f
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-18-WW-3576-session-map-staleness.md
@@ -0,0 +1,195 @@
+# WW-3576 SessionMap Staleness — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use 
superpowers:subagent-driven-development (recommended) or 
superpowers:executing-plans to implement this plan task-by-task. Steps use 
checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Stop `SessionMap` from going stale (NPE race, 
`IllegalStateException` after invalidation).
+
+**Architecture:** The request `SessionMap` pins its session, reads it once per 
call into a local, treats `IllegalStateException` as "invalidated → no 
session", and drops the `entries` cache and the interned-id lock.
+
+**Tech Stack:** Java 17, Jakarta Servlet 6, JUnit 3 
(`junit.framework.TestCase` / `StrutsInternalTestCase`), Spring 
`MockHttpServletRequest`/`MockHttpSession`, AssertJ, Log4j2 via 
`org.apache.struts2.test.LogCapture`.
+
+**Spec:** 
`docs/superpowers/specs/2026-10-18-WW-3576-session-map-staleness-design.md`
+
+## Global Constraints
+
+- Branch `feature/WW-3576-session-map-staleness`; every commit message starts 
`WW-3576 `; stage explicit paths only (never `git add -A`), never `git stash`.
+- Tests are JUnit 3 in every file touched here: `testXxx()` names, no `@Test`.
+- Build with `./mvnw` (no `mvn` on PATH); check Maven's exit code, not a grep 
of its output.
+- Deprecations are `@Deprecated(since = "7.5.0", forRemoval = true)`; no 
reference in source to a ticket that is not filed.
+- No null guards on required beans; no parameter reassignment; no boolean flag 
arguments; default to no comments.
+- Never log session attribute values.
+
+## Review Focus
+
+- **Map used after its request ended** (async-plugin `Callable`, stored 
`SessionAware` field): with a session pinned, no call may touch the request — 
Task 2 `testPinnedSessionNeverTouchesTheRequestAgain` (closed 
`MockHttpServletRequest`).
+
+---
+
+### Task 1: Deterministic NPE reproduction and race-free `SessionMap`
+
+**Files:**
+- Modify: `core/src/main/java/org/apache/struts2/dispatcher/SessionMap.java`
+- Test: `core/src/test/java/org/apache/struts2/dispatcher/SessionMapTest.java`
+
+**Interfaces:**
+- Produces: `SessionMap` with the same public API; `protected volatile 
HttpSession session` stays the pinned field.
+
+- [ ] **Step 1: Write the failing test** (in `SessionMapTest`)
+
+```java
+public void testGetConcurrentWithInvalidateNeverThrows() throws Exception {
+    CountDownLatch invalidating = new CountDownLatch(1);
+    CountDownLatch release = new CountDownLatch(1);
+    MockHttpSession session = new MockHttpSession() {
+        @Override
+        public void invalidate() {
+            invalidating.countDown();
+            try { release.await(); } catch (InterruptedException e) { 
Thread.currentThread().interrupt(); }
+            super.invalidate();
+        }
+    };
+    session.setAttribute("key", "value");
+    MockHttpServletRequest request = new MockHttpServletRequest();
+    request.setSession(session);
+    SessionMap sessionMap = new SessionMap(request);
+    AtomicReference<Throwable> failure = new AtomicReference<>();
+
+    Thread invalidator = new Thread(sessionMap::invalidate);
+    invalidator.start();
+    invalidating.await();
+    Thread reader = new Thread(() -> {
+        try { sessionMap.get("key"); } catch (Throwable t) { failure.set(t); }
+    });
+    reader.start();
+    long deadline = System.currentTimeMillis() + 5000;
+    while (reader.getState() != Thread.State.BLOCKED && reader.getState() != 
Thread.State.TERMINATED) {
+        assertTrue("reader neither blocked nor finished", 
System.currentTimeMillis() < deadline);
+        Thread.sleep(1);
+    }
+    release.countDown();
+    invalidator.join(5000);
+    reader.join(5000);
+
+    assertNull("get() concurrent with invalidate() threw", failure.get());
+}
+```
+
+- [ ] **Step 2: Run it and watch it fail on current code**
+
+Run: `./mvnw -q test -DskipAssembly -pl core 
-Dtest=SessionMapTest#testGetConcurrentWithInvalidateNeverThrows 
-Dsurefire.failIfNoSpecifiedTests=false; echo exit=$?`
+Expected: `exit=1`, failure message `get() concurrent with invalidate() threw` 
caused by `NullPointerException ... because "this.session" is null`. Run it 3 
times; it must fail every time.
+
+- [ ] **Step 3: Make every method read `session` once into a local and drop 
the interned-id lock**
+
+`session` becomes `protected volatile HttpSession session`. Each method: 
`HttpSession current = session; if (current == null) return <empty>;` then 
operate on `current` only. Remove every `synchronized (… getId().intern())` 
block. `put` keeps `synchronized (this)` only around pinning the session from 
`request.getSession(true)` when none is pinned, and obtains the old value 
through the overridable `get(key)` (pinned by `MockSessionMap`). `invalidate()` 
sets `session = null` after `current [...]
+
+- [ ] **Step 4: Run `SessionMapTest` and verify it passes**
+
+Run: `./mvnw -q test -DskipAssembly -pl core -Dtest=SessionMapTest 
-Dsurefire.failIfNoSpecifiedTests=false; echo exit=$?`
+Expected: `exit=0`, the new test plus the 12 existing ones pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add core/src/main/java/org/apache/struts2/dispatcher/SessionMap.java 
core/src/test/java/org/apache/struts2/dispatcher/SessionMapTest.java
+git commit -m "WW-3576 fix(core): read SessionMap's session once per call and 
drop the interned-id lock"
+```
+
+### Task 2: Invalidation detection and no `entries` cache
+
+**Files:**
+- Modify: `core/src/main/java/org/apache/struts2/dispatcher/SessionMap.java`
+- Test: `core/src/test/java/org/apache/struts2/dispatcher/SessionMapTest.java`
+
+**Interfaces:**
+- Consumes: Task 1's pinned `session` field.
+- Produces: `entries` field deprecated and unread; un-pinning on 
`IllegalStateException`.
+
+- [ ] **Step 1: Write the failing tests**
+
+```java
+public void testSessionInvalidatedElsewhereReadsAsNoSession() {
+    MockHttpServletRequest request = new MockHttpServletRequest();
+    MockHttpSession session = new MockHttpSession();
+    session.setAttribute("key", "value");
+    request.setSession(session);
+    SessionMap sessionMap = new SessionMap(request);
+
+    session.invalidate();
+
+    assertNull(sessionMap.get("key"));
+    assertFalse(sessionMap.containsKey("key"));
+    assertTrue(sessionMap.entrySet().isEmpty());
+    assertNull(sessionMap.remove("key"));
+    sessionMap.clear();
+    sessionMap.invalidate();
+}
+
+public void testPutAfterInvalidationElsewhereUsesANewSession() {
+    MockHttpServletRequest request = new MockHttpServletRequest();
+    MockHttpSession session = new MockHttpSession();
+    request.setSession(session);
+    SessionMap sessionMap = new SessionMap(request);
+
+    session.invalidate();
+    sessionMap.put("key", "value");
+
+    HttpSession current = request.getSession(false);
+    assertNotNull(current);
+    assertNotSame(session, current);
+    assertEquals("value", current.getAttribute("key"));
+}
+
+public void testAttributeSetDirectlyIsVisibleThroughEntrySet() {
+    MockHttpServletRequest request = new MockHttpServletRequest();
+    MockHttpSession session = new MockHttpSession();
+    request.setSession(session);
+    SessionMap sessionMap = new SessionMap(request);
+    assertTrue(sessionMap.entrySet().isEmpty());
+
+    session.setAttribute("key", "value");
+
+    assertEquals(1, sessionMap.entrySet().size());
+}
+
+public void testPinnedSessionNeverTouchesTheRequestAgain() {
+    MockHttpServletRequest request = new MockHttpServletRequest();
+    request.setSession(new MockHttpSession());
+    SessionMap sessionMap = new SessionMap(request);
+    request.close();
+
+    sessionMap.put("key", "value");
+    assertEquals("value", sessionMap.get("key"));
+    assertEquals(1, sessionMap.entrySet().size());
+    assertEquals("value", sessionMap.remove("key"));
+}
+```
+
+- [ ] **Step 2: Run them and verify the first three fail**
+
+Run: `./mvnw -q test -DskipAssembly -pl core -Dtest=SessionMapTest 
-Dsurefire.failIfNoSpecifiedTests=false; echo exit=$?`
+Expected: `exit=1`; `testSessionInvalidatedElsewhereReadsAsNoSession` errors 
with `IllegalStateException`, 
`testPutAfterInvalidationElsewhereUsesANewSession` errors with 
`IllegalStateException`, `testAttributeSetDirectlyIsVisibleThroughEntrySet` 
fails on the cached empty set. `testPinnedSessionNeverTouchesTheRequestAgain` 
already passes (it guards Task 1/2 against re-resolving through the request).
+
+- [ ] **Step 3: Implement detection and remove the cache**
+
+Each session call is wrapped so an `IllegalStateException` un-pins (`session = 
null` only if it still holds the same reference) and returns the "no session" 
answer (`null` / `false` / `Collections.emptySet()` / no-op). `put` on an 
un-pinned map goes through the same `request.getSession(true)` path as a first 
`put`. `entrySet()` builds a fresh `HashSet` on every call; `setValue` writes 
to the session captured when the set was built. `entries` becomes 
`@Deprecated(since = "7.5.0", forRemov [...]
+
+- [ ] **Step 4: Run the tests and verify they pass**
+
+Run: `./mvnw -q test -DskipAssembly -pl core 
-Dtest='SessionMapTest,AttributeMapTest,DispatcherTest' 
-Dsurefire.failIfNoSpecifiedTests=false; echo exit=$?`
+Expected: `exit=0`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add core/src/main/java/org/apache/struts2/dispatcher/SessionMap.java 
core/src/test/java/org/apache/struts2/dispatcher/SessionMapTest.java
+git commit -m "WW-3576 fix(core): treat an invalidated session as absent and 
stop caching SessionMap entries"
+```
+
+The `execAndWait` session sandbox (former Tasks 3–6) was split out to 
[WW-5787](https://issues.apache.org/jira/browse/WW-5787) (8.0.0) after review.
+
+### Task 7: Reactor build and hand-off items
+
+- [ ] **Step 1: Full reactor test-compile** — `./mvnw -q test-compile 
-DskipAssembly > …/scratchpad/reactor.log 2>&1; echo exit=$?`, expected 
`exit=0` (the async plugin and other modules still compile against 
`SessionMap`).
+- [ ] **Step 2: Ask Łukasz** before filing the 8.0.0 Task to remove the 
deprecated `SessionMap.entries` field.
+- [ ] **Step 3: Record owed docs in memory** — 7.5.0 migration notes 
(subclass-visible `SessionMap` changes) and the struts-site `execAndWait` page.
diff --git 
a/docs/superpowers/specs/2026-10-18-WW-3576-session-map-staleness-design.md 
b/docs/superpowers/specs/2026-10-18-WW-3576-session-map-staleness-design.md
new file mode 100644
index 000000000..87bb488ce
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-18-WW-3576-session-map-staleness-design.md
@@ -0,0 +1,107 @@
+# WW-3576 — SessionMap staleness
+
+Ticket: [WW-3576](https://issues.apache.org/jira/browse/WW-3576) — fix version 
7.5.0.
+Previous attempt: PR #1577 (closed 2026-10-17; changed the lock to `this` and 
swallowed `IllegalStateException`).
+
+## Intent
+
+`SessionMap` is a wrapper around `HttpSession`, but it caches state that 
belongs to the container: the
+`session` reference and the `entries` snapshot. Any operation on the real 
session — invalidation by
+another request, attributes set directly on `HttpSession` — makes the wrapper 
outdated. The ticket's
+two symptoms are both staleness:
+
+1. **NPE race (confirmed on `main`).** Every method does `if (session == null) 
return;` then
+   `synchronized (session.getId().intern()) { session.… }`. The field is 
re-read inside the lock; a
+   concurrent `invalidate()` on the same map nulls it in between. Realistic 
trigger: `execAndWait`,
+   where the background thread binds the *same* `ActionContext` (and therefore 
the same `SessionMap`)
+   as the request that started it (`StrutsBackgroundProcess#beforeInvocation`).
+2. **`IllegalStateException` "already invalidated"** (the reporter's 
stress-test symptom). Another
+   request invalidated the `HttpSession`; this request's `SessionMap` still 
holds the dead reference.
+
+Fix the staleness, not the locking: one map that caches nothing but the 
session it pinned and notices
+when that session dies.
+
+## Goals
+
+- Request-thread `SessionMap` always reflects the pinned session's live 
attributes; an invalidated
+  session reads as "no session" instead of throwing.
+- No map resolves a session through a request that has already ended.
+
+## Non-goals
+
+- Making the shared `ActionContext` safe for `execAndWait` background threads, 
including isolating
+  their session writes. A session sandbox with controlled write-back was 
designed and built on this
+  ticket, then split out: it changes observable `execAndWait` behaviour, so it 
moves to the 8.0.0
+  rewrite, [WW-5787](https://issues.apache.org/jira/browse/WW-5787).
+
+## Anti-goals
+
+- Catching `IllegalStateException` and carrying on as if the session were 
alive (PR #1577 swallowed it in
+  every method and kept the dead reference). Catching it to *detect* 
invalidation, un-pin, and answer
+  "no session" is the design — including a `put` that discovers the 
invalidation, un-pins, and pins a
+  new session once through the normal `request.getSession(true)` path (no 
loop).
+- Any request-thread map that resolves its session through a request after 
that request has ended.
+
+## Design
+
+### Request-scoped `SessionMap`
+
+- **Pins the session it first sees (decided)**, as today: 
`request.getSession(false)` at construction,
+  `request.getSession(true)` on the first `put` when none is pinned. It does 
**not** re-resolve through
+  the request on every call — code holding the map past its request 
(async-plugin `Callable`s, stored
+  `SessionAware` fields) must never resolve a session through a request that 
has already ended.
+- **Invalidation is detected, not hidden:** an `IllegalStateException` from 
the pinned session means it
+  was invalidated (by another request or directly); the map un-pins it and 
answers as "no session"
+  (`null` / `false` / empty set / no-op). A later `put` obtains a session 
through
+  `request.getSession(true)` — **only while the request is live**.
+- **Detached at request end (decided after the final review):** the request 
cleanup in the dispatcher
+  detaches every `SessionMap` built for that request (each registers itself on 
the request at
+  construction), not only the bound context's: this covers the original map 
left behind when
+  `createSession` replaces `ActionContext.SESSION`, and the maps of a nested 
forward. While the request
+  is in async mode, detach (of all request maps, including the bound 
context's) is deferred to the
+  async dispatch's cleanup. A detached map never calls its request again: with 
a live pinned session it
+  keeps working (async `Callable`s, stored `SessionAware` fields); a `put` 
with no live pinned session
+  throws `IllegalStateException`. Reason: a background thread (`execAndWait`, 
custom
+  `BackgroundProcess`) must not resolve a session through a request that has 
already ended; this applies
+  equally to a never-pinned map used after its request ended. Detach is 
package-private.
+- The pinned reference is read once per call into a local (no 
check-then-reread), so a concurrent
+  `invalidate()` on the same map cannot NPE; how it is published (`volatile`) 
is the builder's.
+- **No `entries` cache:** `entrySet()` is built fresh on every call; an 
entry's `setValue` writes
+  straight through to the pinned session.
+- `invalidate()` invalidates the pinned session, if any, and un-pins it.
+- `put`/`remove` keep obtaining the old value through the overridable 
`get(key)` —
+  `SessionMapTest.MockSessionMap` pins that subclass contract.
+- **No locking at all.** Single attribute calls are thread-safe per the 
Servlet spec. `put`/`remove`
+  return an advisory old value; nothing in the framework relies on 
cross-request atomicity
+  (decided). The `session.getId().intern()` monitor is removed — it was a lock 
on the JVM-wide string
+  pool.
+- `protected Set<…> entries` becomes `@Deprecated(since = "7.5.0", forRemoval 
= true)` and is no
+  longer read by `SessionMap`; file a WW Task at 8.0.0 for its removal (do not 
reference it in source
+  before it exists). Whether `protected HttpSession session` stays the pinned 
field or is deprecated
+  behind a private one is the builder's, provided subclass access keeps 
compiling in 7.5.0. Changed
+  subclass-visible behaviour goes in the 7.5.0 migration notes.
+- `StrutsBackgroundProcess.done` becomes `volatile`: the polling request reads 
the flag the background
+  thread writes.
+
+## Testing
+
+Match the target file's JUnit style: `SessionMapTest` extends 
`junit.framework.TestCase` (JUnit 3 —
+`testXxx()` names, no `@Test`).
+
+1. **RED first — deterministic NPE reproduction** on current `main`: one 
`SessionMap`, thread A in
+   `invalidate()` held by a latch inside `HttpSession#invalidate`, thread B 
calling `get()`; on current
+   code B blocks on the monitor and then NPEs. The assertion is behavioural 
("`get()` concurrent with
+   `invalidate()` returns a value or null and never throws"), so it passes on 
the new code where B does
+   not block. No sleep-based stress tests.
+2. **Reporter's case:** two maps over the same session; the session is 
invalidated through the other
+   one (or directly); `get`/`containsKey`/`entrySet`/`remove` return empty 
results, no
+   `IllegalStateException`; `put` creates a new session only because the 
request has none.
+3. Staleness: attribute set directly on `HttpSession` is visible through an 
existing map and its
+   `entrySet()`.
+4. Existing `SessionMapTest`, `ExecuteAndWaitInterceptorTest` and the tests 
constructing
+   `new SessionMap(request)` keep passing.
+
+## Left to the builder
+
+- Whether `CreateSessionInterceptor` is simplified in this change.
+- Debug log wording.

Reply via email to