You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@struts.apache.org by lu...@apache.org on 2026/10/18 10:51:18 UTC

(struts) 20/22: WW-3576 fix(core): keep the bound SessionMap attached while async is started

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 <lu...@apache.org>
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 <no...@anthropic.com>
---
 .../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