Repository: jenkinsci/jenkins
Pull request #10623: Render `t:artifactList` lazily
Author: login: jglick
Labels: name: ready-for-merge
description: The PR is ready to go, and it will be merged soon if there is no negative feedback
name: bug
description: For changelog: Minor bug. Will be listed after features
name: ath-fail
description: The acceptance-test-harness suite needs a forward-compatible change
Created: 2025-05-05T17:50:47Z | Merged: 2025-05-08T17:59:29Z

A controller using `artifact-manager-s3` was slow to render build index pages ([CloudBees-internal issue](https://cloudbees.atlassian.net/browse/SECO-4538)) because the HTTP rendering thread was contacting S3. This artifact list is nice to see but not critical and could be rendered lazily.

### Testing done

Automated test that the main pages do not block. Without fix, test times out.

Set up `artifact-manager-s3`.

```bash
eval `aws configure export-credentials --format env`
mvn -pl war jetty:run
```

Ran a (Pipeline) build using S3 artifacts. Displayed correctly on build and project pages. Turned off my network, visited those pages again. Other content renders fine; the `$stapler` request hangs for a few seconds before timing out and giving a 200 and no artifacts are displayed. Without fix, main page rendering hangs for a few seconds.

### Proposed changelog entries

- Defer rendering a build’s artifact list to a background request, in case this computation is expensive.

### Proposed changelog category

/label bug

### Proposed upgrade guidelines

N/A

Before the changes are marked as `ready-for-merge`:

### Maintainer checklist

- [ ] There are at least two (2) approvals for the pull request and no outstanding requests for change.
- [ ] Conversations in the pull request are over, or it is explicit that a reviewer is not blocking the change.
- [ ] Changelog entries in the pull request title and/or **Proposed changelog entries** are accurate, human-readable, and in the imperative mood.
- [ ] Proper changelog labels are set so that the changelog can be generated automatically.
- [ ] If the change needs additional upgrade steps from users, the `upgrade-guide-needed` label is set and there is a **Proposed upgrade guidelines** section in the pull request title (see [example](https://github.com/jenkinsci/jenkins/pull/4387)).
- [ ] If it would make sense to backport the change to LTS, a Jira issue must exist, be a _Bug_ or _Improvement_, and be labeled as `lts-candidate` to be considered (see [query](https://issues.jenkins.io/issues/?filter=12146)).



Diff:
diff --git a/core/src/main/java/hudson/model/Run.java b/core/src/main/java/hudson/model/Run.java
index e748cc16f82a..975899575d0e 100644
--- a/core/src/main/java/hudson/model/Run.java
+++ b/core/src/main/java/hudson/model/Run.java
@@ -135,6 +135,7 @@
 import org.apache.commons.io.IOUtils;
 import org.apache.commons.jelly.XMLOutput;
 import org.kohsuke.accmod.Restricted;
+import org.kohsuke.accmod.restrictions.DoNotUse;
 import org.kohsuke.accmod.restrictions.NoExternalUse;
 import org.kohsuke.stapler.HttpResponse;
 import org.kohsuke.stapler.QueryParameter;
@@ -1073,6 +1074,11 @@ public List<ParameterValue> getParameterValues() {
         }
     }
 
+    @Restricted(DoNotUse.class) // Jelly
+    public final boolean hasCustomArtifactManager() {
+        return artifactManager != null;
+    }
+
     /**
      * Gets the directory where the artifacts are archived.
      * @deprecated Should only be used from {@link StandardArtifactManager} or subclasses.
diff --git a/core/src/main/java/hudson/widgets/RenderOnDemandClosure.java b/core/src/main/java/hudson/widgets/RenderOnDemandClosure.java
index bd3a5518a75b..a7ca7f12f7be 100644
--- a/core/src/main/java/hudson/widgets/RenderOnDemandClosure.java
+++ b/core/src/main/java/hudson/widgets/RenderOnDemandClosure.java
@@ -72,8 +72,12 @@ public RenderOnDemandClosure(JellyContext context, String attributesToCapture) {
         assert !bodyStack.isEmpty();    // there must be at least one, which is the direct child of <l:renderOnDemand>
 
         Map<String, Object> variables = new HashMap<>();
-        for (String v : Util.fixNull(attributesToCapture).split(","))
-            variables.put(v.intern(), context.getVariable(v));
+        String _attributesToCapture = Util.fixEmpty(attributesToCapture);
+        if (_attributesToCapture != null) {
+            for (String v : _attributesToCapture.split(",")) {
+                variables.put(v.intern(), context.getVariable(v));
+            }
+        }
 
         // capture the current base of context for descriptors
         currentDescriptorByNameUrl = Descriptor.getCurrentDescriptorByNameUrl();
@@ -86,6 +90,7 @@ public RenderOnDemandClosure(JellyContext context, String attributesToCapture) {
         for (String adjunct : _adjuncts) {
             this.adjuncts[i++] = adjunct.intern();
         }
+        LOGGER.fine(() -> "captured " + variables);
     }
 
     /**
@@ -96,6 +101,8 @@ public HttpResponse render() {
         return new HttpResponse() {
             @Override
             public void generateResponse(StaplerRequest2 req, StaplerResponse2 rsp, Object node) throws IOException, ServletException {
+                LOGGER.fine(() -> "rendering " + req.getPathInfo());
+                req.getBoundObjectTable().releaseMe();
                 req.getWebApp().getDispatchValidator().allowDispatch(req, rsp);
                 try {
                     new DefaultScriptInvoker() {
diff --git a/core/src/main/resources/hudson/model/AbstractProject/main.jelly b/core/src/main/resources/hudson/model/AbstractProject/main.jelly
index e4fadc9e3417..0fa78b2d07fd 100644
--- a/core/src/main/resources/hudson/model/AbstractProject/main.jelly
+++ b/core/src/main/resources/hudson/model/AbstractProject/main.jelly
@@ -38,9 +38,7 @@ THE SOFTWARE.
       </t:summary>
     </j:forEach>
 
-    <t:artifactList caption="${%Last Successful Artifacts}"
-        build="${it.lastSuccessfulBuild}" baseURL="lastSuccessfulBuild/"
-        permission="${it.lastSuccessfulBuild.ARTIFACTS}"/>
+    <t:artifactList build="${it.lastSuccessfulBuild}" caption="${%Last Successful Artifacts}"/>
   </table>
 
   <!-- merge fragments from the actions -->
diff --git a/core/src/main/resources/hudson/model/Run/artifactList.jelly b/core/src/main/resources/hudson/model/Run/artifactList.jelly
new file mode 100644
index 000000000000..eac9ad5193b1
--- /dev/null
+++ b/core/src/main/resources/hudson/model/Run/artifactList.jelly
@@ -0,0 +1,71 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+The MIT License
+
+Copyright 2025 CloudBees, Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+-->
+
+<?jelly escape-by-default='true'?>
+<j:jelly xmlns:j="jelly:core" xmlns:st="jelly:stapler" xmlns:l="/lib/layout" xmlns:t="/lib/hudson">
+    <l:ajax>
+        <j:if test="${!h.artifactsPermissionEnabled or h.artifactsPermissionEnabled and it.hasPermission(it.ARTIFACTS)}">
+            <j:set var="artifacts" value="${it.getArtifactsUpTo(it.LIST_CUTOFF+1)}" />
+            <j:if test="${!empty(artifacts)}">
+                <t:summary icon="symbol-cube" href="${rootURL}/${it.url}artifact/">
+                    <a href="${rootURL}/${it.url}artifact/">${caption != null ? caption : request2.getParameter('caption')}</a>
+                    <j:if test="${it.building}">
+                        <p>
+                            ${%building}
+                        </p>
+                    </j:if>
+                    <j:if test="${size(artifacts) le it.LIST_CUTOFF}">
+                        <!-- if not too many, just list them -->
+                        <table class="fileList">
+                            <j:forEach var="f" items="${artifacts}">
+                                <tr>
+                                    <td>
+                                        <l:icon class="icon-document icon-sm"/>
+                                    </td>
+                                    <td>
+                                        <a href="${rootURL}/${it.url}artifact/${f.href}">${f.displayPath}</a>
+                                    </td>
+                                    <td class="fileSize">
+                                        ${h.humanReadableByteSize(f.getFileSize())}
+                                    </td>
+                                    <td>
+                                        <j:if test="${app.fingerprintMap.ready}">
+                                            <a href="${rootURL}/${it.url}artifact/${f.href}/*fingerprint*/">
+                                                <l:icon class="icon-fingerprint icon-sm"/>
+                                            </a>
+                                            <st:nbsp/>
+                                        </j:if>
+                                        <a href="${rootURL}/${it.url}artifact/${f.href}/*view*/">${%view}</a>
+                                    </td>
+                                </tr>
+                            </j:forEach>
+                        </table>
+                    </j:if>
+                    <!-- otherwise just show link to directory browser -->
+                </t:summary>
+            </j:if>
+        </j:if>
+    </l:ajax>
+</j:jelly>
diff --git a/core/src/main/resources/hudson/model/Run/index.jelly b/core/src/main/resources/hudson/model/Run/index.jelly
index c358324918f2..d89235e33e2f 100644
--- a/core/src/main/resources/hudson/model/Run/index.jelly
+++ b/core/src/main/resources/hudson/model/Run/index.jelly
@@ -67,8 +67,7 @@ THE SOFTWARE.
 
 
           <table>
-            <t:artifactList build="${it}" caption="${%Build Artifacts}"
-                            permission="${it.ARTIFACTS}" />
+            <t:artifactList build="${it}" caption="${%Build Artifacts}"/>
 
             <!-- give actions a chance to contribute summary item -->
             <j:forEach var="a" items="${it.allActions}">
diff --git a/core/src/main/resources/hudson/model/Run/new-build-page.jelly b/core/src/main/resources/hudson/model/Run/new-build-page.jelly
index 625424723f41..56dc8da51df6 100644
--- a/core/src/main/resources/hudson/model/Run/new-build-page.jelly
+++ b/core/src/main/resources/hudson/model/Run/new-build-page.jelly
@@ -72,7 +72,7 @@ THE SOFTWARE.
         <l:card title="Summary">
           <div>
             <table>
-              <t:artifactList build="${it}" caption="${%Build Artifacts}" permission="${it.ARTIFACTS}" />
+              <t:artifactList build="${it}" caption="${%Build Artifacts}"/>
 
               <!-- give actions a chance to contribute summary item -->
               <j:forEach var="a" items="${it.allActions}">
diff --git a/core/src/main/resources/lib/hudson/artifactList.jelly b/core/src/main/resources/lib/hudson/artifactList.jelly
index 446696db65d2..d6f44ecf955a 100644
--- a/core/src/main/resources/lib/hudson/artifactList.jelly
+++ b/core/src/main/resources/lib/hudson/artifactList.jelly
@@ -23,60 +23,28 @@ THE SOFTWARE.
 -->
 
 <?jelly escape-by-default='true'?>
-<j:jelly xmlns:j="jelly:core" xmlns:st="jelly:stapler" xmlns:d="jelly:define" xmlns:l="/lib/layout" xmlns:t="/lib/hudson" xmlns:f="/lib/form">
+<j:jelly xmlns:j="jelly:core" xmlns:st="jelly:stapler">
   <st:documentation>
     Generates a listing of the build artifacts.
     Depending on the size of the artifact, this will either produce a list or a link to the directory view.
 
-    <st:attribute name="build" type="hudson.model.Build" use="required">
+    <st:attribute name="build" type="hudson.model.Run" use="required">
       Build object for which the artifacts are displayed
     </st:attribute>
     <st:attribute name="caption" use="required">
       Human readable title text
     </st:attribute>
-    <st:attribute name="baseURL">
-      If the hyperlink to artifacts are at another URL, specify the prefix.
-    </st:attribute>
   </st:documentation>
-  <j:if test="${!h.isArtifactsPermissionEnabled() or h.isArtifactsPermissionEnabled() and h.hasPermission(it,attrs.permission)}">
-    <j:set var="artifacts" value="${build.getArtifactsUpTo(build.LIST_CUTOFF+1)}" />
-    <j:if test="${!empty(artifacts)}">
-      <t:summary icon="symbol-cube" href="${baseURL}artifact/">
-        <a href="${baseURL}artifact/">${caption}</a>
-        <j:if test="${build.building}">
-          <p>
-            ${%building}
-          </p>
-        </j:if>
-        <j:if test="${size(artifacts) le build.LIST_CUTOFF}">
-          <!-- if not too many, just list them -->
-          <table class="fileList">
-            <j:forEach var="f" items="${artifacts}">
-              <tr>
-                <td>
-                  <l:icon class="icon-document icon-sm"/>
-                </td>
-                <td>
-                  <a href="${baseURL}artifact/${f.href}">${f.displayPath}</a>
-                </td>
-                <td class="fileSize">
-                  ${h.humanReadableByteSize(f.getFileSize())}
-                </td>
-                <td>
-                  <j:if test="${app.fingerprintMap.ready}">
-                    <a href="${baseURL}artifact/${f.href}/*fingerprint*/">
-                      <l:icon class="icon-fingerprint icon-sm"/>
-                    </a>
-                    <st:nbsp/>
-                  </j:if>
-                  <a href="${baseURL}artifact/${f.href}/*view*/">${%view}</a>
-                </td>
-              </tr>
-            </j:forEach>
-          </table>
-        </j:if>
-          <!-- otherwise just show link to directory browser -->
-      </t:summary>
-    </j:if>
-  </j:if>
+  <j:choose>
+    <j:when test="${build == null}">
+      <!-- nothing to render -->
+    </j:when>
+    <j:when test="${build.hasCustomArtifactManager()}">
+      <tbody class="artifact-list" data-url="${rootURL}/${build.url}artifactList" data-caption="${caption}"/>
+      <st:adjunct includes="lib.hudson.artifactList"/>
+    </j:when>
+    <j:otherwise>
+      <st:include it="${build}" page="artifactList"/>
+    </j:otherwise>
+  </j:choose>
 </j:jelly>
diff --git a/core/src/main/resources/lib/hudson/artifactList.js b/core/src/main/resources/lib/hudson/artifactList.js
new file mode 100644
index 000000000000..f737016e7e32
--- /dev/null
+++ b/core/src/main/resources/lib/hudson/artifactList.js
@@ -0,0 +1,39 @@
+/*
+ * The MIT License
+ *
+ * Copyright 2025 CloudBees, Inc.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+// TODO package this logic into a generic utility like renderOnDemand/refreshPart with Behaviour.specify
+var e = document.querySelector(".artifact-list");
+fetch(
+  e.getAttribute("data-url") +
+    "?" +
+    new URLSearchParams({ caption: e.getAttribute("data-caption") }),
+).then((rsp) => {
+  if (rsp.ok) {
+    rsp.text().then((responseText) => {
+      e.innerHTML = responseText;
+      Behaviour.applySubtree(e);
+      layoutUpdateCallback.call();
+    });
+  }
+});
diff --git a/test/src/test/java/hudson/model/RunTest.java b/test/src/test/java/hudson/model/RunTest.java
index 4df895309abb..66fb66173aef 100644
--- a/test/src/test/java/hudson/model/RunTest.java
+++ b/test/src/test/java/hudson/model/RunTest.java
@@ -27,6 +27,7 @@
 import static org.hamcrest.MatcherAssert.assertThat;
 import static org.hamcrest.Matchers.containsString;
 import static org.hamcrest.Matchers.instanceOf;
+import static org.hamcrest.Matchers.isA;
 import static org.hamcrest.Matchers.not;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNotNull;
@@ -38,18 +39,23 @@
 import hudson.Launcher;
 import hudson.XmlFile;
 import hudson.model.listeners.SaveableListener;
+import hudson.remoting.Callable;
 import hudson.tasks.ArtifactArchiver;
 import hudson.tasks.BuildTrigger;
 import hudson.tasks.Builder;
 import hudson.tasks.Fingerprinter;
+import java.io.FileNotFoundException;
 import java.io.IOException;
+import java.io.InputStream;
 import java.net.HttpURLConnection;
+import java.net.URI;
 import java.nio.charset.StandardCharsets;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.logging.Logger;
 import jenkins.model.ArtifactManager;
 import jenkins.model.ArtifactManagerConfiguration;
 import jenkins.model.ArtifactManagerFactory;
@@ -71,6 +77,8 @@
 @Category(SmokeTest.class)
 public class RunTest  {
 
+    private static final Logger LOGGER = Logger.getLogger(RunTest.class.getName());
+
     @Rule public JenkinsRule j = new JenkinsRule();
 
     @Issue("JENKINS-17935")
@@ -276,4 +284,104 @@ public static final class Factory extends ArtifactManagerFactory {
         }
     }
 
+    @Test public void slowArtifactManager() throws Exception {
+        ArtifactManagerConfiguration.get().getArtifactManagerFactories().add(new SlowMgr.Factory());
+        var p = j.createFreeStyleProject();
+        j.jenkins.getWorkspaceFor(p).child("f").write("", null);
+        p.getPublishersList().add(new ArtifactArchiver("f"));
+        var b = j.buildAndAssertSuccess(p);
+        assertThat(b.getArtifactManager(), isA(SlowMgr.class));
+        var wc = j.createWebClient();
+        wc.getOptions().setJavaScriptEnabled(false);
+        wc.getPage(b);
+        wc.getPage(p);
+    }
+
+    public static final class SlowMgr extends ArtifactManager {
+        static final AtomicBoolean deleted = new AtomicBoolean();
+
+        @Override public boolean delete() {
+            return !deleted.getAndSet(true);
+        }
+
+        @Override public void onLoad(Run<?, ?> build) {}
+
+        @Override public void archive(FilePath workspace, Launcher launcher, BuildListener listener, Map<String, String> artifacts) {
+            LOGGER.info(() -> "Pretending to archive " + artifacts);
+        }
+
+        @Override public VirtualFile root() {
+            return new VirtualFile() {
+                @Override public <V> V run(Callable<V, IOException> callable) throws IOException {
+                    LOGGER.info("Sleeping");
+                    try {
+                        Thread.sleep(Long.MAX_VALUE);
+                    } catch (InterruptedException x) {
+                        throw new IOException(x);
+                    }
+                    throw new IllegalStateException();
+                }
+
+                @Override public String getName() {
+                    return "";
+                }
+
+                @Override public URI toURI() {
+                    return URI.create("no://where");
+                }
+
+                @Override public VirtualFile getParent() {
+                    return null;
+                }
+
+                @Override public boolean isDirectory() throws IOException {
+                    return true;
+                }
+
+                @Override public boolean isFile() throws IOException {
+                    return false;
+                }
+
+                @Override public boolean exists() throws IOException {
+                    return true;
+                }
+
+                @Override public VirtualFile[] list() throws IOException {
+                    return new VirtualFile[0];
+                }
+
+                @Override public VirtualFile child(String name) {
+                    return null;
+                }
+
+                @Override public long length() throws IOException {
+                    return 0;
+                }
+
+                @Override public long lastModified() throws IOException {
+                    return 0;
+                }
+
+                @Override public boolean canRead() throws IOException {
+                    return true;
+                }
+
+                @Override public InputStream open() throws IOException {
+                    throw new FileNotFoundException();
+                }
+            };
+        }
+
+        public static final class Factory extends ArtifactManagerFactory {
+            @DataBoundConstructor public Factory() {}
+
+            @Override public ArtifactManager managerFor(Run<?, ?> build) {
+                LOGGER.info(() -> "Picking manager for " + build);
+                return new SlowMgr();
+            }
+
+            @TestExtension("slowArtifactManager") public static final class DescriptorImpl extends ArtifactManagerFactoryDescriptor {}
+        }
+    }
+
 }
diff --git a/test/src/test/java/lib/layout/RenderOnDemandTest.java b/test/src/test/java/lib/layout/RenderOnDemandTest.java
index a38c17029939..a949289d834d 100644
--- a/test/src/test/java/lib/layout/RenderOnDemandTest.java
+++ b/test/src/test/java/lib/layout/RenderOnDemandTest.java
@@ -30,12 +30,20 @@
 
 import hudson.model.InvisibleAction;
 import hudson.model.RootAction;
+import hudson.widgets.RenderOnDemandClosure;
+import java.lang.ref.SoftReference;
+import java.lang.ref.WeakReference;
+import java.util.logging.Level;
 import org.htmlunit.ScriptResult;
 import org.htmlunit.WebClientUtil;
 import org.htmlunit.html.HtmlPage;
+import org.junit.Ignore;
 import org.junit.Rule;
 import org.junit.Test;
+import org.jvnet.hudson.test.Issue;
 import org.jvnet.hudson.test.JenkinsRule;
+import org.jvnet.hudson.test.LoggerRule;
+import org.jvnet.hudson.test.MemoryAssert;
 import org.jvnet.hudson.test.TestExtension;
 
 /**
@@ -46,6 +54,7 @@
 public class RenderOnDemandTest {
 
     @Rule public JenkinsRule j = new JenkinsRule();
+    @Rule public LoggerRule logging = new LoggerRule().record(RenderOnDemandClosure.class, Level.FINE);
 
     /**
      * Makes sure that the behavior rules are applied to newly inserted nodes,
@@ -64,27 +73,40 @@ public void testBehaviour() throws Exception {
         assertEquals("AlphaBravoCharlie", r.getJavaScriptResult().toString());
     }
 
-    /*
+    @Ignore("just informational")
+    @Issue("JENKINS-16341")
     @Test
     public void testMemoryConsumption() throws Exception {
-        j.createWebClient().goTo("self/testBehaviour"); // prime caches
+        var wc = j.createWebClient();
+        callTestBehaviour(wc); // prime caches
         int total = 0;
-        for (MemoryAssert.HistogramElement element : MemoryAssert.increasedMemory(new Callable<Void>() {
-            @Override public Void call() throws Exception {
-                j.createWebClient().goTo("self/testBehaviour");
-                return null;
+        int count = 50;
+        for (var element : MemoryAssert.increasedMemory(() -> {
+            for (int i = 0; i < count; i++) {
+                System.err.println("#" + i);
+                callTestBehaviour(wc);
             }
-        }, new Filter() {
-            @Override public boolean accept(Object obj, Object referredFrom, Field reference) {
-                return !obj.getClass().getName().contains("htmlunit");
-            }
-        })) {
+            var o = new Object();
+            var sr = new SoftReference<>(o);
+            var wr = new WeakReference<>(o);
+            o = null;
+            MemoryAssert.assertGC(wr, true);
+            return null;
+        }, (obj, referredFrom, reference) -> !obj.getClass().getName().contains("htmlunit"))) {
             total += element.byteSize;
+            if (element.instanceCount == count) {
+                System.out.print("⚠ ️");
+            }
             System.out.println(element.className + " ×" + element.instanceCount + ": " + element.byteSize);
         }
         System.out.println("total: " + total);
     }
-    */
+
+    private void callTestBehaviour(JenkinsRule.WebClient wc) throws Exception {
+        var p = wc.goTo("self/testBehaviour");
+        p.executeJavaScript("renderOnDemand(document.getElementsBySelector('.lazy')[0])");
+        WebClientUtil.waitForJSExec(p.getWebClient());
+    }
 
     /**
      * Makes sure that scripts get evaluated.


Reviews:
user: login: jglick
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-05T18:07:56Z
user: login: jglick
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-05T18:08:09Z
user: login: jglick
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-05T19:06:10Z
user: login: jglick
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-06T15:59:47Z
user: login: jglick
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-06T16:07:36Z
user: login: jglick
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-06T16:08:05Z
user: login: timja
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-06T21:15:05Z
user: login: timja
body: I tested locally to make sure it wasn't jarring or anything for users and the change wasn't noticeable with only a few artifacts. 
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-05-06T21:23:29Z
user: login: jglick
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-07T10:16:58Z
user: login: jglick
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-07T10:19:31Z
user: login: timja
body: Looks good, not re-tested with custom artifact manager
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-07T13:06:25Z
user: login: Vlatombe
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-07T15:16:43Z
user: login: jglick
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-07T17:46:18Z
user: login: A1exKH
body: LGTM.
state: APPROVED
author_association: NONE
submitted_at: 2025-05-08T09:30:56Z

Review comments:
diff_hunk: @@ -38,45 +38,50 @@ THE SOFTWARE.
       If the hyperlink to artifacts are at another URL, specify the prefix.
     </st:attribute>
   </st:documentation>
-  <j:if test="${!h.isArtifactsPermissionEnabled() or h.isArtifactsPermissionEnabled() and h.hasPermission(it,attrs.permission)}">
-    <j:set var="artifacts" value="${build.getArtifactsUpTo(build.LIST_CUTOFF+1)}" />
-    <j:if test="${!empty(artifacts)}">
-      <t:summary icon="symbol-cube" href="${baseURL}artifact/">
-        <a href="${baseURL}artifact/">${caption}</a>
-        <j:if test="${build.building}">
-          <p>
-            ${%building}
-          </p>
-        </j:if>
-        <j:if test="${size(artifacts) le build.LIST_CUTOFF}">
-          <!-- if not too many, just list them -->
-          <table class="fileList">
-            <j:forEach var="f" items="${artifacts}">
-              <tr>
-                <td>
-                  <l:icon class="icon-document icon-sm"/>
-                </td>
-                <td>
-                  <a href="${baseURL}artifact/${f.href}">${f.displayPath}</a>
-                </td>
-                <td class="fileSize">
-                  ${h.humanReadableByteSize(f.getFileSize())}
-                </td>
-                <td>
-                  <j:if test="${app.fingerprintMap.ready}">
-                    <a href="${baseURL}artifact/${f.href}/*fingerprint*/">
-                      <l:icon class="icon-fingerprint icon-sm"/>
-                    </a>
-                    <st:nbsp/>
-                  </j:if>
-                  <a href="${baseURL}artifact/${f.href}/*view*/">${%view}</a>
-                </td>
-              </tr>
-            </j:forEach>
-          </table>
-        </j:if>
-          <!-- otherwise just show link to directory browser -->
-      </t:summary>
-    </j:if>
+  <j:if test="${build != null and (!h.artifactsPermissionEnabled or h.artifactsPermissionEnabled and h.hasPermission(build, attrs.permission))}">
+    <l:renderOnDemand div="tag" clazz="artifact-list" capture="build,caption,baseURL">
+        <l:ajax>
+            <j:set var="artifacts" value="${build.getArtifactsUpTo(build.LIST_CUTOFF+1)}" />
path: core/src/main/resources/lib/hudson/artifactList.jelly
user: login: jglick
body: (hide WS)
created_at: 2025-05-05T18:07:56Z
author_association: MEMBER
diff_hunk: @@ -38,45 +38,50 @@ THE SOFTWARE.
       If the hyperlink to artifacts are at another URL, specify the prefix.
     </st:attribute>
   </st:documentation>
-  <j:if test="${!h.isArtifactsPermissionEnabled() or h.isArtifactsPermissionEnabled() and h.hasPermission(it,attrs.permission)}">
-    <j:set var="artifacts" value="${build.getArtifactsUpTo(build.LIST_CUTOFF+1)}" />
-    <j:if test="${!empty(artifacts)}">
-      <t:summary icon="symbol-cube" href="${baseURL}artifact/">
-        <a href="${baseURL}artifact/">${caption}</a>
-        <j:if test="${build.building}">
-          <p>
-            ${%building}
-          </p>
-        </j:if>
-        <j:if test="${size(artifacts) le build.LIST_CUTOFF}">
-          <!-- if not too many, just list them -->
-          <table class="fileList">
-            <j:forEach var="f" items="${artifacts}">
-              <tr>
-                <td>
-                  <l:icon class="icon-document icon-sm"/>
-                </td>
-                <td>
-                  <a href="${baseURL}artifact/${f.href}">${f.displayPath}</a>
-                </td>
-                <td class="fileSize">
-                  ${h.humanReadableByteSize(f.getFileSize())}
-                </td>
-                <td>
-                  <j:if test="${app.fingerprintMap.ready}">
-                    <a href="${baseURL}artifact/${f.href}/*fingerprint*/">
-                      <l:icon class="icon-fingerprint icon-sm"/>
-                    </a>
-                    <st:nbsp/>
-                  </j:if>
-                  <a href="${baseURL}artifact/${f.href}/*view*/">${%view}</a>
-                </td>
-              </tr>
-            </j:forEach>
-          </table>
-        </j:if>
-          <!-- otherwise just show link to directory browser -->
-      </t:summary>
-    </j:if>
+  <j:if test="${build != null and (!h.artifactsPermissionEnabled or h.artifactsPermissionEnabled and h.hasPermission(build, attrs.permission))}">
+    <l:renderOnDemand div="tag" clazz="artifact-list" capture="build,caption,baseURL">
+        <l:ajax>
+            <j:set var="artifacts" value="${build.getArtifactsUpTo(build.LIST_CUTOFF+1)}" />
+            <j:if test="${!empty(artifacts)}">
+                <!-- TODO does not scale correctly <t:summary icon="symbol-cube" href="${baseURL}artifact/"> -->
path: core/src/main/resources/lib/hudson/artifactList.jelly
user: login: jglick
body: Maybe https://github.com/jenkinsci/jenkins/blob/9af946f037b53387525c69a42f5e1341f406be3d/src/main/scss/base/_style.scss#L868-L877 is not working somehow?
created_at: 2025-05-05T18:08:09Z
author_association: MEMBER
diff_hunk: @@ -38,45 +38,50 @@ THE SOFTWARE.
       If the hyperlink to artifacts are at another URL, specify the prefix.
     </st:attribute>
   </st:documentation>
-  <j:if test="${!h.isArtifactsPermissionEnabled() or h.isArtifactsPermissionEnabled() and h.hasPermission(it,attrs.permission)}">
-    <j:set var="artifacts" value="${build.getArtifactsUpTo(build.LIST_CUTOFF+1)}" />
-    <j:if test="${!empty(artifacts)}">
-      <t:summary icon="symbol-cube" href="${baseURL}artifact/">
-        <a href="${baseURL}artifact/">${caption}</a>
-        <j:if test="${build.building}">
-          <p>
-            ${%building}
-          </p>
-        </j:if>
-        <j:if test="${size(artifacts) le build.LIST_CUTOFF}">
-          <!-- if not too many, just list them -->
-          <table class="fileList">
-            <j:forEach var="f" items="${artifacts}">
-              <tr>
-                <td>
-                  <l:icon class="icon-document icon-sm"/>
-                </td>
-                <td>
-                  <a href="${baseURL}artifact/${f.href}">${f.displayPath}</a>
-                </td>
-                <td class="fileSize">
-                  ${h.humanReadableByteSize(f.getFileSize())}
-                </td>
-                <td>
-                  <j:if test="${app.fingerprintMap.ready}">
-                    <a href="${baseURL}artifact/${f.href}/*fingerprint*/">
-                      <l:icon class="icon-fingerprint icon-sm"/>
-                    </a>
-                    <st:nbsp/>
-                  </j:if>
-                  <a href="${baseURL}artifact/${f.href}/*view*/">${%view}</a>
-                </td>
-              </tr>
-            </j:forEach>
-          </table>
-        </j:if>
-          <!-- otherwise just show link to directory browser -->
-      </t:summary>
-    </j:if>
+  <j:if test="${build != null and (!h.artifactsPermissionEnabled or h.artifactsPermissionEnabled and h.hasPermission(build, attrs.permission))}">
path: core/src/main/resources/lib/hudson/artifactList.jelly
user: login: jglick
body: https://github.com/jenkinsci/jenkins/blob/9af946f037b53387525c69a42f5e1341f406be3d/core/src/main/resources/hudson/model/AbstractProject/main.jelly#L41-L43 (and the `WorkflowJob` equivalent) uses this facet without checking that there _is_ a last successful build.
created_at: 2025-05-05T19:06:10Z
author_association: MEMBER
diff_hunk: @@ -96,6 +101,8 @@ public HttpResponse render() {
         return new HttpResponse() {
             @Override
             public void generateResponse(StaplerRequest2 req, StaplerResponse2 rsp, Object node) throws IOException, ServletException {
+                LOGGER.fine(() -> "rendering " + req.getPathInfo());
+                req.getBoundObjectTable().releaseMe();
path: core/src/main/java/hudson/widgets/RenderOnDemandClosure.java
user: login: jglick
body: Addresses the [memory leak](https://issues.jenkins.io/browse/JENKINS-16341) at least for the case ~introduced here~ formerly introduced here, where the snippet is actually rendered. If you open a project config page, there are still leaks from the existing code. According to this diagnostic

```groovy
new TreeMap(org.kohsuke.stapler.Stapler.currentRequest.boundObjectTable.table.entries).each {k, v ->
  def o = v.get();
  println "$k: $o";
  if (o instanceof hudson.widgets.RenderOnDemandClosure) {
    println "  $o.currentDescriptorByNameUrl $o.variables";
    o.bodyStack.each {s ->
      def t = s['this$0'];
      if (t) {
        println "    $t";
      }
    };
  }
}; null
```

there are two kinds of leaks easily reproduced:

```
c76cf2f3-e266-43ac-81db-ef47ab24f8f8: hudson.widgets.RenderOnDemandClosure@19873941
  /jenkins/job/p [instance:null, descriptor:hudson.model.BooleanParameterDefinition$DescriptorImpl@50e171fe, it:org.jenkinsci.plugins.workflow.job.WorkflowJob@375c71a1[p]]
    org.kohsuke.stapler.jelly.CustomTagLibrary$1@cdf2810[tag=l:renderOnDemand;at=145:105]
    org.kohsuke.stapler.jelly.CustomTagLibrary$1@3718223f[tag=f:hetero-list;at=76:13]
    org.kohsuke.stapler.jelly.CustomTagLibrary$1@4b8b8213[tag=f:repeatableHeteroProperty;at=28:115]
    org.kohsuke.stapler.jelly.CustomTagLibrary$1@b9616c6[tag=f:descriptorList;at=61:115]
d0579da6-90cd-4af2-99ed-8d33a4aa0246: hudson.widgets.RenderOnDemandClosure@cc176e4
  /jenkins/job/artifacts [descriptor:org.jenkinsci.plugins.workflow.cps.CpsScmFlowDefinition$DescriptorImpl@52f9810e, it:org.jenkinsci.plugins.workflow.job.WorkflowJob@49ae14c2[artifacts]]
    org.kohsuke.stapler.jelly.CustomTagLibrary$1@730b3170[tag=l:renderOnDemand;at=58:63]
    org.kohsuke.stapler.jelly.CustomTagLibrary$1@414fb3ca[tag=f:dropdownListBlock;at=64:41]
    org.kohsuke.stapler.jelly.CustomTagLibrary$1@7bca607f[tag=f:dropdownDescriptorSelector;at=35:139]
```
created_at: 2025-05-06T15:59:47Z
author_association: MEMBER
diff_hunk: @@ -72,8 +72,12 @@ public RenderOnDemandClosure(JellyContext context, String attributesToCapture) {
         assert !bodyStack.isEmpty();    // there must be at least one, which is the direct child of <l:renderOnDemand>
 
         Map<String, Object> variables = new HashMap<>();
-        for (String v : Util.fixNull(attributesToCapture).split(","))
-            variables.put(v.intern(), context.getVariable(v));
+        String _attributesToCapture = Util.fixEmpty(attributesToCapture);
+        if (_attributesToCapture != null) {
path: core/src/main/java/hudson/widgets/RenderOnDemandClosure.java
user: login: jglick
body: Buglet: was binding `{"": null}` when no `capture` was specified.
created_at: 2025-05-06T16:07:35Z
author_association: MEMBER
diff_hunk: @@ -28,7 +28,7 @@ THE SOFTWARE.
     Generates a listing of the build artifacts.
     Depending on the size of the artifact, this will either produce a list or a link to the directory view.
 
-    <st:attribute name="build" type="hudson.model.Build" use="required">
+    <st:attribute name="build" type="hudson.model.Run" use="required">
path: core/src/main/resources/lib/hudson/artifactList.jelly
user: login: jglick
body: Not actually enforced, obviously.
created_at: 2025-05-06T16:08:05Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,29 @@
+/*
+ * The MIT License
+ *
+ * Copyright 2025 CloudBees, Inc.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+// Or could use Behaviour.specify.
+// TODO why is render-on-demand not specified to run this already?
+// hudson-behavior.js hard-codes this for .dropdownList,
+// and RenderOnDemandTest calls it manually which seems like cheating.
+renderOnDemand(document.getElementsBySelector(".artifact-list")[0]);
path: core/src/main/resources/lib/hudson/artifactList.js
user: login: timja
body: this would just get one result or null
```suggestion
renderOnDemand(document.querySelector(".artifact-list"));
```
created_at: 2025-05-06T21:15:05Z
author_association: MEMBER
path: core/src/main/java/hudson/widgets/RenderOnDemandClosure.java
user: login: jglick
body: Leaving changes to this class (and its test) in this PR for now, even though I am no longer actually using this facility here, and starting to think it should simply be deprecated.
created_at: 2025-05-07T10:16:58Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,39 @@
+/*
+ * The MIT License
+ *
+ * Copyright 2025 CloudBees, Inc.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+// TODO package this logic into a generic utility like renderOnDemand/refreshPart with Behaviour.specify
+var e = document.querySelector(".artifact-list");
+fetch(
+  e.getAttribute("data-url") +
+    "?" +
+    new URLSearchParams({ caption: e.getAttribute("data-caption") }),
path: core/src/main/resources/lib/hudson/artifactList.js
user: login: jglick
body: Could `POST` a form body but this seemed easier.
created_at: 2025-05-07T10:19:30Z
author_association: MEMBER
diff_hunk: @@ -38,9 +38,7 @@ THE SOFTWARE.
       </t:summary>
     </j:forEach>
 
-    <t:artifactList caption="${%Last Successful Artifacts}"
-        build="${it.lastSuccessfulBuild}" baseURL="lastSuccessfulBuild/"
-        permission="${it.lastSuccessfulBuild.ARTIFACTS}"/>
+    <t:artifactList build="${it.lastSuccessfulBuild}" caption="${%Last Successful Artifacts}"/>
path: core/src/main/resources/hudson/model/AbstractProject/main.jelly
user: login: jglick
body: The copy in `WorkflowJob` continues to pass two now-ignored attributes.
created_at: 2025-05-07T17:46:18Z
author_association: MEMBER

PR comments:
user: login: jglick
created_at: 2025-05-05T22:12:16Z
body: :eyes: https://issues.jenkins.io/browse/JENKINS-16341
author_association: MEMBER
user: login: jglick
created_at: 2025-05-07T07:34:32Z
body: Going to try to make this conditional on using a nondefault artifact manager.

Really it would be better to avoid `l:renderOnDemand` altogether. My original draft used a separate view with `l:ajax` which sort of worked though it relied on `refreshPart` which is normally used for widgets and not what we want here, where we only want the fragment rendered once. Currently there does not seem to be a prebuilt JavaScript function which would render a given fragment _once_.
author_association: MEMBER
user: login: timja
created_at: 2025-05-07T15:23:38Z
body: /label ready-for-merge

----

This PR is now ready for merge, after ~24 hours, we will merge it if there's no negative feedback.

Thanks!
author_association: MEMBER
user: login: basil
created_at: 2025-05-13T20:59:19Z
body: Breaks ATH:

[latest-linux-jdk21-firefox-split3 / plugins.GradlePluginTest.run_gradle_job_parameters_as_project_properties](https://ci.jenkins.io/job/Core/job/acceptance-test-harness/job/PR-2006/1/testReport/junit/plugins/GradlePluginTest/latest_linux_jdk21_firefox_split3___run_gradle_job_parameters_as_project_properties/)
    [latest-linux-jdk21-firefox-split3 / plugins.GradlePluginTest.run_gradle_wrapper](https://ci.jenkins.io/job/Core/job/acceptance-test-harness/job/PR-2006/1/testReport/junit/plugins/GradlePluginTest/latest_linux_jdk21_firefox_split3___run_gradle_wrapper/)
    [latest-linux-jdk21-firefox-split7 / plugins.GroovyPluginTest.run_groovy](https://ci.jenkins.io/job/Core/job/acceptance-test-harness/job/PR-2006/1/testReport/junit/plugins/GroovyPluginTest/latest_linux_jdk21_firefox_split7___run_groovy/)
    [latest-linux-jdk21-firefox-split3 / plugins.JUnitPluginTest.publish_parametrized_tests](https://ci.jenkins.io/job/Core/job/acceptance-test-harness/job/PR-2006/1/testReport/junit/plugins/JUnitPluginTest/latest_linux_jdk21_firefox_split3___publish_parametrized_tests/)
    [latest-linux-jdk21-firefox-split3 / plugins.JUnitPluginTest.publish_test_result_which_failed](https://ci.jenkins.io/job/Core/job/acceptance-test-harness/job/PR-2006/1/testReport/junit/plugins/JUnitPluginTest/latest_linux_jdk21_firefox_split3___publish_test_result_which_failed/)
    [latest-linux-jdk21-firefox-split3 / plugins.JUnitPluginTest.publish_test_result_which_passed](https://ci.jenkins.io/job/Core/job/acceptance-test-harness/job/PR-2006/1/testReport/junit/plugins/JUnitPluginTest/latest_linux_jdk21_firefox_split3___publish_test_result_which_passed/)
    [latest-linux-jdk21-firefox-split5 / plugins.JavadocPluginTest.publish_javadoc_from_freestyle_job](https://ci.jenkins.io/job/Core/job/acceptance-test-harness/job/PR-2006/1/testReport/junit/plugins/JavadocPluginTest/latest_linux_jdk21_firefox_split5___publish_javadoc_from_freestyle_job/)
    [latest-linux-jdk21-firefox-split5 / plugins.JavadocPluginTest.publish_javadoc_from_matrix_job](https://ci.jenkins.io/job/Core/job/acceptance-test-harness/job/PR-2006/1/testReport/junit/plugins/JavadocPluginTest/latest_linux_jdk21_firefox_split5___publish_javadoc_from_matrix_job/)
    [latest-linux-jdk21-firefox-split5 / plugins.JavadocPluginTest.validate_javadoc_retention](https://ci.jenkins.io/job/Core/job/acceptance-test-harness/job/PR-2006/1/testReport/junit/plugins/JavadocPluginTest/latest_linux_jdk21_firefox_split5___validate_javadoc_retention/)
    [latest-linux-jdk21-firefox-split0 / plugins.JobDslPluginTest.should_look_on_filesystem](https://ci.jenkins.io/job/Core/job/acceptance-test-harness/job/PR-2006/1/testReport/junit/plugins/JobDslPluginTest/latest_linux_jdk21_firefox_split0___should_look_on_filesystem/)
    [latest-linux-jdk21-firefox-split0 / plugins.JobDslPluginTest.should_use_additional_classpath](https://ci.jenkins.io/job/Core/job/acceptance-test-harness/job/PR-2006/1/testReport/junit/plugins/JobDslPluginTest/latest_linux_jdk21_firefox_split0___should_use_additional_classpath/)
    [latest-linux-jdk21-firefox-split2 / plugins.MavenPluginTest.preserve_backslash_in_property](https://ci.jenkins.io/job/Core/job/acceptance-test-harness/job/PR-2006/1/testReport/junit/plugins/MavenPluginTest/latest_linux_jdk21_firefox_split2___preserve_backslash_in_property/)
    [latest-linux-jdk21-firefox-split4 / plugins.PrioritySorterPluginTest.match_jobs_by_name](https://ci.jenkins.io/job/Core/job/acceptance-test-harness/job/PR-2006/1/testReport/junit/plugins/PrioritySorterPluginTest/latest_linux_jdk21_firefox_split4___match_jobs_by_name/)
    [latest-linux-jdk21-firefox-split4 / plugins.PrioritySorterPluginTest.match_jobs_by_view](https://ci.jenkins.io/job/Core/job/acceptance-test-harness/job/PR-2006/1/testReport/junit/plugins/PrioritySorterPluginTest/latest_linux_jdk21_firefox_split4___match_jobs_by_view/)
    [latest-linux-jdk21-firefox-split3 / plugins.SshSlavesPluginTest.connectWithEd25519EncKey](https://ci.jenkins.io/job/Core/job/acceptance-test-harness/job/PR-2006/1/testReport/junit/plugins/SshSlavesPluginTest/latest_linux_jdk21_firefox_split3___connectWithEd25519EncKey/)
    [latest-linux-jdk21-firefox-split3 / plugins.SshSlavesPluginTest.connectWithKey](https://ci.jenkins.io/job/Core/job/acceptance-test-harness/job/PR-2006/1/testReport/junit/plugins/SshSlavesPluginTest/latest_linux_jdk21_firefox_split3___connectWithKey/)
    [latest-linux-jdk21-firefox-split3 / plugins.SshSlavesPluginTest.connectWithPassword](https://ci.jenkins.io/job/Core/job/acceptance-test-harness/job/PR-2006/1/testReport/junit/plugins/SshSlavesPluginTest/latest_linux_jdk21_firefox_split3___connectWithPassword/)
    [latest-linux-jdk21-firefox-split3 / plugins.SshSlavesPluginTest.customJavaPath](https://ci.jenkins.io/job/Core/job/acceptance-test-harness/job/PR-2006/1/testReport/junit/plugins/SshSlavesPluginTest/latest_linux_jdk21_firefox_split3___customJavaPath/)
    [latest-linux-jdk21-firefox-split3 / plugins.SshSlavesPluginTest.customStartup](https://ci.jenkins.io/job/Core/job/acceptance-test-harness/job/PR-2006/1/testReport/junit/plugins/SshSlavesPluginTest/latest_linux_jdk21_firefox_split3___customStartup/)
    [latest-linux-jdk21-firefox-split3 / plugins.SshSlavesPluginTest.jvmOptions](https://ci.jenkins.io/job/Core/job/acceptance-test-harness/job/PR-2006/1/testReport/junit/plugins/SshSlavesPluginTest/latest_linux_jdk21_firefox_split3___jvmOptions/)
    [latest-linux-jdk21-firefox-split0 / plugins.XUnitPluginTest.publish_xunit_results](https://ci.jenkins.io/job/Core/job/acceptance-test-harness/job/PR-2006/1/testReport/junit/plugins/XUnitPluginTest/latest_linux_jdk21_firefox_split0___publish_xunit_results/)
author_association: MEMBER