Skip to content

Commit c406ccc

Browse files
committed
Merge branch 'master' into infinite-loop-memory-leak-2
2 parents c6b4fa9 + c211223 commit c406ccc

File tree

4 files changed

+111
-27
lines changed

4 files changed

+111
-27
lines changed

.github/workflows/jenkins-security-scan.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,4 @@ jobs:
1818
uses: jenkins-infra/jenkins-security-scan/.github/workflows/jenkins-security-scan.yaml@v2
1919
with:
2020
java-cache: 'maven' # Optionally enable use of a build dependency cache. Specify 'maven' or 'gradle' as appropriate.
21-
java-version: 21 # What version of Java to set up for the build.
21+
# java-version: 21 # Optionally specify what version of Java to set up for the build, or remove to use a recent default.

pom.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,12 @@
8888
<groupId>org.jenkins-ci.plugins</groupId>
8989
<artifactId>scm-api</artifactId>
9090
</dependency>
91+
<dependency>
92+
<groupId>org.awaitility</groupId>
93+
<artifactId>awaitility</artifactId>
94+
<version>4.2.2</version>
95+
<scope>test</scope>
96+
</dependency>
9197
<dependency>
9298
<groupId>org.jenkins-ci.plugins.workflow</groupId>
9399
<artifactId>workflow-job</artifactId>

src/main/java/org/jenkinsci/plugins/workflow/flow/FlowExecutionList.java

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -253,24 +253,25 @@ public ListenableFuture<?> apply(final Function<StepExecution, Void> f) {
253253

254254
for (FlowExecution e : FlowExecutionList.get()) {
255255
ListenableFuture<List<StepExecution>> execs = e.getCurrentExecutions(false);
256-
all.add(execs);
257-
Futures.addCallback(execs,new FutureCallback<List<StepExecution>>() {
258-
@Override
259-
public void onSuccess(@NonNull List<StepExecution> result) {
260-
for (StepExecution e : result) {
261-
try {
262-
f.apply(e);
263-
} catch (RuntimeException x) {
264-
LOGGER.log(Level.WARNING, null, x);
265-
}
256+
// It is important that the combined future's return values do not reference the individual step
257+
// executions, so we use transform instead of addCallback. Otherwise, it is possible to leak references
258+
// to the WorkflowRun for each processed StepExecution in the case where a single live FlowExecution
259+
// has a stuck CpsVmExecutorService that prevents the getCurrentExecutions future from completing.
260+
ListenableFuture<Void> results = Futures.transform(execs, (List<StepExecution> result) -> {
261+
for (StepExecution se : result) {
262+
try {
263+
f.apply(se);
264+
} catch (RuntimeException x) {
265+
LOGGER.log(Level.WARNING, null, x);
266266
}
267267
}
268-
269-
@Override
270-
public void onFailure(@NonNull Throwable t) {
271-
LOGGER.log(Level.WARNING, null, t);
272-
}
268+
return null;
269+
}, MoreExecutors.directExecutor());
270+
ListenableFuture<Void> resultsWithWarningsLogged = Futures.catching(results, Throwable.class, t -> {
271+
LOGGER.log(Level.WARNING, null, t);
272+
return null;
273273
}, MoreExecutors.directExecutor());
274+
all.add(resultsWithWarningsLogged);
274275
}
275276

276277
return Futures.allAsList(all);

src/test/java/org/jenkinsci/plugins/workflow/flow/FlowExecutionListTest.java

Lines changed: 88 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
package org.jenkinsci.plugins.workflow.flow;
2626

27+
import static org.awaitility.Awaitility.await;
2728
import static org.hamcrest.MatcherAssert.assertThat;
2829
import static org.hamcrest.Matchers.containsString;
2930
import static org.hamcrest.Matchers.hasItem;
@@ -38,23 +39,25 @@
3839
import hudson.model.TaskListener;
3940
import hudson.model.queue.QueueTaskFuture;
4041
import java.io.Serializable;
41-
import java.time.Duration;
42-
import java.time.Instant;
42+
import java.lang.ref.WeakReference;
4343
import java.util.Collections;
4444
import java.util.Objects;
45+
import java.util.HashMap;
46+
import java.util.Map;
4547
import java.util.Set;
46-
import java.util.function.Supplier;
48+
import java.util.concurrent.TimeUnit;
4749
import java.util.logging.Level;
4850
import java.util.logging.LogRecord;
4951
import java.util.stream.Collectors;
50-
import org.hamcrest.Matcher;
52+
import jenkins.model.Jenkins;
5153
import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition;
5254
import org.jenkinsci.plugins.workflow.job.WorkflowJob;
5355
import org.jenkinsci.plugins.workflow.job.WorkflowRun;
5456
import org.jenkinsci.plugins.workflow.steps.Step;
5557
import org.jenkinsci.plugins.workflow.steps.StepContext;
5658
import org.jenkinsci.plugins.workflow.steps.StepDescriptor;
5759
import org.jenkinsci.plugins.workflow.steps.StepExecution;
60+
import org.jenkinsci.plugins.workflow.steps.StepExecutions;
5861
import org.jenkinsci.plugins.workflow.test.steps.SemaphoreStep;
5962
import org.junit.ClassRule;
6063
import org.junit.Test;
@@ -63,6 +66,7 @@
6366
import org.jvnet.hudson.test.Issue;
6467
import org.jvnet.hudson.test.LoggerRule;
6568
import org.jvnet.hudson.test.JenkinsSessionRule;
69+
import org.jvnet.hudson.test.MemoryAssert;
6670
import org.jvnet.hudson.test.TestExtension;
6771
import org.jvnet.hudson.test.recipes.LocalData;
6872
import org.kohsuke.stapler.DataBoundConstructor;
@@ -136,7 +140,7 @@ public class FlowExecutionListTest {
136140
at org.jenkinsci.plugins.workflow.flow.FlowExecutionList$ItemListenerImpl.onLoaded(FlowExecutionList.java:175)
137141
at jenkins.model.Jenkins.<init>(Jenkins.java:1019)
138142
*/
139-
waitFor(logging::getMessages, hasItem(containsString("Will resume [org.jenkinsci.plugins.workflow.test.steps.SemaphoreStep")));
143+
await().atMost(5, TimeUnit.SECONDS).until(logging::getMessages, hasItem(containsString("Will resume [org.jenkinsci.plugins.workflow.test.steps.SemaphoreStep")));
140144
WorkflowJob p = r.jenkins.getItemByFullName("p", WorkflowJob.class);
141145
SemaphoreStep.success("wait/1", null);
142146
WorkflowRun b = p.getBuildByNumber(1);
@@ -193,6 +197,34 @@ public class FlowExecutionListTest {
193197
});
194198
}
195199

200+
@Test public void stepExecutionIteratorDoesNotLeakBuildsWhenOneIsStuck() throws Throwable {
201+
sessions.then(r -> {
202+
var notStuck = r.createProject(WorkflowJob.class, "not-stuck");
203+
notStuck.setDefinition(new CpsFlowDefinition("semaphore 'wait'", true));
204+
var notStuckBuild = notStuck.scheduleBuild2(0).waitForStart();
205+
SemaphoreStep.waitForStart("wait/1", notStuckBuild);
206+
WeakReference<Object> notStuckBuildRef = new WeakReference<>(notStuckBuild);
207+
// Create a Pipeline that runs a long-lived task on its CpsVmExecutorService, causing it to get stuck.
208+
var stuck = r.createProject(WorkflowJob.class, "stuck");
209+
stuck.setDefinition(new CpsFlowDefinition("blockSynchronously 'stuck'", false));
210+
var stuckBuild = stuck.scheduleBuild2(0).waitForStart();
211+
await().atMost(5, TimeUnit.SECONDS).until(() -> SynchronousBlockingStep.isStarted("stuck"));
212+
// Make FlowExecutionList$StepExecutionIteratorImpl.applyAll submit a task to the CpsVmExecutorService
213+
// for stuck #1 that will never complete, so the resulting future will never complete.
214+
StepExecution.applyAll(e -> null);
215+
// Let notStuckBuild complete and clean up all references.
216+
SemaphoreStep.success("wait/1", null);
217+
r.waitForCompletion(notStuckBuild);
218+
notStuckBuild = null; // Clear out the local variable in this thread.
219+
Jenkins.get().getQueue().clearLeftItems(); // Otherwise we'd have to wait 5 minutes for the cache to be cleared.
220+
// Make sure that the reference can be GC'd.
221+
MemoryAssert.assertGC(notStuckBuildRef, true);
222+
// Allow stuck #1 to complete so the test can be cleaned up promptly.
223+
SynchronousBlockingStep.unblock("stuck");
224+
r.waitForCompletion(stuckBuild);
225+
});
226+
}
227+
196228
public static class NonResumableStep extends Step implements Serializable {
197229
public static final long serialVersionUID = 1L;
198230
@DataBoundConstructor
@@ -231,14 +263,59 @@ public String getFunctionName() {
231263
}
232264

233265
/**
234-
* Wait up to 5 seconds for the given supplier to return a matching value.
266+
* Blocks the CPS VM thread synchronously (bad!) to test related problems.
235267
*/
236-
private static <T> void waitFor(Supplier<T> valueSupplier, Matcher<T> matcher) throws InterruptedException {
237-
Instant end = Instant.now().plus(Duration.ofSeconds(5));
238-
while (!matcher.matches(valueSupplier.get()) && Instant.now().isBefore(end)) {
239-
Thread.sleep(100L);
268+
public static class SynchronousBlockingStep extends Step implements Serializable {
269+
private static final long serialVersionUID = 1L;
270+
private static final Map<String, State> blocked = new HashMap<>();
271+
private final String id;
272+
273+
@DataBoundConstructor
274+
public SynchronousBlockingStep(String id) {
275+
this.id = id;
276+
if (blocked.put(id, State.NOT_STARTED) != null) {
277+
throw new IllegalArgumentException("Attempting to reuse ID: " + id);
278+
}
279+
}
280+
281+
@Override
282+
public StepExecution start(StepContext context) throws Exception {
283+
return StepExecutions.synchronous(context, c -> {
284+
blocked.put(id, State.BLOCKED);
285+
c.get(TaskListener.class).getLogger().println(id + " blocked");
286+
while (blocked.get(id) == State.BLOCKED) {
287+
Thread.sleep(100L);
288+
}
289+
c.get(TaskListener.class).getLogger().println(id + " unblocked ");
290+
return null;
291+
});
292+
}
293+
294+
public static boolean isStarted(String id) {
295+
var state = blocked.get(id);
296+
return state != null && state != State.NOT_STARTED;
297+
}
298+
299+
public static void unblock(String id) {
300+
blocked.put(id, State.UNBLOCKED);
301+
}
302+
303+
private enum State {
304+
NOT_STARTED,
305+
BLOCKED,
306+
UNBLOCKED,
307+
}
308+
309+
@TestExtension("stepExecutionIteratorDoesNotLeakBuildsWhenOneIsStuck") public static class DescriptorImpl extends StepDescriptor {
310+
@Override
311+
public Set<? extends Class<?>> getRequiredContext() {
312+
return Collections.singleton(TaskListener.class);
313+
}
314+
@Override
315+
public String getFunctionName() {
316+
return "blockSynchronously";
317+
}
240318
}
241-
assertThat("Matcher should have matched after 5s", valueSupplier.get(), matcher);
242319
}
243320

244321
}

0 commit comments

Comments
 (0)