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 8b1b440e89c038d32b440b49115b8ec0ea4f597f
Author: Lukasz Lenart <[email protected]>
AuthorDate: Sun Oct 18 12:33:38 2026 +0200

    WW-3576 fix(core): keep request SessionMaps attached while async is started
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../struts2/dispatcher/PrepareOperations.java      |  4 ++-
 .../struts2/dispatcher/PrepareOperationsTest.java  | 29 ++++++++++++++++++++++
 ...6-10-18-WW-3576-session-map-staleness-design.md |  3 ++-
 3 files changed, 34 insertions(+), 2 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 d9398e301..4d07c211a 100644
--- a/core/src/main/java/org/apache/struts2/dispatcher/PrepareOperations.java
+++ b/core/src/main/java/org/apache/struts2/dispatcher/PrepareOperations.java
@@ -75,7 +75,9 @@ public class PrepareOperations {
             try {
                 dispatcher.cleanUpRequest(request);
             } finally {
-                SessionMap.detachAll(request);
+                if (!request.isAsyncStarted()) {
+                    SessionMap.detachAll(request);
+                }
                 detachSessionMap();
                 ActionContext.clear();
                 Dispatcher.clearInstance();
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 b01b87cad..105017f0e 100644
--- 
a/core/src/test/java/org/apache/struts2/dispatcher/PrepareOperationsTest.java
+++ 
b/core/src/test/java/org/apache/struts2/dispatcher/PrepareOperationsTest.java
@@ -106,6 +106,35 @@ public class PrepareOperationsTest extends 
StrutsInternalTestCase {
         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 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 2fc7a9de0..fbaaaebd4 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,8 @@ 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. A detached map never calls its request again: with a live
+  `createSession` replaces `ActionContext.SESSION`, and the maps of a nested 
forward. Detach 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
   `BackgroundProcess`) reaching `request.getSession(true)` after the request 
was recycled would write

Reply via email to