bin/magento module:uninstall never completes, hangs on removing code from Magento codebase:

When you are developing a plugin and are writing an uninstaller for it to clean up your attributes and models, you might notice that it hangs on removing code from Magento Code base:

It then blinks, and hangs and does nothing for a long time, no matter how long you wait. In this blog post, I will try to explain the cause and a solution for the problem. For the solution, scroll down to the end, where the Solution header is.


This problem, without error messages, log entries, or anything noticeable really, is caused by Magento wrapping the composer output in their own buffered output to hide the composer output. You can see this here.

In Magento\Setup\Model\ModuleUninstaller the process to start the uninstall is kickstarted. The message you notice in the console is also visible here. You can see the start of the code here, I'll walk you for a bit through the path where it runs through.
$output->writeln('<info>Removing code from Magento codebase:</info>');
$packages = [];
/** @var \Magento\Framework\Module\PackageInfo $packageInfo */
$packageInfo = $this->objectManager->get(\Magento\Framework\Module\PackageInfoFactory::class)->create();
foreach ($modules as $module) {
    $packages[] = $packageInfo->getPackageName($module);
}
$this->remove->remove($packages);


You will notice that $this->remove is called. We can find what remove is in the constructor and see that this is a \Magento\Framework\Composer\Remove instance.

When we check that class out we see that the composer instance is created by the composer factory and then the command to remove the given package is put into the composer instance to remove it.

If we check out the create method we see that it makes a new \Magento\Composer\MagentoComposerApplication
 
If we check what is called in the code of the factory, versus what is in the constructor of the MagentoComposerApplication

public function create()
{
    return new MagentoComposerApplication($this->pathToComposerHome, $this->pathToComposerJson);
}

Constructor code
public function __construct(
    $pathToComposerHome,
    $pathToComposerJson,
    Application $consoleApplication = null,
    ConsoleArrayInputFactory $consoleArrayInputFactory = null,
    BufferedOutput $consoleOutput = null
)

You will notice that the last three parameters are not utilized, especially the BufferedOutput $consoleOutput.

When this is null, a new BufferedOutput() is made. This BufferedOutput collects all the strings that are outputted during the composer actions, but not output it to STDOUT. This effectively hides all the actions behind a curtain of mystery.

When the command is finished running it returns all collected input.

return $this->consoleOutput->fetch();

But if we check out where this method is called we see that the output is returned from Magento\Framework\Composer\Remove::remove() and if we return back to the first code sample in this blog post above here, we see that it vanishes in the void. Whatever happens in this composer's command, will forever remain a secret.

So we don't see any output, we don't see if anything needs an input, we don't know what is happening that is holding up the workflow. We need to find out what is going on. The only way to get output is to trigger an Exception.

If you would type gibberish and press enter a few times you will see the actual error output, and see that it tries to request the access keys to repo.magento.com, as the output is then outputted by an Exception handler instead of the void.

Loading composer repositories with package information
"Warning from repo.magento.com: You haven't provided your Magento authentication keys. For instructions, visit https://devdocs.magento.com/guides/v2.3/install-gde/prereq/connect-auth.html
    Authentication required (repo.magento.com):
      Username:       
      Password: 
Warning from repo.magento.com: Your Magento authentication keys are invalid. Please double-check your keys in your Marketplace account. For instructions, visit https://devdocs.magento.com/guides/v2.3/install-gde/prereq/connect-auth.html
Warning from repo.magento.com: Your Magento authentication keys are invalid. Please double-check your keys in your Marketplace account. For instructions, visit https://devdocs.magento.com/guides/v2.3/install-gde/prereq/connect-auth.html
Warning from repo.magento.com: Your Magento authentication keys are invalid. Please double-check your keys in your Marketplace account. For instructions, visit https://devdocs.magento.com/guides/v2.3/install-gde/prereq/connect-auth.html

In RemoteFilesystem.php line 748:
                                                                               
  Invalid credentials for 'https://repo.magento.com/packages.json, aborting. 


The solution, and a tip

To resolve this, copy auth.json.sample in your magento web root to auth.json in your webroot and enter in that file your public and private keys for repo.magento.com and any other access keys you might need. You can usually just blanket copy in the contents of your ~/.composer/auth.json if you only do magento development.

Make sure you add an auth.json entry in your .gitignore

You do not want your private access keys ending up on Github or Bitbucket or something. Just include auth.json on the webserver that will host Magento manually if needed there.

After that, if you run the command again, it should run properly, after a wait of roughly 30 seconds whilst it does it's magic.


To ensure you don't get caught by this error again or people who implement your plugin get a decent warning of why this error occurs you can add a sanity check like in the code below
<?php namespace Test\Dummy\Setup;

use Magento\Catalog\Model\Product;
use Magento\Eav\Setup\EavSetupFactory;
use Magento\Framework\Setup\ModuleContextInterface;
use Magento\Framework\Setup\SchemaSetupInterface;
use Magento\Framework\Setup\UninstallInterface;
use Symfony\Component\Console\Output\ConsoleOutput;

class Uninstall implements UninstallInterface
{
    protected $eavSetupFactory;
    protected $output;

    public function __construct(EavSetupFactory $eavSetupFactory, ConsoleOutput $output)
    {
        $this->eavSetupFactory = $eavSetupFactory;
        $this->output = $output;
    }

    /**
     * @inheritDoc
     */
    public function uninstall(SchemaSetupInterface $setup, ModuleContextInterface $context)
    {
        // Sanity check
        $auth = BP . DIRECTORY_SEPARATOR . 'auth.json';
        if(!file_exists($auth)) {
            $this->output->writeln("<fg=red>NO AUTH.JSON FOUND IN \"$auth\"</>");
            $this->output->writeln("<fg=yellow>In the event that this process hang indefinitely on 'Removing code from Magento CodeBase:'</>");
            $this->output->writeln("<fg=yellow>Create in your Magento root folder a auth.json copied from auth.json.sample. </>");
            $this->output->writeln("<fg=yellow>Enter in that file your repo.magento.com and other access keys. </>");
            $this->output->writeln("<fg=red>Do not forget to add auth.json to your .gitignore to prevent leaking your access keys!!!</>");
        }
        
        // Uninstall code
        $eavSetup = $this->eavSetupFactory->create();
        $this->output->writeln("Starting to remove the attributes for something.");
        $eavSetup->removeAttribute(Product::ENTITY, UpgradeData::SOME_ATTRIBUTE);
    }
}


I'll leave it up to you if you'll throw an Exception in that case(recommended) or just chance it. How mean do you feel.

Anyways, I hope that this helped you a little bit.

Comments