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 {
+ *     &#64;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
+ * &lt;s:url includeParams="all"/&gt; 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);
+        }
+    }
+}

Reply via email to