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
