-
-
Notifications
You must be signed in to change notification settings - Fork 9.4k
[JENKINS-75232] Prevent dynamic plugin installation from registering the same extension twice in some cases #10240
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 9 commits
70faaaf
24f86ad
6817532
4b33059
acba892
5b702a0
6c25f40
b2e5e15
8af7aec
b4b60cd
d23f8ba
99b8be8
2889db2
f73026b
a7ac415
6bf3b09
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
|
|
@@ -2867,11 +2867,21 @@ public void refreshExtensions() throws ExtensionRefreshException { | |||
| 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()) { | ||||
jglick marked this conversation as resolved.
Show resolved
Hide resolved
|
||||
| el.refresh(delta); | ||||
| if (el.refresh(delta)) { | ||||
| listsToFireOnChangeListeners.add(el); | ||||
| } | ||||
| } | ||||
| // 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
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? | ||||
|
|
||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| package hudson; | ||
|
|
||
| import static org.junit.Assert.assertNotNull; | ||
|
|
||
| import jenkins.plugins.dynamic_extension_loading.CustomExtensionLoadedViaConstructor; | ||
| import jenkins.plugins.dynamic_extension_loading.CustomExtensionLoadedViaListener; | ||
| import jenkins.plugins.dynamic_extension_loading.CustomPeriodicWork; | ||
| import org.junit.Rule; | ||
| import org.junit.Test; | ||
| 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 | ||
| 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; | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| package jenkins.plugins.dynamic_extension_loading; | ||
|
|
||
| import hudson.Extension; | ||
| import java.util.logging.Level; | ||
| import java.util.logging.Logger; | ||
|
|
||
| @Extension | ||
| 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 hudson.Extension; | ||
| import java.util.logging.Level; | ||
| import java.util.logging.Logger; | ||
|
|
||
| @Extension | ||
| 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.Extension; | ||
| import hudson.ExtensionList; | ||
| import hudson.model.PeriodicWork; | ||
| import java.util.logging.Level; | ||
| import java.util.logging.Logger; | ||
|
|
||
| @Extension | ||
| 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; |
There was a problem hiding this comment.
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
cloudbeesorg) 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.