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 52a21921e18fc8d96e7b4752f44960cbd4fc252e
Author: Lukasz Lenart <[email protected]>
AuthorDate: Sun Oct 18 10:34:27 2026 +0200

    WW-3576 fix(core): copy the background context on the request thread
    
    The background process's context map is copied when the sandbox is handed
    over, on the request thread, instead of on the background thread while the
    request may still be rendering the wait page. An OgnlContext is copied as an
    OgnlContext. A context bound by an overridden beforeInvocation() is honoured
    and copied on the background thread. done is volatile, since it gates the
    write-back.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../interceptor/exec/StrutsBackgroundProcess.java  | 30 +++++++++--
 .../exec/StrutsBackgroundProcessTest.java          | 58 ++++++++++++++++++++++
 2 files changed, 85 insertions(+), 3 deletions(-)

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 6ad0f2967..5bf7e6a11 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
@@ -18,6 +18,8 @@
  */
 package org.apache.struts2.interceptor.exec;
 
+import ognl.Ognl;
+import ognl.OgnlContext;
 import org.apache.struts2.ActionContext;
 import org.apache.struts2.ActionInvocation;
 import org.apache.logging.log4j.LogManager;
@@ -27,6 +29,7 @@ import org.apache.struts2.action.SessionAware;
 import java.io.Serial;
 import java.io.Serializable;
 import java.util.HashMap;
+import java.util.Map;
 
 /**
  * Background thread to be executed by the ExecuteAndWaitInterceptor.
@@ -46,9 +49,10 @@ public class StrutsBackgroundProcess implements 
BackgroundProcess, Serializable
     protected transient ActionInvocation invocation;
     protected transient Exception exception;
     private transient SandboxedSessionMap sessionSandbox;
+    private transient Map<String, Object> sandboxContext;
 
     protected String result;
-    protected boolean done;
+    protected volatile boolean done;
 
     /**
      * Constructs a background process
@@ -98,6 +102,7 @@ public class StrutsBackgroundProcess implements 
BackgroundProcess, Serializable
     @Override
     public BackgroundProcess withSessionSandbox(SandboxedSessionMap sandbox) {
         this.sessionSandbox = sandbox;
+        this.sandboxContext = 
copyOf(invocation.getInvocationContext().getContextMap());
         return this;
     }
 
@@ -117,13 +122,32 @@ public class StrutsBackgroundProcess implements 
BackgroundProcess, Serializable
         if (sessionSandbox == null) {
             return;
         }
-        ActionContext.bind(ActionContext.of(new 
HashMap<>(invocation.getInvocationContext().getContextMap()))
-                .withSession(sessionSandbox));
+        
ActionContext.bind(ActionContext.of(backgroundContextMap()).withSession(sessionSandbox));
         if (invocation.getAction() instanceof SessionAware sessionAware) {
             sessionAware.withSession(sessionSandbox);
         }
     }
 
+    private Map<String, Object> backgroundContextMap() {
+        ActionContext bound = ActionContext.getContext();
+        if (bound == null || bound.getContextMap() == 
invocation.getInvocationContext().getContextMap()) {
+            return sandboxContext;
+        }
+        return copyOf(bound.getContextMap());
+    }
+
+    private static Map<String, Object> copyOf(Map<String, Object> context) {
+        if (context instanceof OgnlContext ognlContext) {
+            OgnlContext copy = 
Ognl.createDefaultContext(ognlContext.getRoot(), ognlContext.getMemberAccess(),
+                    ognlContext.getClassResolver(), 
ognlContext.getTypeConverter());
+            copy.setTraceEvaluations(ognlContext.isTraceEvaluations());
+            copy.setKeepLastEvaluation(ognlContext.isKeepLastEvaluation());
+            copy.putAll(ognlContext.getValues());
+            return copy;
+        }
+        return new HashMap<>(context);
+    }
+
     @Override
     public void run() {
         if (processThread == null) {
diff --git 
a/core/src/test/java/org/apache/struts2/interceptor/exec/StrutsBackgroundProcessTest.java
 
b/core/src/test/java/org/apache/struts2/interceptor/exec/StrutsBackgroundProcessTest.java
index ad3409bd1..39c30db57 100644
--- 
a/core/src/test/java/org/apache/struts2/interceptor/exec/StrutsBackgroundProcessTest.java
+++ 
b/core/src/test/java/org/apache/struts2/interceptor/exec/StrutsBackgroundProcessTest.java
@@ -24,7 +24,9 @@ import java.io.ObjectInputStream;
 import java.io.ObjectOutputStream;
 import java.security.SecureRandom;
 import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.Random;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
@@ -34,9 +36,14 @@ import java.util.concurrent.Semaphore;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
 
+import ognl.OgnlContext;
 import org.apache.struts2.StrutsInternalTestCase;
+import org.apache.struts2.util.ValueStack;
+import org.apache.struts2.util.ValueStackFactory;
 import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpSession;
 
 import org.apache.struts2.ActionContext;
 import org.apache.struts2.ActionInvocation;
@@ -161,6 +168,57 @@ public class StrutsBackgroundProcessTest extends 
StrutsInternalTestCase {
         assertEquals("Background thread Unprepared has not been prepared!", 
bp.getException().getMessage());
     }
 
+    public void testSandboxContextIsCopiedAtHandoverAndStaysAnOgnlContext() {
+        ValueStack stack = 
container.getInstance(ValueStackFactory.class).createValueStack();
+        ActionContext requestContext = 
ActionContext.of(stack.getContext()).withSession(new HashMap<>());
+        AtomicReference<Map<String, Object>> seen = new AtomicReference<>();
+        MockActionInvocationWithActionInvoker invocation = new 
MockActionInvocationWithActionInvoker(() -> {
+            seen.set(ActionContext.getContext().getContextMap());
+            return "done";
+        });
+        invocation.setInvocationContext(requestContext);
+        SandboxedSessionMap sandbox = new SandboxedSessionMap(new 
MockHttpSession(), "copy");
+
+        BackgroundProcess bp = new StrutsBackgroundProcess(invocation, "copy", 
Thread.NORM_PRIORITY)
+                .withSessionSandbox(sandbox)
+                .prepare();
+        requestContext.put("afterHandover", "x");
+        executor.execute(bp);
+        await().atMost(5, TimeUnit.SECONDS).until(bp::isDone);
+
+        assertNull(bp.getException());
+        Map<String, Object> background = seen.get();
+        assertTrue(background instanceof OgnlContext);
+        assertNotSame(requestContext.getContextMap(), background);
+        assertFalse(background.containsKey("afterHandover"));
+        assertSame(sandbox, ActionContext.of(background).getSession());
+        assertSame(stack, ActionContext.of(background).getValueStack());
+        assertNotSame(sandbox, requestContext.getSession());
+    }
+
+    public void testSandboxHonoursTheContextBoundByBeforeInvocation() {
+        AtomicReference<Map<String, Object>> seen = new AtomicReference<>();
+        MockActionInvocationWithActionInvoker invocation = new 
MockActionInvocationWithActionInvoker(() -> {
+            seen.set(ActionContext.getContext().getContextMap());
+            return "done";
+        });
+        invocation.setInvocationContext(ActionContext.of());
+        SandboxedSessionMap sandbox = new SandboxedSessionMap(new 
MockHttpSession(), "custom");
+
+        BackgroundProcess bp = new StrutsBackgroundProcess(invocation, 
"custom", Thread.NORM_PRIORITY) {
+            @Override
+            protected void beforeInvocation() {
+                ActionContext.bind(ActionContext.of().with("custom", "yes"));
+            }
+        }.withSessionSandbox(sandbox).prepare();
+        executor.execute(bp);
+        await().atMost(5, TimeUnit.SECONDS).until(bp::isDone);
+
+        assertNull(bp.getException());
+        assertEquals("yes", seen.get().get("custom"));
+        assertSame(sandbox, ActionContext.of(seen.get()).getSession());
+    }
+
     private static class MockActionInvocationWithActionInvoker extends 
MockActionInvocation {
         private final Callable<String> actionInvoker;
 

Reply via email to