Skip to content
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

[JENKINS-75232] Prevent dynamic plugin installation from registering the same extension twice in some cases #10240

Open
wants to merge 15 commits into
base: master
Choose a base branch
from

Conversation

dwnusbaum
Copy link
Member

@dwnusbaum dwnusbaum commented Feb 4, 2025

See JENKINS-75232. The relevant ExtensionList code has not changed since it was introduced in #1673, but in the real-world case I saw the listener that is causing the issues comes from #3496.

This PR requires jenkinsci/jenkins-test-harness#920 for the new test.

Testing done

A new automated test has been created to demonstrate the bug and test the fix. @jgreffe and I also tested the fix against the real-world plugin which triggered the bug (a proprietary CloudBees plugin).

Proposed changelog entries

  • Prevent dynamic plugin installation from registering the same extension twice in some cases

Proposed upgrade guidelines

N/A

Submitter checklist

Preview Give feedback

Desired reviewers

Before the changes are marked as ready-for-merge:

Maintainer checklist

Preview Give feedback

…the same extension twice in some cases

Co-authored-by: Julien Greffe <[email protected]>
@dwnusbaum dwnusbaum added the bug For changelog: Minor bug. Will be listed after features label Feb 4, 2025
Comment on lines +342 to +343
@Restricted(NoExternalUse.class)
public boolean refresh(ExtensionComponentSet delta) {
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.

Comment on lines +2878 to +2881
// 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();
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.

@dwnusbaum dwnusbaum marked this pull request as ready for review February 4, 2025 21:01
krisstern
krisstern previously approved these changes Feb 14, 2025
@dwnusbaum dwnusbaum requested review from jglick and jtnord February 14, 2025 14:42
Copy link
Member

@jglick jglick left a comment

Choose a reason for hiding this comment

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

Source code looks fine. Recommend trying another approach for test plugin maintenance.

@dwnusbaum dwnusbaum marked this pull request as draft February 14, 2025 16:00
Copy link
Member

@jtnord jtnord left a comment

Choose a reason for hiding this comment

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

Whilst this fixes the test case, I can't help but feel this doesn't fix the case where an Extension loads another extension (either in its constructor, or a Thread it kicks off).

The listener causing a load of an extension by proxy of calling some code, would just be one trigger of the wider cause here?

The suggested alternative of checking for duplicates and not using addAll would seem sane, however IIUC we would create 2 objects for the same extension (not the return the same Object). So if the extension is kicking off something async in its constructor things have already got into a bad state (and the plugin is already liable highly suspect to race conditions)?

Iff we are only creating a single extension for the class but registering it twice I think the addAll fix would be more robust

@dwnusbaum
Copy link
Member Author

Whilst this fixes the test case, I can't help but feel this doesn't fix the case where an Extension loads another extension (either in its constructor, or a Thread it kicks off).

Ok, I can check this case.

however IIUC we would create 2 objects for the same extension (not the return the same Object)

Iff we are only creating a single extension for the class but registering it twice I think the addAll fix would be more robust

As far as I can tell, right now we really are only creating one object, just registering it twice. I'll recheck though.

@jtnord
Copy link
Member

jtnord commented Feb 14, 2025

Iff we are only creating a single extension for the class but registering it twice I think the addAll fix would be more robust

As far as I can tell, right now we really are only creating one object, just registering it twice. I'll recheck though.

if that is the case it may be worthwhile doing both.

  • not calling listeners means less churn if there is interdependency
  • the addAll change means the singleton would be respected if someone does funky stuff in more areas.

@dwnusbaum
Copy link
Member Author

dwnusbaum commented Feb 14, 2025

Whilst this fixes the test case, I can't help but feel this doesn't fix the case where an Extension loads another extension (either in its constructor, or a Thread it kicks off).

@jtnord Indeed this case is still broken. I confirmed that in the problematic cases the extension is only instantiated once but registered twice. I'll add duplicate prevention to ExtensionList.refresh as well.

@jtnord
Copy link
Member

jtnord commented Feb 15, 2025

Whilst this fixes the test case, I can't help but feel this doesn't fix the case where an Extension loads another extension (either in its constructor, or a Thread it kicks off).

@jtnord Indeed this case is still broken. I confirmed that in the problematic cases the extension is only instantiated once but registered twice. I'll add duplicate prevention to ExtensionList.refresh as well.

it's a shame that ExtensionList was a List not a Set and it's likely going to break a lot of things if we changed that :-(
The internal representation (ExtensionList.extensions) could be changed, but the front end code uses indexes into the list for some things IIRC, and changing this smells very risky to me.

@github-actions github-actions bot added the unresolved-merge-conflict There is a merge conflict with the target branch. label Feb 18, 2025
Copy link
Contributor

Please take a moment and address the merge conflicts of your pull request. Thanks!

@github-actions github-actions bot removed the unresolved-merge-conflict There is a merge conflict with the target branch. label Feb 18, 2025
@dwnusbaum
Copy link
Member Author

dwnusbaum commented Feb 18, 2025

it's a shame that ExtensionList was a List not a Set ... changing this smells very risky to me.

Yeah, in retrospect it seems that having this class actually be a collection was not really necessary. If we had instead only defined collection-like methods to covered the critical use cases, I would feel more comfortable refactoring the internal representation.

Copy link
Member

@jtnord jtnord left a comment

Choose a reason for hiding this comment

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

Seems reasonable, did not pay much attention to the test code.

@dwnusbaum dwnusbaum marked this pull request as ready for review February 19, 2025 18:24
Copy link
Member

@jglick jglick left a comment

Choose a reason for hiding this comment

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

Main code change is opaque to me, but test looks nice.

@jglick
Copy link
Member

jglick commented Feb 20, 2025

Expected: Exited with 0 return code
     but: CLI command exited with 1
STDOUT:
Installing a plugin from standard input

STDERR:
-name is deprecated; it is no longer necessary nor honored.

ERROR: Unexpected exception occurred while performing install-plugin command.
jenkins.RestartRequiredException
 at hudson.PluginManager.dynamicLoad(PluginManager.java:957)
 at hudson.PluginManager.dynamicLoad(PluginManager.java:927)
 at hudson.cli.InstallPluginCommand.run(InstallPluginCommand.java:100)
 at hudson.cli.CLICommand.main(CLICommand.java:258)
 at hudson.cli.CLICommandInvoker.invoke(CLICommandInvoker.java:156)
 at hudson.cli.CLICommandInvoker.invokeWithArgs(CLICommandInvoker.java:135)
 at hudson.cli.EnablePluginCommandTest.installTestPlugin(EnablePluginCommandTest.java:54)
 at hudson.cli.EnablePluginCommandTest.enableNoPluginsWithRestartIsNoOp(EnablePluginCommandTest.java:143)

smells like a regression at least in test code?

@github-actions github-actions bot added the unresolved-merge-conflict There is a merge conflict with the target branch. label Feb 20, 2025
Copy link
Contributor

Please take a moment and address the merge conflicts of your pull request. Thanks!

@dwnusbaum
Copy link
Member Author

smells like a regression at least in test code?

Ah, yes, EnablePluginCommandTest.enableNoPluginsWithRestartIsNoOp tries to install variant dynamically, which no longer works because I had to add variant as a test-scope dependency. I'll fix it.

@github-actions github-actions bot removed the unresolved-merge-conflict There is a merge conflict with the target branch. label Feb 20, 2025
Copy link
Member Author

@dwnusbaum dwnusbaum left a comment

Choose a reason for hiding this comment

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

There were quite a few tests that were installing variant dynamically. I tried to fix the failing test and clean things up in 99b8be8.

*/
@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.

//
@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).

@@ -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.

@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!

@@ -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.

@dwnusbaum
Copy link
Member Author

dwnusbaum commented Feb 20, 2025

I think the hudson.model.RunTest.preventXssInBadgeTooltip test failure is an unrelated flake, or at least I don't see any obvious relation to this PR.

@timja
Copy link
Member

timja commented Feb 20, 2025

I think the hudson.model.RunTest.preventXssInBadgeTooltip test failure is an unrelated flake, or at least I don't see any obvious relation to this PR.

likely


FWIW this PR triggered narrowing down a bit jenkinsci/avatar-plugin#18.

I've tested and this PR doesn't resolve it though, but seems to be another issue related to dynamic loading.
The plugin is pretty simple although I didn't manage to trigger the same issue in e.g. theme manager.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug For changelog: Minor bug. Will be listed after features
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants