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; + } +}
