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

lukaszlenart pushed a commit to branch feature/WW-3576-session-map-staleness
in repository https://gitbox.apache.org/repos/asf/struts.git

commit d313368985f2962494bbe16378f365a791b14ef5
Author: Lukasz Lenart <[email protected]>
AuthorDate: Sun Oct 18 12:37:46 2026 +0200

    WW-3576 fix(core): keep the bound SessionMap attached while async is started
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../struts2/dispatcher/PrepareOperations.java      | 12 +++++---
 .../struts2/dispatcher/PrepareOperationsTest.java  | 32 ++++++++++++++++++++++
 ...6-10-18-WW-3576-session-map-staleness-design.md |  2 +-
 3 files changed, 41 insertions(+), 5 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 4d07c211a..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,10 +75,7 @@ public class PrepareOperations {
             try {
                 dispatcher.cleanUpRequest(request);
             } finally {
-                if (!request.isAsyncStarted()) {
-                    SessionMap.detachAll(request);
-                }
-                detachSessionMap();
+                detachSessionMaps(request);
                 ActionContext.clear();
                 Dispatcher.clearInstance();
                 ContainerHolder.clear();
@@ -87,6 +84,13 @@ 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) {
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 105017f0e..2b320cbe0 100644
--- 
a/core/src/test/java/org/apache/struts2/dispatcher/PrepareOperationsTest.java
+++ 
b/core/src/test/java/org/apache/struts2/dispatcher/PrepareOperationsTest.java
@@ -135,6 +135,38 @@ public class PrepareOperationsTest extends 
StrutsInternalTestCase {
         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);
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
index fbaaaebd4..0e79e03cd 100644
--- 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
@@ -65,7 +65,7 @@ when that session dies (request threads), and one map that is 
stale *by design*
 - **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. Detach is deferred while the request is in async mode, until the
+  `createSession` replaces `ActionContext.SESSION`, and the maps of a nested 
forward. Detach (of all request maps, including the bound context's) is 
deferred while the request is in async mode, until 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 
(sandbox escape, custom

Reply via email to