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 a573fc13e0e8532a3551b99b6d062fe22bc0efb5
Author: Lukasz Lenart <[email protected]>
AuthorDate: Sun Oct 18 09:15:05 2026 +0200

    WW-3576 feat(core): run execAndWait background processes against a session 
sandbox
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../interceptor/ExecuteAndWaitInterceptor.java     | 18 ++++-
 .../interceptor/exec/BackgroundProcess.java        |  8 +++
 .../interceptor/exec/StrutsBackgroundProcess.java  | 26 +++++++
 .../interceptor/ExecuteAndWaitInterceptorTest.java | 82 ++++++++++++++++++++++
 .../interceptor/SessionWritingWaitAction.java      | 60 ++++++++++++++++
 5 files changed, 193 insertions(+), 1 deletion(-)

diff --git 
a/core/src/main/java/org/apache/struts2/interceptor/ExecuteAndWaitInterceptor.java
 
b/core/src/main/java/org/apache/struts2/interceptor/ExecuteAndWaitInterceptor.java
index 5a073147f..7572a6d4e 100644
--- 
a/core/src/main/java/org/apache/struts2/interceptor/ExecuteAndWaitInterceptor.java
+++ 
b/core/src/main/java/org/apache/struts2/interceptor/ExecuteAndWaitInterceptor.java
@@ -22,6 +22,7 @@ import org.apache.struts2.action.Action;
 import org.apache.struts2.ActionContext;
 import org.apache.struts2.ActionInvocation;
 import org.apache.struts2.ActionProxy;
+import org.apache.struts2.action.SessionAware;
 import org.apache.struts2.config.entities.ResultConfig;
 import org.apache.struts2.inject.Container;
 import org.apache.struts2.inject.Inject;
@@ -31,6 +32,8 @@ import org.apache.logging.log4j.Logger;
 import org.apache.struts2.ServletActionContext;
 import org.apache.struts2.interceptor.exec.BackgroundProcess;
 import org.apache.struts2.interceptor.exec.ExecutorProvider;
+import org.apache.struts2.interceptor.exec.SandboxedSessionMap;
+import org.apache.struts2.interceptor.exec.SessionSandboxReconciler;
 import org.apache.struts2.interceptor.exec.StrutsBackgroundProcess;
 import org.apache.struts2.interceptor.exec.StrutsExecutorProvider;
 import org.apache.struts2.util.TokenHelper;
@@ -295,7 +298,8 @@ public class ExecuteAndWaitInterceptor extends 
MethodFilterInterceptor {
 
             if ((!executeAfterValidationPass || secondTime) && bp == null) {
                 LOG.trace("BackgroundProcess instance is null (create new 
instance) - executeAfterValidationPass: {}, secondTime: {}.", 
executeAfterValidationPass, secondTime);
-                bp = getNewBackgroundProcess(name, actionInvocation, 
threadPriority).prepare();
+                SandboxedSessionMap sandbox = new 
SandboxedSessionMap(httpSession, name);
+                bp = getNewBackgroundProcess(name, actionInvocation, 
threadPriority).withSessionSandbox(sandbox).prepare();
                 session.put(bp_SessionKey, bp);
                 if (executor == null || executor.isShutdown()) {
                     LOG.warn("Executor is shutting down (or null), cannot 
execute a new process, invoke next ActionInvocation step and return.");
@@ -334,6 +338,7 @@ public class ExecuteAndWaitInterceptor extends 
MethodFilterInterceptor {
                 LOG.trace("BackgroundProcess instance is done (remove key, 
return result) - executeAfterValidationPass: {}, secondTime: {}.", 
executeAfterValidationPass, secondTime);
                 session.remove(bp_SessionKey);
                 actionInvocation.getStack().push(bp.getAction());
+                writeBackSessionSandbox(bp, context);
 
                 // if an exception occurred during action execution, throw it 
here
                 if (bp.getException() != null) {
@@ -352,6 +357,17 @@ public class ExecuteAndWaitInterceptor extends 
MethodFilterInterceptor {
         }
     }
 
+    private void writeBackSessionSandbox(BackgroundProcess bp, ActionContext 
context) {
+        SandboxedSessionMap sandbox = bp.getSessionSandbox();
+        if (sandbox == null) {
+            return;
+        }
+        SessionSandboxReconciler.reconcile(sandbox, 
ServletActionContext.getRequest(), bp.getAction());
+        if (bp.getAction() instanceof SessionAware sessionAware) {
+            sessionAware.withSession(context.getSession());
+        }
+    }
+
     /**
      * <p>
      * Performs the initial delay.
diff --git 
a/core/src/main/java/org/apache/struts2/interceptor/exec/BackgroundProcess.java 
b/core/src/main/java/org/apache/struts2/interceptor/exec/BackgroundProcess.java
index a9ca48d20..7b1392ac8 100644
--- 
a/core/src/main/java/org/apache/struts2/interceptor/exec/BackgroundProcess.java
+++ 
b/core/src/main/java/org/apache/struts2/interceptor/exec/BackgroundProcess.java
@@ -38,4 +38,12 @@ public interface BackgroundProcess extends Runnable {
     Exception getException();
 
     boolean isDone();
+
+    default BackgroundProcess withSessionSandbox(SandboxedSessionMap sandbox) {
+        return this;
+    }
+
+    default SandboxedSessionMap getSessionSandbox() {
+        return null;
+    }
 }
diff --git 
a/core/src/main/java/org/apache/struts2/interceptor/exec/StrutsBackgroundProcess.java
 
b/core/src/main/java/org/apache/struts2/interceptor/exec/StrutsBackgroundProcess.java
index adab43dc6..8c41c3a8a 100644
--- 
a/core/src/main/java/org/apache/struts2/interceptor/exec/StrutsBackgroundProcess.java
+++ 
b/core/src/main/java/org/apache/struts2/interceptor/exec/StrutsBackgroundProcess.java
@@ -22,9 +22,11 @@ import org.apache.struts2.ActionContext;
 import org.apache.struts2.ActionInvocation;
 import org.apache.logging.log4j.LogManager;
 import org.apache.logging.log4j.Logger;
+import org.apache.struts2.action.SessionAware;
 
 import java.io.Serial;
 import java.io.Serializable;
+import java.util.HashMap;
 
 /**
  * Background thread to be executed by the ExecuteAndWaitInterceptor.
@@ -43,6 +45,7 @@ public class StrutsBackgroundProcess implements 
BackgroundProcess, Serializable
     //WW-4900 transient since 2.5.15
     protected transient ActionInvocation invocation;
     protected transient Exception exception;
+    private transient SandboxedSessionMap sessionSandbox;
 
     protected String result;
     protected boolean done;
@@ -66,6 +69,7 @@ public class StrutsBackgroundProcess implements 
BackgroundProcess, Serializable
             processThread = new Thread(() -> {
                 try {
                     beforeInvocation();
+                    installSessionSandbox();
                     result = invocation.invokeActionOnly();
                 } catch (Exception e) {
                     LOG.warn("Exception during invokeActionOnly() execution", 
e);
@@ -91,6 +95,28 @@ public class StrutsBackgroundProcess implements 
BackgroundProcess, Serializable
         return this;
     }
 
+    @Override
+    public BackgroundProcess withSessionSandbox(SandboxedSessionMap sandbox) {
+        this.sessionSandbox = sandbox;
+        return this;
+    }
+
+    @Override
+    public SandboxedSessionMap getSessionSandbox() {
+        return sessionSandbox;
+    }
+
+    private void installSessionSandbox() {
+        if (sessionSandbox == null) {
+            return;
+        }
+        ActionContext.bind(ActionContext.of(new 
HashMap<>(invocation.getInvocationContext().getContextMap()))
+                .withSession(sessionSandbox));
+        if (invocation.getAction() instanceof SessionAware sessionAware) {
+            sessionAware.withSession(sessionSandbox);
+        }
+    }
+
     @Override
     public void run() {
         if (processThread == null) {
diff --git 
a/core/src/test/java/org/apache/struts2/interceptor/ExecuteAndWaitInterceptorTest.java
 
b/core/src/test/java/org/apache/struts2/interceptor/ExecuteAndWaitInterceptorTest.java
index 7701173eb..5216a6564 100644
--- 
a/core/src/test/java/org/apache/struts2/interceptor/ExecuteAndWaitInterceptorTest.java
+++ 
b/core/src/test/java/org/apache/struts2/interceptor/ExecuteAndWaitInterceptorTest.java
@@ -39,10 +39,14 @@ import org.apache.struts2.util.location.LocatableProperties;
 import jakarta.servlet.http.HttpSession;
 import org.apache.struts2.StrutsInternalTestCase;
 import org.apache.struts2.dispatcher.HttpParameters;
+import org.apache.struts2.dispatcher.SessionMap;
+import org.apache.struts2.interceptor.exec.BackgroundProcess;
 import org.apache.struts2.interceptor.exec.ExecutorProvider;
+import org.apache.struts2.interceptor.exec.SandboxedSessionMap;
 import org.apache.struts2.interceptor.parameter.ParametersInterceptor;
 import org.apache.struts2.views.jsp.StrutsMockHttpServletRequest;
 import org.apache.struts2.views.jsp.StrutsMockHttpSession;
+import org.springframework.mock.web.MockHttpSession;
 
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
@@ -67,6 +71,7 @@ public class ExecuteAndWaitInterceptorTest extends 
StrutsInternalTestCase {
     private ParametersInterceptor parametersInterceptor;
     private TokenInterceptor tokenInterceptor;
     private TokenSessionStoreInterceptor tokenSessionInterceptor;
+    private ServletConfigInterceptor servletConfigInterceptor;
 
     public void testOneWait() throws Exception {
         waitInterceptor.setDelay(0);
@@ -275,6 +280,74 @@ public class ExecuteAndWaitInterceptorTest extends 
StrutsInternalTestCase {
         assertNull(session.get(tokenSessionName));
     }
 
+    public void testBackgroundWritesReachTheRealSession() throws Exception {
+        useLiveSession();
+
+        assertEquals("wait", buildProxy("sessionAction").execute());
+        awaitDone("sessionAction");
+
+        assertEquals("success", buildProxy("sessionAction").execute());
+        assertEquals("done", httpSession.getAttribute("result"));
+        assertEquals("yes", httpSession.getAttribute("aware"));
+    }
+
+    public void testBackgroundWritesAreInvisibleUntilPolled() throws Exception 
{
+        useLiveSession();
+
+        assertEquals("wait", buildProxy("sessionAction").execute());
+        awaitDone("sessionAction");
+
+        assertNull(httpSession.getAttribute("result"));
+        assertNull(httpSession.getAttribute("aware"));
+    }
+
+    public void testWritesOfAFailedActionAreApplied() throws Exception {
+        useLiveSession();
+        Map<String, String[]> requestParams = new HashMap<>();
+        requestParams.put("failWith", new String[]{"boom"});
+        withRequestParams(requestParams);
+
+        assertEquals("wait", buildProxy("sessionAction").execute());
+        awaitDone("sessionAction");
+
+        ActionProxy poll = buildProxy("sessionAction");
+        try {
+            poll.execute();
+            fail("the background failure should be rethrown by the polling 
request");
+        } catch (IllegalStateException failure) {
+            assertEquals("boom", failure.getMessage());
+        }
+        assertEquals("done", httpSession.getAttribute("result"));
+    }
+
+    public void testSessionAwareGetsTheLiveMapBackAfterWriteBack() throws 
Exception {
+        useLiveSession();
+
+        assertEquals("wait", buildProxy("sessionAction").execute());
+        SessionWritingWaitAction action = (SessionWritingWaitAction) 
awaitDone("sessionAction").getAction();
+
+        assertEquals("success", buildProxy("sessionAction").execute());
+        assertFalse(action.getSession() instanceof SandboxedSessionMap);
+        assertSame(session, action.getSession());
+    }
+
+    private void useLiveSession() {
+        httpSession = new MockHttpSession();
+        request.setSession(httpSession);
+        session = new SessionMap(request);
+        context = 
ActionContext.of(context).withSession(session).getContextMap();
+    }
+
+    private BackgroundProcess awaitDone(String actionName) throws 
InterruptedException {
+        BackgroundProcess process = (BackgroundProcess) 
httpSession.getAttribute(ExecuteAndWaitInterceptor.KEY + actionName);
+        long deadline = System.currentTimeMillis() + 5000;
+        while (!process.isDone() && System.currentTimeMillis() < deadline) {
+            Thread.sleep(20);
+        }
+        assertTrue("background process did not finish in time", 
process.isDone());
+        return process;
+    }
+
     private void withRequestParams(Map<String, String[]> requestParams) {
         context = 
ActionContext.of(context).withParameters(HttpParameters.create(requestParams).build()).getContextMap();
     }
@@ -306,6 +379,7 @@ public class ExecuteAndWaitInterceptorTest extends 
StrutsInternalTestCase {
         container.inject(parametersInterceptor);
         container.inject(tokenInterceptor);
         container.inject(tokenSessionInterceptor);
+        container.inject(servletConfigInterceptor);
 
         waitInterceptor.init();
         parametersInterceptor.init();
@@ -339,6 +413,7 @@ public class ExecuteAndWaitInterceptorTest extends 
StrutsInternalTestCase {
             parametersInterceptor = new ParametersInterceptor();
             tokenInterceptor = new TokenInterceptor();
             tokenSessionInterceptor = new TokenSessionStoreInterceptor();
+            servletConfigInterceptor = new ServletConfigInterceptor();
             PackageConfig wait = new PackageConfig.Builder("")
                 .addActionConfig("action1", new ActionConfig.Builder("", 
"action1", ExecuteAndWaitDelayAction.class.getName())
                     .addResultConfig(new ResultConfig.Builder(Action.SUCCESS, 
MockResult.class.getName()).build())
@@ -347,6 +422,13 @@ public class ExecuteAndWaitInterceptorTest extends 
StrutsInternalTestCase {
                     .addInterceptor(new InterceptorMapping("execAndWait", 
waitInterceptor))
                     .build())
                 .addActionConfig("plainWait", waitActionConfig("plainWait"))
+                .addActionConfig("sessionAction", new ActionConfig.Builder("", 
"sessionAction", SessionWritingWaitAction.class.getName())
+                    .addResultConfig(new ResultConfig.Builder(Action.SUCCESS, 
MockResult.class.getName()).build())
+                    .addResultConfig(new 
ResultConfig.Builder(ExecuteAndWaitInterceptor.WAIT, 
MockResult.class.getName()).build())
+                    .addInterceptor(new InterceptorMapping("servletConfig", 
servletConfigInterceptor))
+                    .addInterceptor(new InterceptorMapping("params", 
parametersInterceptor))
+                    .addInterceptor(new InterceptorMapping("execAndWait", 
waitInterceptor))
+                    .build())
                 .addActionConfig("tokenWait", waitActionConfig("tokenWait", 
new InterceptorMapping("token", tokenInterceptor)))
                 .addActionConfig("tokenSessionWait", 
waitActionConfig("tokenSessionWait", new InterceptorMapping("tokenSession", 
tokenSessionInterceptor)))
                 .build();
diff --git 
a/core/src/test/java/org/apache/struts2/interceptor/SessionWritingWaitAction.java
 
b/core/src/test/java/org/apache/struts2/interceptor/SessionWritingWaitAction.java
new file mode 100644
index 000000000..4610d3786
--- /dev/null
+++ 
b/core/src/test/java/org/apache/struts2/interceptor/SessionWritingWaitAction.java
@@ -0,0 +1,60 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.struts2.interceptor;
+
+import org.apache.struts2.ActionContext;
+import org.apache.struts2.action.Action;
+import org.apache.struts2.action.SessionAware;
+import org.apache.struts2.interceptor.parameter.StrutsParameter;
+
+import java.util.Map;
+
+/**
+ * Used by ExecuteAndWaitInterceptorTest to write to the session from the 
background thread.
+ */
+public class SessionWritingWaitAction implements Action, SessionAware {
+
+    private Map<String, Object> session;
+    private String failWith;
+
+    @Override
+    public String execute() throws Exception {
+        Thread.sleep(200);
+        ActionContext.getContext().getSession().put("result", "done");
+        session.put("aware", "yes");
+        if (failWith != null) {
+            throw new IllegalStateException(failWith);
+        }
+        return SUCCESS;
+    }
+
+    @Override
+    public void withSession(Map<String, Object> session) {
+        this.session = session;
+    }
+
+    public Map<String, Object> getSession() {
+        return session;
+    }
+
+    @StrutsParameter
+    public void setFailWith(String failWith) {
+        this.failWith = failWith;
+    }
+}

Reply via email to