diff --git a/package_manager/tests/modules/package_manager_bypass/src/ComposerStagerExceptionTrait.php b/package_manager/tests/modules/package_manager_bypass/src/ComposerStagerExceptionTrait.php
index 7284dd31a169b3f83b46be41cb39487433b4c06c..ed2a04894e21364ec11ecc3c20f402c789dbb247 100644
--- a/package_manager/tests/modules/package_manager_bypass/src/ComposerStagerExceptionTrait.php
+++ b/package_manager/tests/modules/package_manager_bypass/src/ComposerStagerExceptionTrait.php
@@ -14,10 +14,10 @@ trait ComposerStagerExceptionTrait {
   /**
    * Sets an exception to be thrown.
    *
-   * @param \Throwable $exception
-   *   The throwable.
+   * @param \Throwable|null $exception
+   *   The exception to throw, or NULL to delete a stored exception.
    */
-  public static function setException(\Throwable $exception): void {
+  public static function setException(?\Throwable $exception): void {
     \Drupal::state()->set(static::class . '-exception', $exception);
   }
 
diff --git a/src/BatchProcessor.php b/src/BatchProcessor.php
index aa816be4949c9c4cd06f4803e17afc653df6b918..08b6327dd5fc604e71be5a531bf7f7d9a210e399 100644
--- a/src/BatchProcessor.php
+++ b/src/BatchProcessor.php
@@ -95,11 +95,16 @@ final class BatchProcessor {
    */
   public static function stage(): void {
     $stage_id = \Drupal::service('session')->get(static::STAGE_ID_SESSION_KEY);
+    $stage = static::getStage();
     try {
-      static::getStage()->claim($stage_id)->stage();
+      $stage->claim($stage_id)->stage();
     }
     catch (\Throwable $e) {
-      static::clean($stage_id);
+      // If the stage was not already destroyed because of this exception
+      // destroy it.
+      if (!$stage->isAvailable()) {
+        static::clean($stage_id);
+      }
       static::storeErrorMessage($e->getMessage());
       throw $e;
     }
diff --git a/tests/src/Functional/ComposerStagerOperationFailureTest.php b/tests/src/Functional/ComposerStagerOperationFailureTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..f75342b51b2439748022eba5e8422fd077f25764
--- /dev/null
+++ b/tests/src/Functional/ComposerStagerOperationFailureTest.php
@@ -0,0 +1,91 @@
+<?php
+
+declare(strict_types = 1);
+
+namespace Drupal\Tests\automatic_updates\Functional;
+
+use Drupal\package_manager_bypass\LoggingBeginner;
+use Drupal\package_manager_bypass\LoggingCommitter;
+use Drupal\package_manager_bypass\NoOpStager;
+use PhpTuf\ComposerStager\Domain\Exception\InvalidArgumentException;
+use PhpTuf\ComposerStager\Domain\Exception\LogicException;
+
+/**
+ * @covers \Drupal\automatic_updates\Form\UpdaterForm
+ * @group automatic_updates
+ * @internal
+ */
+class ComposerStagerOperationFailureTest extends UpdaterFormTestBase {
+
+  /**
+   * Tests Composer operation failure is handled properly.
+   *
+   * @param string $exception_class
+   *   The exception class.
+   * @param string $service_class
+   *   The Composer Stager service which should throw an exception.
+   *
+   * @dataProvider providerComposerOperationFailure
+   */
+  public function testComposerOperationFailure(string $exception_class, string $service_class): void {
+    $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1');
+
+    $page = $this->getSession()->getPage();
+    $assert_session = $this->assertSession();
+    $this->mockActiveCoreVersion('9.8.0');
+    $this->checkForUpdates();
+    $this->drupalGet('/admin/modules/update');
+    $page->hasButton('Update to 9.8.1');
+
+    // Make the specified Composer Stager operation class throw an exception.
+    $exception = new $exception_class('Failure from inside ' . $service_class);
+    call_user_func([$service_class, 'setException'], $exception);
+
+    // Start the update.
+    $page->pressButton('Update to 9.8.1');
+    $this->checkForMetaRefresh();
+    // We can't continue the update after an error in the committer.
+    if ($service_class === LoggingCommitter::class) {
+      $page->pressButton('Continue');
+      $this->checkForMetaRefresh();
+      $this->clickLink('the error page');
+      $assert_session->statusCodeEquals(200);
+      $assert_session->statusMessageContains('Automatic updates failed to apply, and the site is in an indeterminate state. Consider restoring the code and database from a backup.');
+      return;
+    }
+    $this->clickLink('the error page');
+    $assert_session->statusMessageContains($exception->getMessage());
+
+    // Make the same Composer Stager operation class NOT throw an exception.
+    call_user_func([$service_class, 'setException'], NULL);
+
+    // Set up the update to 9.8.1 again as the stage gets destroyed after an
+    // exception occurs.
+    $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1');
+    // Stage should be automatically deleted when an error occurs.
+    $assert_session->buttonNotExists('Delete existing update');
+    // This ensures that we can still update after the exception no longer
+    // exists.
+    $page->pressButton('Update to 9.8.1');
+    $this->checkForMetaRefresh();
+    $page->pressButton('Continue');
+    $this->checkForMetaRefresh();
+    $assert_session->statusMessageContains('Update complete!');
+  }
+
+  /**
+   * Data provider for testComposerOperationFailure().
+   *
+   * @return string[][]
+   *   The test cases.
+   */
+  public function providerComposerOperationFailure(): array {
+    return [
+      'LogicException from Beginner' => [LogicException::class, LoggingBeginner::class],
+      'LogicException from Stager' => [LogicException::class, NoOpStager::class],
+      'InvalidArgumentException from Stager' => [InvalidArgumentException::class, NoOpStager::class],
+      'LogicException from Committer' => [LogicException::class, LoggingCommitter::class],
+    ];
+  }
+
+}