Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 35 additions & 14 deletions core/src/main/java/hudson/ExtensionList.java
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,12 @@
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.Vector;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
Expand All @@ -48,6 +50,8 @@
import jenkins.ExtensionComponentSet;
import jenkins.model.Jenkins;
import jenkins.util.io.OnMaster;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.NoExternalUse;

/**
* Retains the known extension instances for the given type 'T'.
Expand Down Expand Up @@ -335,27 +339,44 @@
/**
* Used during {@link Jenkins#refreshExtensions()} to add new components into existing {@link ExtensionList}s.
* Do not call from anywhere else.
* @return true if {@link #fireOnChangeListeners} should be called on {@code this} after all lists have been refreshed.
*/
public void refresh(ExtensionComponentSet delta) {
boolean fireOnChangeListeners = false;
@Restricted(NoExternalUse.class)
public boolean refresh(ExtensionComponentSet delta) {
Comment on lines +344 to +345
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an incompatible API change, but given the Javadoc, there really should not be any external callers, and a quick GitHub search (I also checked the cloudbees org) looked ok.

If desired, I can instead introduce a new method and preserve the old one for compatibility. We might still want to add @Restricted(NoExternalUse.class) to the old method if we take that approach though.

synchronized (getLoadLock()) {
if (extensions == null)
return; // not yet loaded. when we load it, we'll load everything visible by then, so no work needed

Collection<ExtensionComponent<T>> found = load(delta);
if (!found.isEmpty()) {
List<ExtensionComponent<T>> l = new ArrayList<>(extensions);
l.addAll(found);
extensions = sort(l);
fireOnChangeListeners = true;
return false; // not yet loaded. when we load it, we'll load everything visible by then, so no work needed

Collection<ExtensionComponent<T>> newComponents = load(delta);
if (!newComponents.isEmpty()) {
// We check to ensure that we do not insert duplicate instances of already-loaded extensions into the list.
// This can happen when dynamically loading a plugin with an extension A that itself loads another
// extension B from the same plugin in some contexts, such as in A's constructor or via a method in A called
// by an ExtensionListListener. In those cases, ExtensionList.refresh may be called on a list that already
// includes the new extensions. Note that ExtensionComponent objects are always unique, even when
// ExtensionComponent.getInstance is identical, so we have to track the components and instances separately
// to handle ordinal sorting and check for dupes.
List<ExtensionComponent<T>> components = new ArrayList<>(extensions);
Set<T> instances = Collections.newSetFromMap(new IdentityHashMap<>());
for (ExtensionComponent<T> component : components) {
instances.add(component.getInstance());
}
boolean fireListeners = false;
for (ExtensionComponent<T> newComponent : newComponents) {
if (instances.add(newComponent.getInstance())) {

Check warning on line 366 in core/src/main/java/hudson/ExtensionList.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 366 is only partially covered, one branch is missing
fireListeners = true;
components.add(newComponent);
}
}
extensions = sort(new ArrayList<>(components));
return fireListeners;
}
}
if (fireOnChangeListeners) {
fireOnChangeListeners();
}
return false;
}

private void fireOnChangeListeners() {
@Restricted(NoExternalUse.class)
public void fireOnChangeListeners() {
for (ExtensionListListener listener : listeners) {
try {
listener.onChange();
Expand Down
14 changes: 12 additions & 2 deletions core/src/main/java/jenkins/model/Jenkins.java
Original file line number Diff line number Diff line change
Expand Up @@ -2867,11 +2867,21 @@
delta = ExtensionComponentSet.union(delta, ef.refresh().filtered());
}

List<ExtensionList> listsToFireOnChangeListeners = new ArrayList<>();
for (ExtensionList el : extensionLists.values()) {
el.refresh(delta);
if (el.refresh(delta)) {
listsToFireOnChangeListeners.add(el);
}
}
for (ExtensionList el : descriptorLists.values()) {
el.refresh(delta);
if (el.refresh(delta)) {

Check warning on line 2877 in core/src/main/java/jenkins/model/Jenkins.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 2877 is only partially covered, one branch is missing
listsToFireOnChangeListeners.add(el);

Check warning on line 2878 in core/src/main/java/jenkins/model/Jenkins.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 2878 is not covered by tests
}
}
// Refresh all extension lists before firing any listeners in case a listener would cause any new extension
// lists to be forcibly loaded, which may lead to duplicate entries for the same extension object in a list.
for (var el : listsToFireOnChangeListeners) {
el.fireOnChangeListeners();
Comment on lines +2881 to +2884
Copy link
Member Author

@dwnusbaum dwnusbaum Feb 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am also happy to consider alternative fixes if anyone has any ideas. One thing I considered was to instead check for duplicates in

but delaying the listener notifications seemed clearer in terms of what we are trying to fix. In general this bug makes me think that there might be deeper problems with dynamic extension loading, but I am not familiar enough with the extension system to know how best to fix things.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate prevention added in 6817532 to fix issues with extensions that load other extensions in their constructors.

}

// TODO: we need some generalization here so that extension points can be notified when a refresh happens?
Expand Down
6 changes: 6 additions & 0 deletions test/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,12 @@ THE SOFTWARE.
<version>343.vdcf37b_a_c81d5</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.jenkins-ci.plugins</groupId>
<artifactId>variant</artifactId>
<version>70.va_d9f17f859e0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
Expand Down
80 changes: 80 additions & 0 deletions test/src/test/java/hudson/ExtensionListRjrTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package hudson;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.empty;
import static org.hamcrest.Matchers.hasSize;
import static org.junit.Assert.assertNotNull;

import java.util.Collection;
import jenkins.plugins.dependee.Dependee;
import jenkins.plugins.dependee.DependeeExtensionPoint;
import jenkins.plugins.dynamic_extension_loading.CustomExtensionLoadedViaConstructor;
import jenkins.plugins.dynamic_extension_loading.CustomExtensionLoadedViaListener;
import jenkins.plugins.dynamic_extension_loading.CustomPeriodicWork;
import jenkins.plugins.optional_depender.OptionalDepender;
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.RealJenkinsRule;
import org.jvnet.hudson.test.RealJenkinsRule.SyntheticPlugin;

public class ExtensionListRjrTest {
@Rule
public RealJenkinsRule rjr = new RealJenkinsRule();

/**
* Check that dynamically loading a plugin does not lead to extension lists with duplicate entries.
* In particular we test extensions that load other extensions in their constructors and extensions that have
* methods that load other extensions and which are called by an {@link ExtensionListListener}.
*/
@Test
@Issue("JENKINS-75232")
public void checkDynamicLoad_singleRegistration() throws Throwable {
var pluginJpi = rjr.createSyntheticPlugin(new SyntheticPlugin(CustomPeriodicWork.class.getPackage())
.shortName("dynamic-extension-loading")
.header("Plugin-Dependencies", "variant:0"));
var fqcn1 = CustomExtensionLoadedViaListener.class.getName();
var fqcn2 = CustomExtensionLoadedViaConstructor.class.getName();
rjr.then(r -> {
r.jenkins.pluginManager.dynamicLoad(pluginJpi);
assertSingleton(r, fqcn1);
assertSingleton(r, fqcn2);
});
}

private static void assertSingleton(JenkinsRule r, String fqcn) throws Exception {
var clazz = r.jenkins.pluginManager.uberClassLoader.loadClass(fqcn);
try {
assertNotNull(ExtensionList.lookupSingleton(clazz));
} catch (Throwable t) {
var list = ExtensionList.lookup(clazz).stream().map(e ->
// TODO: Objects.toIdentityString in Java 19+
e.getClass().getName() + "@" + Integer.toHexString(System.identityHashCode(e))).toList();
System.out.println("Duplicates are: " + list);
throw t;
}
}

@Test
@Issue({ "JENKINS-50336", "JENKINS-60449" })
public void installDependedOptionalPluginWithoutRestart() throws Throwable {
var optionalDependerJpi = rjr.createSyntheticPlugin(new SyntheticPlugin(OptionalDepender.class.getPackage())
.header("Plugin-Dependencies", "variant:0,dependee:0;resolution:=optional"));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A lot easier to follow the test, not to say edit it!

var dependeeJpi = rjr.createSyntheticPlugin(new SyntheticPlugin(Dependee.class.getPackage()).shortName("dependee"));
var fqcn1 = OptionalDepender.class.getName();
var fqcn2 = DependeeExtensionPoint.class.getName();
rjr.then(r -> {
// Load optional-depender.
r.jenkins.pluginManager.dynamicLoad(optionalDependerJpi);
// JENKINS-60449: Extension depending on dependee class isn't loaded
assertThat((Collection<?>) r.jenkins.getExtensionList(fqcn1), empty());
// Load dependee.
r.jenkins.pluginManager.dynamicLoad(dependeeJpi);
// JENKINS-60449: Classes in depender are loaded prior to OptionalDepender being loaded.
assertThat((Collection<?>) r.jenkins.getExtensionList(fqcn1), hasSize(1));
// JENKINS-50336: Extension point from dependee now includes optional extension from optional-depender
assertThat((Collection<?>) r.jenkins.getExtensionList(fqcn2), hasSize(1));
});
}
}
39 changes: 0 additions & 39 deletions test/src/test/java/hudson/PluginManagerTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,6 @@
import java.util.stream.Collectors;
import jenkins.ClassLoaderReflectionToolkit;
import jenkins.RestartRequiredException;
import jenkins.model.GlobalConfiguration;
import jenkins.model.Jenkins;
import net.sf.json.JSONArray;
import net.sf.json.JSONObject;
Expand Down Expand Up @@ -389,28 +388,6 @@ private String callDependerValue() throws Exception {
assertEquals(1, r.jenkins.getExtensionList("org.jenkinsci.plugins.dependencytest.depender.DependerExtension").size());
}

/**
* Load "optional-depender" and then load "dependee".
* Asserts that "depender" can access to "dependee".
*/
@Issue("JENKINS-60449")
@WithPlugin("variant.hpi")
@Test public void installDependedOptionalPluginWithoutRestart() throws Exception {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved into ExtensionListRjrTest.installDependedOptionalPluginWithoutRestart.

Really, most of the tests here should probably be migrated to use RealJenkinsRule and synthetic plugins, but there are a lot of them.

// Load optional-depender.
{
dynamicLoad("optional-depender-0.0.2.hpi");
}
// Extension depending on dependee class isn't loaded
assertTrue(r.jenkins.getExtensionList("org.jenkinsci.plugins.dependencytest.optionaldepender.OptionalDependerExtension").isEmpty());
// Load dependee.
{
dynamicLoad("dependee-0.0.2.hpi");
}

// Extensions in depender are loaded.
assertEquals(1, r.jenkins.getExtensionList("org.jenkinsci.plugins.dependencytest.optionaldepender.OptionalDependerExtension").size());
}

@Issue("JENKINS-21486")
@Test public void installPluginWithObsoleteDependencyFails() throws Exception {
// Load dependee 0.0.1.
Expand Down Expand Up @@ -611,22 +588,6 @@ public void findResourceForPluginFirstClassLoader() {
assertEquals(fromPlugin, fromToolkit);
}

// Sources for jenkins-50336.hpi are available at https://github.com/Vlatombe/jenkins-50336
//
// package io.jenkins.plugins;
// import org.jenkinsci.plugins.variant.OptionalExtension;
// import jenkins.model.GlobalConfiguration;
// @OptionalExtension public class MyGlobalConfiguration extends GlobalConfiguration {}
//
@Issue("JENKINS-50336")
@Test
public void optionalExtensionCanBeFoundAfterDynamicLoadOfVariant() throws Exception {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also moved into ExtensionListRjrTest.installDependedOptionalPluginWithoutRestart.

I did make some changes though to the test to make it more closely match the scenario described in JENKINS-50336 (also so that I could delete variant.hpi).

dynamicLoad("variant.hpi");
assertNotNull(r.jenkins.getPluginManager().getPlugin("variant"));
dynamicLoad("jenkins-50336.hpi");
assertTrue(ExtensionList.lookup(GlobalConfiguration.class).stream().anyMatch(gc -> "io.jenkins.plugins.MyGlobalConfiguration".equals(gc.getClass().getName())));
}

@Test @Issue("JENKINS-64840")
@WithPlugin({"mandatory-depender-0.0.2.hpi", "dependee-0.0.2.hpi", "depender-0.0.2.hpi"})
public void getPluginsSortedByTitle() throws Exception {
Expand Down
15 changes: 6 additions & 9 deletions test/src/test/java/hudson/cli/DisablePluginCommandTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -190,11 +190,10 @@ public void disableAlreadyDisabledPluginNotRestart() throws Exception {
@Ignore("TODO calling restart seems to break Surefire")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clearly could stand to be rewritten.

@Test
@Issue("JENKINS-27177")
@WithPlugin({"variant.hpi", "depender-0.0.2.hpi", "mandatory-depender-0.0.2.hpi", "plugin-first.hpi", "dependee-0.0.2.hpi"})
@WithPlugin({"depender-0.0.2.hpi", "mandatory-depender-0.0.2.hpi", "plugin-first.hpi", "dependee-0.0.2.hpi"})
public void restartAfterDisablePluginsAndErrors() {
assumeNotWindows();
assertThat(disablePluginsCLiCommand("-restart", "variant", "dependee", "depender", "plugin-first", "mandatory-depender"), failedWith(RETURN_CODE_NOT_DISABLED_DEPENDANTS));
assertPluginDisabled("variant");
assertThat(disablePluginsCLiCommand("-restart", "dependee", "depender", "plugin-first", "mandatory-depender"), failedWith(RETURN_CODE_NOT_DISABLED_DEPENDANTS));
assertPluginEnabled("dependee");
assertPluginDisabled("depender");
assertPluginDisabled("plugin-first");
Expand All @@ -207,13 +206,12 @@ public void restartAfterDisablePluginsAndErrors() {
*/
@Test
@Issue("JENKINS-27177")
@WithPlugin({"variant.hpi", "depender-0.0.2.hpi", "mandatory-depender-0.0.2.hpi", "plugin-first.hpi", "dependee-0.0.2.hpi"})
@WithPlugin({"depender-0.0.2.hpi", "mandatory-depender-0.0.2.hpi", "plugin-first.hpi", "dependee-0.0.2.hpi"})
public void disablePluginsStrategyAll() {
assertPluginEnabled("dependee");
assertPluginEnabled("depender");
assertPluginEnabled("mandatory-depender");
assertThat(disablePluginsCLiCommand("-strategy", "all", "variant", "dependee", "plugin-first"), succeeded());
assertPluginDisabled("variant");
assertThat(disablePluginsCLiCommand("-strategy", "all", "dependee", "plugin-first"), succeeded());
assertPluginDisabled("dependee");
assertPluginDisabled("depender");
assertPluginDisabled("plugin-first");
Expand All @@ -225,10 +223,9 @@ public void disablePluginsStrategyAll() {
*/
@Test
@Issue("JENKINS-27177")
@WithPlugin({"variant.hpi", "depender-0.0.2.hpi", "mandatory-depender-0.0.2.hpi", "plugin-first.hpi", "dependee-0.0.2.hpi"})
@WithPlugin({"depender-0.0.2.hpi", "mandatory-depender-0.0.2.hpi", "plugin-first.hpi", "dependee-0.0.2.hpi"})
public void disablePluginsStrategyMandatory() {
assertThat(disablePluginsCLiCommand("-strategy", "mandatory", "variant", "dependee", "plugin-first"), succeeded());
assertPluginDisabled("variant");
assertThat(disablePluginsCLiCommand("-strategy", "mandatory", "dependee", "plugin-first"), succeeded());
assertPluginDisabled("dependee");
assertPluginEnabled("depender");
assertPluginDisabled("plugin-first");
Expand Down
2 changes: 1 addition & 1 deletion test/src/test/java/hudson/cli/EnablePluginCommandTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ public void enablePluginWithRestart() throws IOException {
@Issue("JENKINS-52950")
public void enableNoPluginsWithRestartIsNoOp() {
assumeNotWindows();
String name = "variant";
String name = "icon-shim";
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just swapping for another no-op plugin that is already here. Probably all of these tests should be using RealJenkinsRule and synthetic plugins though.

assertThat(installTestPlugin(name), succeeded());
assertThat(enablePlugins("-restart", name), succeeded());
assertJenkinsNotInQuietMode();
Expand Down
7 changes: 7 additions & 0 deletions test/src/test/java/jenkins/plugins/dependee/Dependee.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package jenkins.plugins.dependee;

public class Dependee {
public static String getValue() {
return "dependee";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package jenkins.plugins.dependee;

import hudson.ExtensionPoint;

public class DependeeExtensionPoint implements ExtensionPoint {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package jenkins.plugins.dynamic_extension_loading;

import java.util.logging.Level;
import java.util.logging.Logger;
import org.jenkinsci.plugins.variant.OptionalExtension;

@OptionalExtension
public class CustomExtensionLoadedViaConstructor {
private static final Logger LOGGER = Logger.getLogger(CustomExtensionLoadedViaConstructor.class.getName());

public CustomExtensionLoadedViaConstructor() {
LOGGER.log(Level.INFO, null, new Exception("Instantiating CustomExtensionLoadedViaConstructor"));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package jenkins.plugins.dynamic_extension_loading;

import java.util.logging.Level;
import java.util.logging.Logger;
import org.jenkinsci.plugins.variant.OptionalExtension;

@OptionalExtension
public class CustomExtensionLoadedViaListener {
private static final Logger LOGGER = Logger.getLogger(CustomExtensionLoadedViaListener.class.getName());

public long recurrencePeriod = 120;

public CustomExtensionLoadedViaListener() {
LOGGER.log(Level.INFO, null, new Exception("Instantiating CustomExtensionLoadedViaListener"));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package jenkins.plugins.dynamic_extension_loading;

import hudson.ExtensionList;
import hudson.model.PeriodicWork;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.jenkinsci.plugins.variant.OptionalExtension;

@OptionalExtension
public class CustomPeriodicWork extends PeriodicWork {

private static final Logger LOGGER = Logger.getLogger(CustomPeriodicWork.class.getName());

public CustomPeriodicWork() {
LOGGER.log(Level.INFO, null, new Exception("Instantiating CustomPeriodicWork"));
ExtensionList.lookupSingleton(CustomExtensionLoadedViaConstructor.class);
}

@Override
protected void doRun() {}

@Override
public long getRecurrencePeriod() {
LOGGER.log(Level.INFO, null, new Exception("Loading CustomExtensionLoadedViaListener"));
return ExtensionList.lookupSingleton(CustomExtensionLoadedViaListener.class).recurrencePeriod;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
@OptionalPackage(requirePlugins = "dynamic-extension-loading")
package jenkins.plugins.dynamic_extension_loading;

import org.jenkinsci.plugins.variant.OptionalPackage;
Loading
Loading