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
