Problem/Motivation

Upgrading a Composer plugin can be an error-prone operation. If the Plugin class instantiates objects (or, more specifically, loads classes) in the `activate` method (or any pre-command hook), and those classes are updated as part of a `composer update` operation, then the old version of the code loaded in `activate` will continue to be used in the post-update hooks, whereas in contrast, classes loaded after the update will be loaded from the new code. This can cause old versions of the classes that were pre-loaded attempt to use methods that no longer exist, e.g. if things are removed. If new versions of the classes have references back to the older (pre-loaded) classes, then errors could be thrown if methods that did not previously exist are called.

Proposed resolution

In the Scaffold plugin, avoid instantiating objects in the `activate` hook, and add a test that confirms whether an upgrade from the Scaffold plugin version 8.8.0 to the current code under test (e.g. in the current patch) works.

The ProjectMessage plugin is already written in a way that makes it resilient against changes. Consider opening a follow-on issue to add a test protecting ProjectMessage from future changes that might alter this.

The VendorHardening plugin has both pre and post hooks, so it would be difficult to protect it by lazy-loading objects. There are not many objects in this plugin, though, so it might be possible to protect it by pre-loading all classes in `activate`. The objects that exist have a simple API, though, so it is less likely that they will need to change. Consider opening a follow-on issue to add a test protecting VendorHardening from future changes that might alter this.

Remaining tasks

Make a follow-on issue to continue this work once #3086644: LegacyProject composer templates wrongly reference 8.x + fix test coverage lands.

DONE: #3104985: Increase scope of Composer plugin update tests to cover other plugins

User interface changes

None.

API changes

None.

Data model changes

None.

Release notes snippet

The Core Composer Scaffold plugin was modified to be more defensive about how it handled object creation. Previously, the Handler object was being created during the 'activate' hook (called as soon as the Scaffold plugin is initialized); this patch postpones the creation of this class until the post-update hook. Composer plugins are vulnerable to potential fragility during updates if internal methods and fields are used in certain ways. For example, if the Handler object is created before the 'composer update' runs, by the time the methods in Handler are called, the implementation of the classes it uses may have changed in incompatible ways. This can potentially translate to upgrade problems for the end user even long after the fix is made, e.g. if someone upgrades straight from 8.8.1 to 9.0.0 (depending on what changes are made to the plugin's implementation). A functional test was added to detect this sort of failure.

Comments

greg.1.anderson created an issue. See original summary.

greg.1.anderson’s picture

StatusFileSize
new3.01 KB

Here is a patch that helps to guard the Scaffold plugin from future upgrades, and also adds a test to confirm that upgrading won't break.

greg.1.anderson’s picture

Status: Active » Needs review

Running tests is cool.

greg.1.anderson’s picture

StatusFileSize
new6.26 KB

#2 was missing the new test file, so here is a new patch that contains it.

No interdiff, because the only difference between #2 and #4 is the addition of core/tests/Drupal/Tests/Composer/Plugin/Scaffold/Functional/ScaffoldUpgradeTest.php

mile23’s picture

Status: Needs review » Needs work

+1 on the need to do this, and also the urgency.

Review:

  1. +++ b/composer/Plugin/Scaffold/Plugin.php
    @@ -86,8 +105,25 @@ public function postPackage(PackageEvent $event) {
    +    if (!$this->handler) {
    +      $this->handler = new Handler($this->composer, $this->io);
    

    So I guess we need to keep a handler object around so it can keep track of whether a beforeRequire thing happened. Otherwise I'd suggest just making a new handler for each event.

  2. +++ b/composer/Plugin/Scaffold/Plugin.php
    @@ -86,8 +105,25 @@ public function postPackage(PackageEvent $event) {
    +        $this->handler->beforeRequire($this->beforeRequireEvent);
    

    Handler::beforeRequire() doesn't actually need the event object.

  3. +++ b/core/tests/Drupal/Tests/Composer/Plugin/Scaffold/Functional/ScaffoldUpgradeTest.php
    @@ -0,0 +1,71 @@
    +    // First test: run composer install. This is the same as composer update
    +    // since there is no lock file. Ensure that scaffold operation ran.
    +    $this->mustExec("composer install --no-ansi", $sut);
    

    Do you mean to run update, or are you saying this catches the use case for both install and update?

  4. +++ b/core/tests/Drupal/Tests/Composer/Plugin/Scaffold/Functional/ScaffoldUpgradeTest.php
    @@ -0,0 +1,71 @@
    +    // Next, bring back packagist.org and install core-composer-scaffold:8.8.0
    ...
    +    // Disable packagist.org and upgrade back to the Scaffold plugin under test
    

    I think these comments are reversed.

  5. +++ b/core/tests/Drupal/Tests/Composer/Plugin/Scaffold/Functional/ScaffoldUpgradeTest.php
    @@ -0,0 +1,71 @@
    +    $this->assertRegExp("#Installing drupal/core-composer-scaffold.*Symlinking from#", $stdout);
    

    We could assert against the expected error message if it didn't work correctly, maybe with assertNotRegExp().

greg.1.anderson’s picture

Status: Needs work » Needs review
StatusFileSize
new5.06 KB
new7.64 KB

1. It would be possible to not cache the handler object. However, it's helpful to keep it as a general technique, and furthermore, throwing away the handler object will not save us from upgrade errors, if there are any. The first time a handler object is created, the Handler.php source file will be included. PHP won't allow us to load the same file more than once, so even if we throw away and recreate the handler, the new handler will still be using the old version of the source code.

2. True. I was concerned about changing the Handler API, as I did not want to introduce a breaking change (since the old versions of the Scaffold plugin instantiate a Handler object early). However, in this case it is possible to change this part of the Handler API without breaking the old Scaffold tool, so I went ahead and did it. The test confirm that this internal API change works.

3. The intention of this call to composer install is just to set up the scaffold plugin before we downgrade and then upgrade it again. We are still testing to see that the Scaffold plugin is working here, but this is more of a control than a test. Updated the comment for clarity.

4. The comments are in the correct order. `composer config --unset repositories.packagist.org` removes the "packagist.org": false from the composer.json file. Removing this configuration allows us to use Packagist again. Similarly, when we no longer want to use Packagist, we re-insert the false configuration.

5. If the operation really didn't work, then the exec will return a non-zero status code, and the test will fail. If the SUT is not being re-installed, then the "Symlinking from" line will definitely be missing. There are many possible messages that will NOT appear when the operation works correctly; I'm not sure that it is necessary to try to enumerate them with a NotRegExp. I'll add any that you think are particularly important, though.

mile23’s picture

Status: Needs review » Reviewed & tested by the community

Cool. +1 on the changes. The tests haven't finished, but RTBC from me.

greg.1.anderson’s picture

One coding standard message -- fix for that coming right up.

greg.1.anderson’s picture

StatusFileSize
new690 bytes
new7.95 KB

Updated patch.

greg.1.anderson’s picture

Issue summary: View changes
mile23’s picture

So the follow-up in 'Remaining Tasks' would be to move responsibilities out of the plugin class to avoid breaking plugins during update.

Not as urgent, because those plugins don't have the potential to break your scaffolded files.

greg.1.anderson’s picture

The plugin class is always instantiated by Composer before any part of the plugin runs (activate in the plugin class is the first thing that runs). Once the plugin class is loaded, it won't change, even if the rest of the plugin code is updated. Because of this dynamic, sometimes the solution is to move more responsibilities into the plugin class.

The main weakness is going to be the contract between the plugin class any any other class that it instantiates before a "post" event (e.g. post-update). Any method of any such class called by the plugin class must be frozen forever. If it is not, users will have to know to run composer update --no-plugins when they upgrade that plugin from the older version to any new version with a new API.

When we analyze the other plugins by this criteria, we see that:

The ProjectMessage plugin only has "post" events, and does not instantiate any objects in its activate method. Unless this changes, this plugin is going to be safe to update. This is also a simple plugin, so it should not have a great need to change in a way that would make future updates dangerous.

The VendorHardening plugin has both "pre" and "post" events. It would be really difficult to avoid instantiating any objects until the "post" events are called. However, the VendorHardening plugin also has very few classes: just a file security class, and a config class. As long as the API for these classes never change, the VendorHardening plugin should also be safe to update moving forward.

The remaining task, then, should be to make a follow-on issue to convert the test here to a build test so that we can have upgrade tests for all of the plugins, to avoid the possibility that someone might unwittingly make an unsafe change to some plugin.

greg.1.anderson’s picture

Issue summary: View changes
alexpott’s picture

Status: Reviewed & tested by the community » Needs review
+++ b/composer/Plugin/Scaffold/Plugin.php
@@ -86,8 +106,25 @@ public function postPackage(PackageEvent $event) {
+      $this->requireWasCalled = TRUE;

If the $this->handler object has been instantiated already should we be calling $this->handler->requireWasCalled() or should we doing $this->handler = NULL. Either is what we want. Maybe if the handler is instantiated at this point should we be throwing an error?

greg.1.anderson’s picture

Status: Needs review » Needs work

We are expecting that the handler should never be instantiated at this point. $this->handler = NULL would not help; if the handler was instantiated, then the Handler class would already have been autoloaded, which is unrecoverable.

I'll throw an Error here to catch early instantiation of the handler object.

greg.1.anderson’s picture

Status: Needs work » Needs review
StatusFileSize
new616 bytes
new8.13 KB

New patch that throws an error when expectations are not met.

mile23’s picture

Status: Needs review » Reviewed & tested by the community

Re: #14: I don't see how we'd end up with a stray handler object in onCommand(), but if there was one, it would mean that something was off the rails enough to warrant an error. That's because the scaffolding might be a security update, and we'd rather break your build than let you miss the security update.

greg.1.anderson’s picture

Thanks for the review @mile23. Throwing an error in onCommand will hopefully cause things to "break on the bench" and never get to the point of being committed or released to anyone; at least, that's the theory.

alexpott’s picture

Status: Reviewed & tested by the community » Fixed

Committed and pushed 61fa1bf27c to 9.0.x and a4b6c45a36 to 8.9.x. Thanks!

  • alexpott committed 61fa1bf on 9.0.x
    Issue #3104922 by greg.1.anderson, Mile23, alexpott: Guard against...

  • alexpott committed a4b6c45 on 8.9.x
    Issue #3104922 by greg.1.anderson, Mile23, alexpott: Guard against...
greg.1.anderson’s picture

Woot! Thanks everyone!

alexpott’s picture

Version: 8.9.x-dev » 8.8.x-dev
Category: Task » Bug report
Status: Fixed » Patch (to be ported)

This is more of a bug. I'm going to ask release managers about backport to 8.8.x

greg.1.anderson’s picture

My explanation from a slack convo:

This could still potentially cause upgrade problems, e.g. if someone upgrades straight from 8.8.1 to 9.0.0 (or whatever version changes the handler). Better to make sure the "dangerous" versions are as far in the past as possible.

Thanks for asking about the backport @alexpott.

catch’s picture

If it was runtime code I'd probably say no, but the reasoning in #24 is good, the less versions the unpatched version are in the better.

xjm’s picture

For the future, even if it does not seem that a release note will be needed, it's helpful to write one anyway. This helps us make decisions about the potential disruptions of issues and things like policy-exception backports as well.

greg.1.anderson’s picture

Issue summary: View changes

Updated IS per #26.

mile23’s picture

Status: Patch (to be ported) » Needs review

This is a straight copy of @greg.1.anderson's patch in #16, which still applies to 8.8.x.

mile23’s picture

StatusFileSize
new8.13 KB

Maybe I should add the patch. :-)

mile23’s picture

Status: Needs review » Reviewed & tested by the community

I RTBCd it before (in #17), and it's the same patch. And it's not really mine since it's a copy of #16.

xjm’s picture

Thanks for adding the release note.

Regarding the end of it:

A functional test was added to guard against this sort of failure.

Is this the only change we made to fix their applications? Assuming it's not. What else did we do to fix it and protect them in the future?

mile23’s picture

Is this the only change we made to fix their applications? Assuming it's not. What else did we do to fix it and protect them in the future?

We also have #3104985: Increase scope of Composer plugin update tests to cover other plugins to test other Composer plugins in a similar way.

Beyond that, I'm not sure what else there is to do (or to say in release notes)... The test performs a scaffold using Drupal 8.8.0, and then updates the scaffold to HEAD/patch and checks that it worked.

greg.1.anderson’s picture

Issue summary: View changes

Updated the release note snippet in the IS to be more specific about the exact change that was made here.

catch’s picture

Version: 8.8.x-dev » 8.9.x-dev
Status: Reviewed & tested by the community » Fixed

This fell off the radar. Since we are approaching the end of regular bug releases in 8.8, I think we should leave it in 8.9 at this point.

greg.1.anderson’s picture

This will slightly increase the window where we will need to be extremely cautious about changes made to the scaffold plugin. This is probably okay, though, as we have tests that will catch problems. It will just have to be a limitation we live with for a while.

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.