This is an automated email from the ASF dual-hosted git repository.
lukaszlenart pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/struts.git
The following commit(s) were added to refs/heads/main by this push:
new 482b4d2ce WW-1742 docs(execAndWait): document token-scoped background
process naming (#1861)
482b4d2ce is described below
commit 482b4d2cec82a760271e2b7b004835e632b0c14f
Author: Lukasz Lenart <[email protected]>
AuthorDate: Sun Aug 23 20:52:13 2026 +0200
WW-1742 docs(execAndWait): document token-scoped background process naming
(#1861)
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 <[email protected]>
---
.../interceptor/ExecuteAndWaitInterceptor.java | 28 ++++
.../ExecuteAndWaitInterceptorTokenScopeTest.java | 163 +++++++++++++++++++++
2 files changed, 191 insertions(+)
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 5ab03f15a..6fe8ed584 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 @@ import java.util.Map;
* 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.
* </p>
+ *
+ * <p>
+ * 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:
+ * </p>
+ *
+ * <pre>
+ * 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;
+ * }
+ * }
+ * </pre>
+ *
+ * <p>
+ * 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.
+ * </p>
* <!-- END SNIPPET: extending -->
*
* <p><u>Example code:</u></p>
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 000000000..e298cb84d
--- /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<String, Object> 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<String> 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<String> backgroundProcessKeys() {
+ return session.keySet().stream()
+ .filter(k -> k.startsWith(ExecuteAndWaitInterceptor.KEY))
+ .sorted()
+ .toList();
+ }
+
+ private String execute(String token) throws Exception {
+ Map<String, Object> 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);
+ }
+ }
+}