From 6fffc71e45f238c1c100afa1f43091f2338beae0 Mon Sep 17 00:00:00 2001
From: Lukasz Lenart
Date: Sun, 23 Aug 2026 20:18:44 +0200
Subject: [PATCH] WW-1742 docs(execAndWait): document token-scoped background
process naming
The request was for the framework to ship a TokenizedExecuteAndWaitInterceptor
so that several browser tabs of one session can run the same action in the
background. The hook that makes this possible already exists - WW-1740 added
getBackgroundProcessName(ActionProxy) - so the capability is reachable in a
few lines; what was missing is that nobody wrote it down.
Document the override in the interceptor's "extending" snippet, including the
two caveats that make shipping it as the default a bad trade: session entries
are only reclaimed when a request observes the process as done, so a per-token
key grows unboundedly with abandoned runs, and a wait page that does not
propagate the token starts a new background process on every refresh.
Add a test covering both keyings: the action-name default shares one process
across tabs, the documented override gives each tab its own.
Co-Authored-By: Claude Opus 5
---
.../ExecuteAndWaitInterceptor.java | 28 +++
...ecuteAndWaitInterceptorTokenScopeTest.java | 163 ++++++++++++++++++
2 files changed, 191 insertions(+)
create mode 100644 core/src/test/java/org/apache/struts2/interceptor/ExecuteAndWaitInterceptorTokenScopeTest.java
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 5ab03f15a7..6fe8ed5848 100644
--- a/core/src/main/java/org/apache/struts2/interceptor/ExecuteAndWaitInterceptor.java
+++ b/core/src/main/java/org/apache/struts2/interceptor/ExecuteAndWaitInterceptor.java
@@ -111,6 +111,34 @@
* for obtaining and releasing resources that the background process will need to execute successfully. To use your
* background process extension, extend ExecuteAndWaitInterceptor and implement the getNewBackgroundProcess() method.
*
+ *
+ *
+ * The background process is keyed by action name alone, so within one session a given action can only run once at a
+ * time - a second browser tab joins the process already running instead of starting its own. Override
+ * {@link #getBackgroundProcessName(ActionProxy)} to widen that key, for example with the transaction token, so that
+ * each tab gets its own process:
+ *
+ *
+ *
+ * public class TokenizedExecuteAndWaitInterceptor extends ExecuteAndWaitInterceptor {
+ * @Override
+ * protected String getBackgroundProcessName(ActionProxy proxy) {
+ * String token = TokenHelper.getToken();
+ * return token == null
+ * ? super.getBackgroundProcessName(proxy)
+ * : super.getBackgroundProcessName(proxy) + "_" + token;
+ * }
+ * }
+ *
+ *
+ *
+ * Two caveats apply to any key that varies per request. First, the entry is dropped from the session only when a
+ * request observes the process as done, so a per-tab or per-token key strands one background process - and the action
+ * instance it holds - in the session for every run the user abandons; unlike the action-name key, that growth is
+ * unbounded. Second, the wait page must carry the value used in the key on every refresh (for instance
+ * <s:url includeParams="all"/> together with the token interceptor); if it does not, each refresh starts another
+ * background process rather than joining the one already running.
+ *
*
*
* Example code:
diff --git a/core/src/test/java/org/apache/struts2/interceptor/ExecuteAndWaitInterceptorTokenScopeTest.java b/core/src/test/java/org/apache/struts2/interceptor/ExecuteAndWaitInterceptorTokenScopeTest.java
new file mode 100644
index 0000000000..e298cb84d4
--- /dev/null
+++ b/core/src/test/java/org/apache/struts2/interceptor/ExecuteAndWaitInterceptorTokenScopeTest.java
@@ -0,0 +1,163 @@
+/*
+ * 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 jakarta.servlet.http.HttpSession;
+import org.apache.struts2.ActionContext;
+import org.apache.struts2.ActionProxy;
+import org.apache.struts2.ActionProxyFactory;
+import org.apache.struts2.DefaultActionProxyFactory;
+import org.apache.struts2.ObjectFactory;
+import org.apache.struts2.StrutsInternalTestCase;
+import org.apache.struts2.action.Action;
+import org.apache.struts2.config.Configuration;
+import org.apache.struts2.config.ConfigurationException;
+import org.apache.struts2.config.ConfigurationProvider;
+import org.apache.struts2.config.entities.ActionConfig;
+import org.apache.struts2.config.entities.InterceptorMapping;
+import org.apache.struts2.config.entities.PackageConfig;
+import org.apache.struts2.config.entities.ResultConfig;
+import org.apache.struts2.dispatcher.HttpParameters;
+import org.apache.struts2.inject.ContainerBuilder;
+import org.apache.struts2.mock.MockResult;
+import org.apache.struts2.ognl.OgnlUtil;
+import org.apache.struts2.util.TokenHelper;
+import org.apache.struts2.util.location.LocatableProperties;
+import org.apache.struts2.views.jsp.StrutsMockHttpServletRequest;
+import org.apache.struts2.views.jsp.StrutsMockHttpSession;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Covers how the background process is keyed within a single session: by action name only, and by
+ * action name plus transaction token when {@link ExecuteAndWaitInterceptor#getBackgroundProcessName}
+ * is overridden as described in that interceptor's javadoc.
+ */
+public class ExecuteAndWaitInterceptorTokenScopeTest extends StrutsInternalTestCase {
+
+ private StrutsMockHttpServletRequest request;
+ private Map session;
+ private ExecuteAndWaitInterceptor waitInterceptor;
+
+ /** Read by the provider during loadPackages() to pick which variant to install. */
+ private boolean tokenScoped;
+
+ /** The token-scoped extension documented in {@link ExecuteAndWaitInterceptor}'s javadoc. */
+ public static class TokenizedExecuteAndWaitInterceptor extends ExecuteAndWaitInterceptor {
+ @Override
+ protected String getBackgroundProcessName(ActionProxy proxy) {
+ String token = TokenHelper.getToken();
+ return token == null
+ ? super.getBackgroundProcessName(proxy)
+ : super.getBackgroundProcessName(proxy) + "_" + token;
+ }
+ }
+
+ public void testStockInterceptorSharesOneProcessAcrossTabs() throws Exception {
+ setUpWith(false);
+
+ assertEquals("wait", execute("tab-A"));
+ assertEquals("wait", execute("tab-B"));
+
+ assertEquals("both tabs share a single background process", 1, backgroundProcessKeys().size());
+ }
+
+ public void testTokenScopedInterceptorIsolatesTabs() throws Exception {
+ setUpWith(true);
+
+ assertEquals("wait", execute("tab-A"));
+ assertEquals("wait", execute("tab-B"));
+
+ List keys = backgroundProcessKeys();
+ assertEquals("each tab gets its own background process: " + keys, 2, keys.size());
+ assertTrue(keys.toString(), keys.contains(ExecuteAndWaitInterceptor.KEY + "action1_tab-A"));
+ assertTrue(keys.toString(), keys.contains(ExecuteAndWaitInterceptor.KEY + "action1_tab-B"));
+ }
+
+ private List backgroundProcessKeys() {
+ return session.keySet().stream()
+ .filter(k -> k.startsWith(ExecuteAndWaitInterceptor.KEY))
+ .sorted()
+ .toList();
+ }
+
+ private String execute(String token) throws Exception {
+ Map context = ActionContext.of(new HashMap<>())
+ .withSession(session)
+ .withParameters(HttpParameters.create(Map.of(
+ TokenHelper.DEFAULT_TOKEN_NAME, token,
+ TokenHelper.TOKEN_NAME_FIELD, TokenHelper.DEFAULT_TOKEN_NAME)).build())
+ .withServletRequest(request)
+ .getContextMap();
+ return actionProxyFactory.createActionProxy("", "action1", null, context).execute();
+ }
+
+ private void setUpWith(boolean useTokenScoped) throws Exception {
+ tokenScoped = useTokenScoped;
+ loadConfigurationProviders(new WaitConfigurationProvider());
+
+ session = new HashMap<>();
+ request = new StrutsMockHttpServletRequest();
+ HttpSession httpSession = new StrutsMockHttpSession();
+ request.setSession(httpSession);
+ request.setParameterMap(new HashMap<>());
+
+ container.inject(waitInterceptor);
+ waitInterceptor.init();
+ waitInterceptor.setDelay(0);
+ waitInterceptor.setDelaySleepInterval(0);
+ }
+
+ private class WaitConfigurationProvider implements ConfigurationProvider {
+
+ private Configuration config;
+
+ public void destroy() {
+ waitInterceptor.destroy();
+ }
+
+ public boolean needsReload() {
+ return false;
+ }
+
+ public void init(Configuration configuration) throws ConfigurationException {
+ this.config = configuration;
+ }
+
+ public void loadPackages() throws ConfigurationException {
+ waitInterceptor = tokenScoped ? new TokenizedExecuteAndWaitInterceptor() : new ExecuteAndWaitInterceptor();
+ PackageConfig wait = new PackageConfig.Builder("")
+ .addActionConfig("action1", new ActionConfig.Builder("", "action1", ExecuteAndWaitDelayAction.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("execAndWait", waitInterceptor))
+ .build())
+ .build();
+ config.addPackageConfig("", wait);
+ }
+
+ public void register(ContainerBuilder builder, LocatableProperties props) throws ConfigurationException {
+ builder.factory(ObjectFactory.class);
+ builder.factory(ActionProxyFactory.class, DefaultActionProxyFactory.class);
+ builder.factory(OgnlUtil.class, OgnlUtil.class);
+ }
+ }
+}